chore: sync upstream v0.3.6 changes
This commit is contained in:
1 parent
9da521ee4e
commit
22132a4d21
34 files changed
+897
-127
No files matched your search
@@ -2,17 +2,18 @@
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import { ChevronLeftIcon, ChevronRightIcon, Loader2Icon, SearchIcon, TerminalIcon } from "lucide-react";
|
||||
import { BarChart3Icon, ChevronLeftIcon, ChevronRightIcon, Loader2Icon, SearchIcon, TerminalIcon } from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { api } from "@/lib/api";
|
||||
import type { CommandRecord } from "@/lib/types";
|
||||
import type { CommandRecord, ToolStat } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function fmtTime(ts: string) {
|
||||
@@ -59,6 +60,11 @@ export default function CommandsPage() {
|
||||
const [total, setTotal] = React.useState(0);
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
|
||||
// 各工具调用次数。弹窗打开时才拉取(多一次聚合查询,不必每次翻页都付)。
|
||||
const [statsOpen, setStatsOpen] = React.useState(false);
|
||||
const [stats, setStats] = React.useState<ToolStat[]>([]);
|
||||
const [statsLoading, setStatsLoading] = React.useState(false);
|
||||
|
||||
// Selected execution is rendered in a right-side detail sheet.
|
||||
const [selected, setSelected] = React.useState<CommandRecord | null>(null);
|
||||
|
||||
@@ -96,6 +102,24 @@ export default function CommandsPage() {
|
||||
};
|
||||
}, [page, size, queryQ, taskFilter]);
|
||||
|
||||
// 统计跟随筛选条件走,和表格描述的是同一批记录(但不分页)。
|
||||
React.useEffect(() => {
|
||||
if (!statsOpen) return;
|
||||
let alive = true;
|
||||
setStatsLoading(true);
|
||||
api
|
||||
.commandStats({ task: taskFilter || undefined, q: queryQ || undefined })
|
||||
.then((r) => alive && setStats(r.stats ?? []))
|
||||
.catch(() => alive && setStats([]))
|
||||
.finally(() => alive && setStatsLoading(false));
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [statsOpen, queryQ, taskFilter]);
|
||||
|
||||
const statsTotal = stats.reduce((n, s) => n + s.total, 0);
|
||||
const statsErrors = stats.reduce((n, s) => n + s.errors, 0);
|
||||
const statsMax = stats.reduce((n, s) => Math.max(n, s.total), 0);
|
||||
const totalPages = Math.max(1, Math.ceil(total / size));
|
||||
const rangeStart = total === 0 ? 0 : page * size + 1;
|
||||
const rangeEnd = page * size + commands.length;
|
||||
@@ -141,6 +165,11 @@ export default function CommandsPage() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Button variant="outline" size="sm" className="h-8" onClick={() => setStatsOpen(true)}>
|
||||
<BarChart3Icon className="size-4" />
|
||||
统计
|
||||
</Button>
|
||||
|
||||
<div className="ml-auto flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span className="tabular-nums">
|
||||
{rangeStart}–{rangeEnd} / {total}
|
||||
@@ -243,6 +272,66 @@ export default function CommandsPage() {
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 工具调用统计:与表格同一批记录(同筛选、不分页) */}
|
||||
<Dialog open={statsOpen} onOpenChange={setStatsOpen}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>工具调用统计</DialogTitle>
|
||||
<DialogDescription>
|
||||
{taskFilter || queryQ ? "当前筛选条件下的全部记录" : "全部工具执行记录"}
|
||||
{stats.length > 0 && (
|
||||
<>
|
||||
{" · "}
|
||||
<span className="tabular-nums">{stats.length}</span> 个工具 ·{" "}
|
||||
<span className="tabular-nums">{statsTotal}</span> 次调用
|
||||
{statsErrors > 0 && (
|
||||
<>
|
||||
{" · 失败 "}
|
||||
<span className="tabular-nums text-red-600 dark:text-red-400">{statsErrors}</span>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{statsLoading && stats.length === 0 ? (
|
||||
<div className="py-10 text-center">
|
||||
<Loader2Icon className="mx-auto h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : stats.length === 0 ? (
|
||||
<div className="py-10 text-center text-sm text-muted-foreground">暂无统计数据</div>
|
||||
) : (
|
||||
<div className="-mr-2 max-h-[55vh] space-y-1 overflow-auto pr-2">
|
||||
{stats.map((s) => (
|
||||
<div key={s.tool} className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3 rounded-md p-2">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate font-mono text-xs font-medium">{s.tool}</span>
|
||||
{s.errors > 0 && (
|
||||
<span className="text-[11px] tabular-nums text-red-600 dark:text-red-400">失败 {s.errors}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1.5 h-1.5 overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className="h-full rounded-full bg-primary"
|
||||
style={{ width: `${statsMax > 0 ? (s.total / statsMax) * 100 : 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-xs font-semibold tabular-nums">{s.total}</div>
|
||||
<div className="text-[11px] tabular-nums text-muted-foreground">
|
||||
{statsTotal > 0 ? ((s.total / statsTotal) * 100).toFixed(1) : "0.0"}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Sheet open={selected !== null} onOpenChange={(open) => !open && setSelected(null)}>
|
||||
<SheetContent className="w-full! max-w-none! gap-0 p-0 sm:w-[46rem]! sm:max-w-[46rem]!">
|
||||
{selected && (
|
||||
|
||||
@@ -309,6 +309,7 @@ function ProfileSheet({
|
||||
const [effort, setEffort] = React.useState(NONE);
|
||||
const [priority, setPriority] = React.useState("0"); // 轮询顺位;越大越先
|
||||
const [poolExclude, setPoolExclude] = React.useState(false);
|
||||
const [streaming, setStreaming] = React.useState(true); // true=流式(默认);false=非流式
|
||||
const [testing, setTesting] = React.useState(false);
|
||||
const [saving, setSaving] = React.useState(false);
|
||||
const [models, setModels] = React.useState<string[]>([]);
|
||||
@@ -331,6 +332,7 @@ function ProfileSheet({
|
||||
setEffort(fromStore(profile?.reasoning_effort));
|
||||
setPriority(String(profile?.priority ?? 0));
|
||||
setPoolExclude(profile?.pool_exclude ?? false);
|
||||
setStreaming(profile?.streaming ?? true);
|
||||
setApiKey("");
|
||||
setKeyHint(profile?.api_key_hint ?? "");
|
||||
setModels([]);
|
||||
@@ -365,8 +367,22 @@ function ProfileSheet({
|
||||
try {
|
||||
// 用配置实际会跑的思考参数来测,这样不支持该字段的模型在这里就失败,
|
||||
// 而不是等到跑任务时才炸。传 profile id:Key 输入框留空时用已存的 Key。
|
||||
const r = await api.testLLM(format, model, baseUrl, apiKey, proxy, toStore(thinkingType), toStore(effort), profileId);
|
||||
if (r.ok) toast.success(`连接成功 · ${r.latency_ms ?? "?"}ms · ${r.model ?? model}`);
|
||||
const r = await api.testLLM(
|
||||
format,
|
||||
model,
|
||||
baseUrl,
|
||||
apiKey,
|
||||
proxy,
|
||||
toStore(thinkingType),
|
||||
toStore(effort),
|
||||
profileId,
|
||||
streaming,
|
||||
);
|
||||
// 回复内容一并展示:看得见模型确实说了话,才算和会话里跑通是一回事。
|
||||
if (r.ok)
|
||||
toast.success(`连接成功 · ${r.latency_ms ?? "?"}ms · ${r.model ?? model}`, {
|
||||
description: r.reply ? `回复:${r.reply}` : undefined,
|
||||
});
|
||||
else toast.error(`连接失败:${r.error ?? "未知"}`);
|
||||
} catch (e) {
|
||||
toast.error(`测试出错:${(e as Error).message}`);
|
||||
@@ -398,6 +414,7 @@ function ProfileSheet({
|
||||
reasoning_effort: toStore(effort),
|
||||
priority: Number(priority) || 0,
|
||||
pool_exclude: poolExclude,
|
||||
streaming,
|
||||
});
|
||||
if (isNew) toast.success(`已新建:${name.trim()}(在卡片上「设为激活」以启用)`);
|
||||
else toast.success(profile?.is_default ? "已保存,激活配置即时生效,无需重启" : "已保存");
|
||||
@@ -525,7 +542,8 @@ function ProfileSheet({
|
||||
onChange={(e) => setProxy(e.target.value)}
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
仅 LLM 出站请求走此代理,支持 http/https/socks5,可带账号密码(如 socks5://user:pass@host:port,密码含特殊字符需 URL 编码);留空表示不使用代理(直连)。
|
||||
仅 LLM 出站请求走此代理,支持 http/https/socks5,可带账号密码(如
|
||||
socks5://user:pass@host:port,密码含特殊字符需 URL 编码);留空表示不使用代理(直连)。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -595,6 +613,17 @@ function ProfileSheet({
|
||||
</div>
|
||||
<Switch checked={poolExclude} onCheckedChange={setPoolExclude} aria-label="不参与轮询" />
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4 border-t pt-3">
|
||||
<div className="grid gap-0.5">
|
||||
<Label className="text-sm">流式输出 · streaming</Label>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
开启(默认)走流式 SSE,有运行中实时进度与实时 token 计数。 关闭则走真·非流式(stream:false,
|
||||
一次性返回完整响应)——可绕开部分网关糟糕的 SSE 实现(空帧 / 思考字段丢帧),
|
||||
代价是失去运行中的实时进度。
|
||||
</p>
|
||||
</div>
|
||||
<Switch checked={streaming} onCheckedChange={setStreaming} aria-label="流式输出" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 rounded-lg border p-3">
|
||||
@@ -602,8 +631,8 @@ function ProfileSheet({
|
||||
<div className="grid gap-0.5">
|
||||
<Label className="text-sm">思考开关 · thinking.type</Label>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
控制是否发送 thinking 字段。不发送=不带该字段(兼容 MiniMax 等不支持
|
||||
的模型);关闭=发 disabled;开启=发 enabled。与下面的强度互相独立。
|
||||
控制是否发送 thinking 字段。不发送=不带该字段(兼容 MiniMax 等不支持 的模型);关闭=发
|
||||
disabled;开启=发 enabled。与下面的强度互相独立。
|
||||
</p>
|
||||
</div>
|
||||
<Select value={thinkingType} onValueChange={setThinkingType}>
|
||||
@@ -623,8 +652,8 @@ function ProfileSheet({
|
||||
<div className="grid gap-0.5">
|
||||
<Label className="text-sm">思考强度 · reasoning_effort</Label>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
独立的强度档位(OpenAI reasoning_effort / Anthropic output_config.effort)。
|
||||
有些接口没有 thinking 字段、只靠强度即可激活思考,故可单独设置、不发送思考开关。
|
||||
独立的强度档位(OpenAI reasoning_effort / Anthropic output_config.effort)。 有些接口没有 thinking
|
||||
字段、只靠强度即可激活思考,故可单独设置、不发送思考开关。
|
||||
</p>
|
||||
</div>
|
||||
<Select value={effort} onValueChange={setEffort}>
|
||||
|
||||
+13
-1
@@ -74,6 +74,7 @@ import type {
|
||||
TokenTotal,
|
||||
TokenUsage,
|
||||
Tool,
|
||||
ToolStat,
|
||||
TrafficDetail,
|
||||
TrafficHost,
|
||||
TrafficResp,
|
||||
@@ -677,8 +678,10 @@ export const api = {
|
||||
thinking_type = "",
|
||||
reasoning_effort = "",
|
||||
profile_id?: number,
|
||||
streaming = true, // 用该配置真实的收发模式来测,别让"流式能通、非流式不通"漏到会话里
|
||||
) =>
|
||||
post<{ ok: boolean; error?: string; latency_ms?: number; model?: string }>("/llm/test", {
|
||||
// reply = 模型实际回复(已截断);一个字都不回的配置后端直接判失败
|
||||
post<{ ok: boolean; error?: string; latency_ms?: number; model?: string; reply?: string }>("/llm/test", {
|
||||
provider,
|
||||
model,
|
||||
base_url,
|
||||
@@ -687,6 +690,7 @@ export const api = {
|
||||
thinking_type,
|
||||
reasoning_effort,
|
||||
profile_id,
|
||||
streaming,
|
||||
}),
|
||||
llmProfiles: () => get<{ profiles: LLMProfile[] }>("/llm/profiles").then((r) => arr(r.profiles)),
|
||||
saveLLMProfile: (p: {
|
||||
@@ -704,6 +708,7 @@ export const api = {
|
||||
reasoning_effort?: string; // ""(不发送)|"low"|"medium"|"high"|"xhigh"|"max"
|
||||
priority?: number; // 轮询顺位,越大越先用
|
||||
pool_exclude?: boolean; // true=不作为故障转移目标
|
||||
streaming?: boolean; // true(默认)=流式 | false=非流式
|
||||
}) => post<{ id: number }>("/llm/profiles", p),
|
||||
deleteLLMProfile: (id: string) => del<{ deleted: number }>(`/llm/profiles/${id}`),
|
||||
activateLLMProfile: (id: string) => post<{ ok: boolean }>("/llm/profiles/active", { id: Number(id) }),
|
||||
@@ -972,6 +977,13 @@ export const api = {
|
||||
sp.set("size", String(params?.size ?? 50));
|
||||
return get<{ commands: CommandRecord[]; total: number }>(`/commands?${sp}`);
|
||||
},
|
||||
// 各工具调用次数;沿用列表的 task/q 筛选,统计的是整个结果集而非当前页。
|
||||
commandStats: (params?: { task?: string; q?: string }) => {
|
||||
const sp = new URLSearchParams();
|
||||
if (params?.task) sp.set("task", params.task);
|
||||
if (params?.q) sp.set("q", params.q);
|
||||
return get<{ stats: ToolStat[] }>(`/commands/stats?${sp}`);
|
||||
},
|
||||
|
||||
// ---- LLM records ----
|
||||
llmRecords: (params?: { model?: string; session?: string; task?: string; page?: number; size?: number }) => {
|
||||
|
||||
@@ -1493,6 +1493,17 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
|
||||
|
||||
// ── 工具执行历史 ──
|
||||
if (path === "/commands" && m === "GET") return { commands: D.commandRecords, total: D.commandRecords.length };
|
||||
if (path === "/commands/stats" && m === "GET") {
|
||||
const tally = new Map<string, { tool: string; total: number; errors: number }>();
|
||||
for (const c of D.commandRecords) {
|
||||
const tool = c.tool || "-";
|
||||
const s = tally.get(tool) ?? { tool, total: 0, errors: 0 };
|
||||
s.total++;
|
||||
if (c.is_error) s.errors++;
|
||||
tally.set(tool, s);
|
||||
}
|
||||
return { stats: [...tally.values()].sort((a, b) => b.total - a.total || a.tool.localeCompare(b.tool)) };
|
||||
}
|
||||
|
||||
// ── LLM ──
|
||||
if (path === "/llm/records" && m === "GET") return { records: mockLLMRecords, total: mockLLMRecords.length };
|
||||
@@ -1509,7 +1520,8 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
|
||||
}
|
||||
if (path === "/llm" && m === "GET") return D.llmConfig;
|
||||
if (path === "/llm" && m === "POST") return { ok: true };
|
||||
if (path === "/llm/test") return { ok: true, latency_ms: 128, model: String(b.model ?? "claude-opus-4-8") };
|
||||
if (path === "/llm/test")
|
||||
return { ok: true, latency_ms: 128, model: String(b.model ?? "claude-opus-4-8"), reply: "OK" };
|
||||
if (path === "/llm/profiles" && m === "GET") return { profiles: D.llmProfiles };
|
||||
if (path === "/llm/profiles" && m === "POST") return { id: Number(b.id) || 3 };
|
||||
if (path === "/llm/profiles/active") return { ok: true };
|
||||
|
||||
@@ -869,6 +869,8 @@ export interface LLMProfile {
|
||||
priority?: number;
|
||||
// true = 不作为故障转移目标(仍可被 agent/任务显式绑定使用)。
|
||||
pool_exclude?: boolean;
|
||||
// true(默认)= 流式(SSE) | false = 真·非流式(stream:false,一次性返回)。
|
||||
streaming?: boolean;
|
||||
}
|
||||
|
||||
// ---- LLM 轮询(故障转移)----
|
||||
@@ -1133,6 +1135,13 @@ export interface CommandRecord {
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
// 单个工具的调用统计(/commands/stats);errors 为其中失败的次数。
|
||||
export interface ToolStat {
|
||||
tool: string;
|
||||
total: number;
|
||||
errors: number;
|
||||
}
|
||||
|
||||
// ---- LLM recording ----
|
||||
export interface LLMRecordItem {
|
||||
id: number;
|
||||
|
||||
Reference in new issue
Block a user