feat(llmrec): 录制 LLM 请求/响应 HTTP 原文

归一化视图(request_body/response_body)会丢弃工具 schema、tool_use 块,
且看不到原始 SSE 帧,排查 provider 侧问题时信息不足。新增在 HTTP transport
层捕获真实 wire body 的能力:

- llmrec: 新增 Capture,Recorder 挂到 ctx,quotaAwareTransport 填充。
  请求取 GetBody 副本(不消费原 body),响应用 tee 保持流式;norma 内部重试
  的多次尝试逐次保留(旧代码会读掉丢弃),单次时为字节级原文。
- db: llm_records 增加 raw_request/raw_response 两列,含旧库 ALTER 迁移。
- web: 录制页详情面板增加「原文」视图切换,及请求/响应内容复制按钮
  (兼容非安全上下文的 execCommand 回退)。
- 测试: 单元(capture)、端到端(逐字节比对捕获=服务器实际收到)、
  迁移(真实 PG 事务回滚)、全链路(Recorder→provider→PG 落库)。
This commit is contained in:
Autumn-27 committed 2026-08-25 09:40:05 -04:00
1 parent 4d9580ebfa
commit 19a7206d12
12 files changed
+884 -17

No files matched your search

@@ -9,6 +9,8 @@ import {
Loader2Icon,
XIcon,
Trash2Icon,
CopyIcon,
CheckIcon,
} from "lucide-react";
import {
@@ -74,6 +76,47 @@ function tryFormatJSON(s: string): string {
}
}
// 复制当前框内文本的小按钮。复制成功后短暂显示对勾。text 为空/仅占位符时禁用。
function CopyButton({ text }: { text: string }) {
const [copied, setCopied] = React.useState(false);
const timer = React.useRef<ReturnType<typeof setTimeout> | null>(null);
React.useEffect(() => () => { if (timer.current) clearTimeout(timer.current); }, []);
const disabled = !text;
const copy = async () => {
if (disabled) return;
try {
await navigator.clipboard.writeText(text);
} catch {
// navigator.clipboard 在非安全上下文(如 http 局域网)不可用,回退到 execCommand。
const ta = document.createElement("textarea");
ta.value = text;
ta.style.position = "fixed";
ta.style.opacity = "0";
document.body.appendChild(ta);
ta.select();
try { document.execCommand("copy"); } catch { /* 忽略:不支持则静默 */ }
document.body.removeChild(ta);
}
setCopied(true);
if (timer.current) clearTimeout(timer.current);
timer.current = setTimeout(() => setCopied(false), 1500);
};
return (
<Button
variant="ghost"
size="icon"
className="size-5 shrink-0"
disabled={disabled}
title={copied ? "已复制" : "复制内容"}
onClick={copy}
>
{copied ? <CheckIcon className="size-3 text-emerald-600" /> : <CopyIcon className="size-3" />}
</Button>
);
}
const PAGE_SIZES = [25, 50, 100];
export default function LLMRecordsPage() {
@@ -96,6 +139,21 @@ export default function LLMRecordsPage() {
const [selected, setSelected] = React.useState<LLMRecordItem | null>(null);
const [detail, setDetail] = React.useState<LLMRecordDetail | null>(null);
const [detailLoading, setDetailLoading] = React.useState(false);
// 归一化视图 / HTTP 原文视图。原文是排查 provider 侧问题的唯一依据:归一化视图
// 不含工具 schema,响应里也没有 tool_use 块。
const [rawView, setRawView] = React.useState(false);
const hasRaw = !!(detail?.raw_request || detail?.raw_response);
// 开关保持用户选择,但切到一条无原文的旧记录时自动落回解析视图,而不是显示空白。
const showRaw = rawView && hasRaw;
// 原文请求体是 JSON,pretty-print 只改排版不改语义,便于阅读;原文响应是 SSE
// 帧,tryFormatJSON 解析失败会原样返回,故两边共用一个函数即可。
const reqText = showRaw
? detail?.raw_request && tryFormatJSON(detail.raw_request)
: detail?.request_body && tryFormatJSON(detail.request_body);
const respText = showRaw
? detail?.raw_response
: detail?.response_body && tryFormatJSON(detail.response_body);
// Per-task delete (task picker + confirm dialog)
const [tasks, setTasks] = React.useState<LLMTask[]>([]);
@@ -427,10 +485,22 @@ export default function LLMRecordsPage() {
) : (
<Badge variant="destructive" className="text-xs">Error</Badge>
)}
{/* 原文视图开关。旧记录没有原文,此时禁用而非静默回退,避免看着像
「原文与解析一致」。 */}
<Button
variant={showRaw ? "secondary" : "ghost"}
size="sm"
className="ml-auto h-7 shrink-0 text-xs"
disabled={!hasRaw}
title={hasRaw ? "查看与 provider 实际收发的 HTTP 原文" : "该记录录制于此功能上线前,无原文"}
onClick={() => setRawView((v) => !v)}
>
原文
</Button>
<Button
variant="ghost"
size="icon"
className="ml-auto size-7 shrink-0"
className="size-7 shrink-0"
onClick={() => setSelected(null)}
>
<XIcon />
@@ -439,8 +509,9 @@ export default function LLMRecordsPage() {
{/* Request / Response split */}
<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">
Request
<div className="flex items-center gap-2 border-b py-0.5 pr-1.5 pl-3 text-[11px] font-medium text-muted-foreground">
<span>Request{showRaw && " · 原文"}</span>
<CopyButton text={reqText || ""} />
</div>
<div className="min-h-0 flex-1 overflow-auto">
{detailLoading ? (
@@ -450,14 +521,15 @@ export default function LLMRecordsPage() {
</div>
) : (
<pre className="p-3 font-mono text-xs break-all whitespace-pre-wrap">
{detail?.request_body ? tryFormatJSON(detail.request_body) : "(空)"}
{reqText || "(空)"}
</pre>
)}
</div>
</div>
<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">
Response
<div className="flex items-center gap-2 border-b py-0.5 pr-1.5 pl-3 text-[11px] font-medium text-muted-foreground">
<span>Response{showRaw && " · 原文(SSE)"}</span>
<CopyButton text={respText || ""} />
</div>
<div className="min-h-0 flex-1 overflow-auto">
{detailLoading ? (
@@ -470,7 +542,7 @@ export default function LLMRecordsPage() {
"p-3 font-mono text-xs break-all whitespace-pre-wrap",
selected.status !== "ok" && "text-red-600 dark:text-red-400",
)}>
{detail?.response_body ? tryFormatJSON(detail.response_body) : "(空)"}
{respText || "(空)"}
</pre>
)}
</div>
+36
View File
@@ -3766,5 +3766,41 @@ export function llmRecordDetail(id: number, records = llmRecords) {
null,
2,
),
// HTTP 原文:请求含被归一化视图丢弃的完整工具 schema,响应为原始 SSE 帧。
raw_request: JSON.stringify({
model: item.model,
max_tokens: 8192,
stream: true,
system: [{ type: "text", text: "你是一个授权渗透测试系统的「执行者」…(省略)", cache_control: { type: "ephemeral" } }],
messages: [{ role: "user", content: "开始执行 system 提示里的这条意图:只做它、只产生事实、做完即停。" }],
tools: [
{
name: "bash",
description: "在目标环境中执行一条 shell 命令并返回其输出。",
input_schema: { type: "object", properties: { command: { type: "string" } }, required: ["command"] },
},
],
thinking: { type: "enabled" },
}),
raw_response: [
`event: message_start`,
`data: {"type":"message_start","message":{"id":"msg_01mock","model":"${item.model}","usage":{"input_tokens":${item.input_tokens},"output_tokens":1}}}`,
``,
`event: content_block_delta`,
`data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"对 search?q= 做注入探测。"}}`,
``,
`event: content_block_start`,
`data: {"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"toolu_01mock","name":"bash"}}`,
``,
`event: content_block_delta`,
`data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"{\\"command\\":\\"curl -s 'https://www.acme.com/search?q=1%27'\\"}"}}`,
``,
`event: message_delta`,
`data: {"type":"message_delta","delta":{"stop_reason":"tool_use"},"usage":{"output_tokens":${item.output_tokens}}}`,
``,
`event: message_stop`,
`data: {"type":"message_stop"}`,
``,
].join("\n"),
};
}
+5
View File
@@ -1084,6 +1084,11 @@ export interface LLMRecordItem {
export interface LLMRecordDetail extends LLMRecordItem {
request_body: string;
response_body: string;
// provider 实际收发的 HTTP 原文:请求为 buildBody() 发出的完整 body(含工具
// schema),响应为原始 SSE 帧。上面的 request_body/response_body 是归一化视图,
// 丢弃了工具 schema 与 tool_use 块。旧记录为空。
raw_request?: string;
raw_response?: string;
}
// One distinct task with its LLM-record count (task picker on the records page).