feat: LLM 录制开关、模型列表第三方回退、命令页改为工具执行

在 review PR5 基础上的三处改进:

1. LLM 录制开关(默认关,settings.llm_record)
   - 录制页数据大小下拉框旁加开关;关闭时 Recorder.Stream 直接透传,
     不序列化、不累积、不写库,零开销。切换即时生效,无需重建 agent。

2. 模型列表拉取支持 Anthropic 兼容第三方
   - pgListModels 改为按序尝试多个候选端点:anthropic 的 /v1/models 拿不到时,
     剥掉 /anthropic 回退到 OpenAI 式 /models(自动切 Bearer 头)。
     修复 DeepSeek(anthropic) 加载模型 404。

3. 命令页改为「工具执行」列表
   - ListCommands 去掉 tool='Bash' 过滤,覆盖所有 tool_use;返回工具名列,
     搜索匹配工具名或参数。前端加「工具」列、标题/侧边栏改为「工具执行」。

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
Autumn-27andClaude Opus 4.8 committed 2026-08-07 09:32:01 -04:00
1 parent 10f891a21d
commit ad9f14f140
12 files changed
+225 -83

No files matched your search

+22 -12
View File
@@ -43,12 +43,13 @@ function fmtTime(ts: string) {
});
}
// extractCommand parses the tool_use detail (JSON like {"command":"..."}) and
// returns just the command string. Falls back to raw text if not JSON.
function extractCommand(raw: string): string {
// toolInput renders a tool's raw input for display. Bash's {"command":"..."} is
// unwrapped to the bare command; other tools show their pretty-printed JSON args.
function toolInput(raw: string): string {
try {
const obj = JSON.parse(raw);
if (obj && typeof obj.command === "string") return obj.command;
return JSON.stringify(obj, null, 2);
} catch { /* not JSON */ }
return raw;
}
@@ -114,7 +115,7 @@ export default function CommandsPage() {
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-2">
<TerminalIcon className="h-5 w-5 text-muted-foreground" />
<h1 className="text-xl font-semibold tracking-tight">命令记录</h1>
<h1 className="text-xl font-semibold tracking-tight">工具执行</h1>
<Badge variant="secondary">{total}</Badge>
</div>
</div>
@@ -124,7 +125,7 @@ export default function CommandsPage() {
<div className="relative max-w-sm flex-1">
<SearchIcon className="absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="搜索命令..."
placeholder="搜索工具 / 参数..."
value={query}
onChange={(e) => setQuery(e.target.value)}
className="h-8 pl-8"
@@ -187,21 +188,22 @@ export default function CommandsPage() {
<TableHead className="w-[130px]">时间</TableHead>
<TableHead className="w-[60px]">任务</TableHead>
<TableHead className="w-[90px]">Worker</TableHead>
<TableHead>命令</TableHead>
<TableHead className="w-[110px]">工具</TableHead>
<TableHead>输入</TableHead>
<TableHead className="w-[60px]">状态</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading && commands.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="py-12 text-center">
<TableCell colSpan={6} className="py-12 text-center">
<Loader2Icon className="mx-auto h-5 w-5 animate-spin text-muted-foreground" />
</TableCell>
</TableRow>
) : commands.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="py-12 text-center text-sm text-muted-foreground">
暂无命令记录
<TableCell colSpan={6} className="py-12 text-center text-sm text-muted-foreground">
暂无工具执行记录
</TableCell>
</TableRow>
) : (
@@ -225,9 +227,14 @@ export default function CommandsPage() {
{cmd.worker || "-"}
</Badge>
</TableCell>
<TableCell>
<Badge variant="secondary" className="text-xs font-mono">
{cmd.tool || "-"}
</Badge>
</TableCell>
<TableCell className="max-w-0">
<code className="block truncate font-mono text-xs">
{truncate(extractCommand(cmd.command), CMD_MAX_LEN)}
{truncate(toolInput(cmd.command), CMD_MAX_LEN)}
</code>
</TableCell>
<TableCell>
@@ -256,6 +263,9 @@ export default function CommandsPage() {
<Badge variant="outline" className="text-xs font-mono">
{selected.worker || "-"}
</Badge>
<Badge variant="secondary" className="text-xs font-mono">
{selected.tool || "-"}
</Badge>
<span className="text-xs text-muted-foreground">
{fmtTime(selected.created_at)}
</span>
@@ -277,11 +287,11 @@ export default function CommandsPage() {
<div className="grid min-h-0 flex-1 grid-cols-2 divide-x">
<div className="flex min-h-0 min-w-0 flex-col">
<div className="border-b px-3 py-1 text-[11px] font-medium text-muted-foreground">
输入 Command
输入 Input
</div>
<div className="min-h-0 flex-1 overflow-auto">
<pre className="p-3 font-mono text-xs break-all whitespace-pre-wrap">
{extractCommand(selected.command)}
{toolInput(selected.command)}
</pre>
</div>
</div>
@@ -14,6 +14,7 @@ import { Card } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import {
Table,
TableBody,
@@ -74,11 +75,39 @@ export default function LLMRecordsPage() {
const [total, setTotal] = React.useState(0);
const [loading, setLoading] = React.useState(false);
// Recording on/off toggle (settings.llm_record; default off). When off the
// backend records nothing.
const [recEnabled, setRecEnabled] = React.useState(false);
const [recBusy, setRecBusy] = React.useState(false);
// Inline detail panel (Burp-style split, not a dialog)
const [selected, setSelected] = React.useState<LLMRecordItem | null>(null);
const [detail, setDetail] = React.useState<LLMRecordDetail | null>(null);
const [detailLoading, setDetailLoading] = React.useState(false);
// Load recording toggle state on mount.
React.useEffect(() => {
let alive = true;
api
.settings()
.then((s) => { if (alive) setRecEnabled(!!s.llm_record); })
.catch(() => {});
return () => { alive = false; };
}, []);
const toggleRecording = async (on: boolean) => {
setRecBusy(true);
setRecEnabled(on); // optimistic
try {
const s = await api.setSettings({ llm_record: on });
setRecEnabled(!!s.llm_record);
} catch {
setRecEnabled(!on); // revert on failure
} finally {
setRecBusy(false);
}
};
// Debounce session filter.
React.useEffect(() => {
const t = setTimeout(() => setSessionQ(session.trim()), 300);
@@ -168,6 +197,26 @@ export default function LLMRecordsPage() {
</SelectContent>
</Select>
{/* Recording on/off — off means no LLM calls are recorded */}
<div className="flex items-center gap-2 rounded-md border px-2.5 py-1">
<Switch
id="llm-rec-toggle"
size="sm"
checked={recEnabled}
onCheckedChange={toggleRecording}
disabled={recBusy}
/>
<label
htmlFor="llm-rec-toggle"
className={cn(
"cursor-pointer text-xs font-medium select-none",
recEnabled ? "text-foreground" : "text-muted-foreground",
)}
>
{recEnabled ? "录制中" : "已关闭"}
</label>
</div>
<div className="ml-auto flex items-center gap-2 text-xs text-muted-foreground">
<span className="tabular-nums">
{rangeStart}–{rangeEnd} / {total}
+1 -1
View File
@@ -450,7 +450,7 @@ export const api = {
if (!r.ok) throw new Error(await r.text());
},
// ---- commands (Bash execution history) ----
// ---- commands (tool execution history, any tool) ----
commands: (params?: { task?: string; q?: string; page?: number; size?: number }) => {
const sp = new URLSearchParams();
if (params?.task) sp.set("task", params.task);
+1
View File
@@ -464,6 +464,7 @@ export const tools: Tool[] = [
// ── Settings ─────────────────────────────────────────────────────────────────
export const settings: Settings = {
traffic_capture: true,
llm_record: false,
web_search_enabled: true,
web_search_backend: "ddgs",
brave_key_set: false,
+3 -1
View File
@@ -338,6 +338,7 @@ export interface TrafficDetail {
// ---- App settings (runtime toggles) ----
export interface Settings {
traffic_capture: boolean;
llm_record: boolean; // LLM 录制开关(默认关);关闭时不记录任何 LLM 调用
// Web search. brave_key_set / tavily_key_set reflect whether a key is stored
// (the values are never returned). On PUT, send the corresponding field to set/clear.
web_search_enabled: boolean;
@@ -555,7 +556,8 @@ export interface CommandRecord {
id: number;
exploration_id: number;
worker: string;
command: string;
tool: string;
command: string; // raw tool input (JSON)
output: string;
is_error: boolean;
created_at: string;
+1 -1
View File
@@ -68,7 +68,7 @@ export const sidebarItems: NavGroup[] = [
{ id: "tasks", title: "任务", url: "/function/tasks", icon: Target },
{ id: "findings", title: "发现", url: "/function/findings", icon: Bug },
{ id: "traffic", title: "流量", url: "/function/traffic", icon: Activity },
{ id: "commands", title: "命令", url: "/function/commands", icon: Terminal },
{ id: "commands", title: "工具执行", url: "/function/commands", icon: Terminal },
{ id: "llm-records", title: "LLM 录制", url: "/function/llm-records", icon: Radio },
{ id: "assets", title: "资产", url: "/function/assets", icon: Network },
{ id: "sync", title: "资产同步", url: "/function/sync", icon: FolderSync },