chore: sync upstream v0.3.6 changes

This commit is contained in:
pphua committed 2026-08-28 14:03:07 +08:00
1 parent 9da521ee4e
commit 22132a4d21
34 files changed
+897 -127

No files matched your search

+91 -2
View File
@@ -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 && (
+36 -7
View File
@@ -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
View File
@@ -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 }) => {
+13 -1
View File
@@ -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 };
+9
View File
@@ -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;