feat(transcript): 归档详情内回放执行/复审转录 (tsk_O9q0_u78_NPo)
- API:新增 GET /api/runs/:id/transcript,读 run.transcriptRef 指向的 jsonl,逐行解析成脱敏后的结构化消息数组。校验 runId 合法且关联任务/ 项目存在;路径只允许在 transcriptDir 下(防目录穿越)。 - 脱敏:只透传 assistant 文本 / tool_use(名+摘要参数) / tool_result(截断) / 结论 / 模型回退;命中 secret/token/env 等敏感键名的值剔除为 [已脱敏]。 - 看板 archiveModal:执行历史从"文件路径"换成可展开的消息流,按 type 分类渲染(文本/工具调用/工具结果/结论/回退),长内容折叠,复用 mdToHtml。 executor 与 reviewer/security run 并列可回放。 - Store:新增 getRun(runId)。 - 测试:transcript 解析(样例 jsonl/脱敏/截断/坏行容错/路径穿越)+ 端点 inject(合法/未知 runId/无转录/越界路径)。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -10,6 +10,7 @@ import { resolvedExecutorModels } from '../executor/models.js';
|
||||
import { mergeBranch } from '../executor/merge.js';
|
||||
import { git, removeWorktree } from '../executor/worktree.js';
|
||||
import { resolveLogo, LOGO_MIME } from './logo.js';
|
||||
import { readTranscript, TranscriptError } from '../executor/transcript.js';
|
||||
import { createReadStream } from 'node:fs';
|
||||
import { createUsageFetcher, type UsageInfo } from '../daemon/usage.js';
|
||||
|
||||
@@ -196,6 +197,27 @@ export function buildServer(opts: ApiOptions): FastifyInstance {
|
||||
return store.listRuns(id);
|
||||
});
|
||||
|
||||
// 执行转录回放:读 run.transcriptRef 指向的 jsonl,逐行解析成脱敏后的结构化消息数组。
|
||||
// 校验:runId 合法 → 关联任务/项目存在(可访问)→ 有转录记录 → 路径在 transcriptDir 下(防穿越)。
|
||||
app.get('/api/runs/:id/transcript', (req, reply) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const run = store.getRun(id);
|
||||
if (!run) return reply.code(404).send({ error: `run 不存在: ${id}` });
|
||||
const task = store.getTask(run.taskId);
|
||||
if (!task) return reply.code(404).send({ error: `run 关联任务不存在: ${run.taskId}` });
|
||||
if (!store.getProject(task.projectId)) return reply.code(404).send({ error: `run 关联项目不存在: ${task.projectId}` });
|
||||
if (!run.transcriptRef) return reply.code(404).send({ error: '该 run 没有转录记录' });
|
||||
try {
|
||||
const messages = readTranscript(run.transcriptRef);
|
||||
return { runId: run.id, taskId: run.taskId, kind: run.kind, status: run.status, messages };
|
||||
} catch (e) {
|
||||
if (e instanceof TranscriptError) {
|
||||
return reply.code(e.code === 'invalid_path' ? 400 : 404).send({ error: e.message });
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
});
|
||||
|
||||
// 单任务全量事件(升序):归档详情的状态流转时间线
|
||||
app.get('/api/tasks/:id/events', (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve, sep } from 'node:path';
|
||||
import { transcriptDir } from './cc.js';
|
||||
|
||||
/**
|
||||
* 结构化转录消息(脱敏后)。看板按 type 分类渲染。
|
||||
* - text : assistant 文本
|
||||
* - tool_use : 工具调用(名 + 摘要参数,已脱敏 / 截断)
|
||||
* - tool_result: 工具结果(截断)
|
||||
* - result : 会话结论文本
|
||||
* - fallback : 模型回退记录
|
||||
*/
|
||||
export interface TranscriptMessage {
|
||||
seq: number;
|
||||
type: 'text' | 'tool_use' | 'tool_result' | 'result' | 'fallback';
|
||||
role?: 'assistant' | 'user';
|
||||
text?: string; // text / result / tool_result 正文
|
||||
name?: string; // tool_use:工具名
|
||||
input?: string; // tool_use:脱敏后的摘要参数(JSON 字符串)
|
||||
isError?: boolean; // tool_result:是否报错
|
||||
truncated?: boolean; // 是否因过长被截断
|
||||
from?: string; // fallback:原模型
|
||||
to?: string; // fallback:回退模型
|
||||
}
|
||||
|
||||
export class TranscriptError extends Error {
|
||||
readonly code: 'invalid_path' | 'not_found';
|
||||
constructor(message: string, code: 'invalid_path' | 'not_found') {
|
||||
super(message);
|
||||
this.code = code;
|
||||
this.name = 'TranscriptError';
|
||||
}
|
||||
}
|
||||
|
||||
/** tool_result 等长文本截断上限(字符) */
|
||||
const MAX_TEXT = 4000;
|
||||
/** tool_use 参数摘要截断上限 */
|
||||
const MAX_INPUT = 2000;
|
||||
const REDACTED = '[已脱敏]';
|
||||
|
||||
/** 敏感字段名(命中即整值脱敏):凭证 / 密钥 / 环境变量等一律不外传 */
|
||||
const SECRET_KEY_RE =
|
||||
/(secret|token|password|passwd|api[-_]?key|apikey|credential|bearer|cookie|session[-_]?key|private[-_]?key|access[-_]?key|client[-_]?secret|refresh[-_]?token|^env$|环境变量)/i;
|
||||
|
||||
/** 递归脱敏:命中敏感键名的值替换为 [已脱敏],其余原样保留 */
|
||||
function redact(value: unknown, depth = 0): unknown {
|
||||
if (depth > 6) return '…';
|
||||
if (Array.isArray(value)) return value.map((v) => redact(v, depth + 1));
|
||||
if (value && typeof value === 'object') {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
||||
out[k] = SECRET_KEY_RE.test(k) ? REDACTED : redact(v, depth + 1);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function truncate(text: string, max: number): { text: string; truncated: boolean } {
|
||||
if (text.length <= max) return { text, truncated: false };
|
||||
return { text: text.slice(0, max), truncated: true };
|
||||
}
|
||||
|
||||
/** tool_use 参数 → 脱敏 + 截断的摘要字符串 */
|
||||
function summarizeInput(input: unknown): { text: string; truncated: boolean } {
|
||||
if (input === undefined || input === null) return { text: '', truncated: false };
|
||||
let json: string;
|
||||
try {
|
||||
json = JSON.stringify(redact(input));
|
||||
} catch {
|
||||
json = String(input);
|
||||
}
|
||||
return truncate(json ?? '', MAX_INPUT);
|
||||
}
|
||||
|
||||
/** tool_result.content(string | block[] | 其它)→ 纯文本 */
|
||||
function extractToolResultText(content: unknown): string {
|
||||
if (typeof content === 'string') return content;
|
||||
if (Array.isArray(content)) {
|
||||
return content
|
||||
.map((b) => {
|
||||
if (b && typeof b === 'object' && 'text' in (b as Record<string, unknown>)) {
|
||||
return String((b as { text: unknown }).text ?? '');
|
||||
}
|
||||
return typeof b === 'string' ? b : JSON.stringify(b);
|
||||
})
|
||||
.join('\n');
|
||||
}
|
||||
if (content === undefined || content === null) return '';
|
||||
return typeof content === 'object' ? JSON.stringify(content) : String(content);
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验转录路径只允许落在 transcriptDir 下(防目录穿越)。
|
||||
* 不解析符号链接(文件可能尚未存在),仅做规范化前缀比对。
|
||||
*/
|
||||
export function assertWithinTranscriptDir(ref: string, baseDir = transcriptDir()): string {
|
||||
const base = resolve(baseDir);
|
||||
const target = resolve(ref);
|
||||
if (target !== base && !target.startsWith(base + sep)) {
|
||||
throw new TranscriptError(`转录路径越界:${ref}`, 'invalid_path');
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取并解析 jsonl 转录为脱敏后的结构化消息数组。
|
||||
* - 路径越界 → TranscriptError('invalid_path')(调用方映射 400)
|
||||
* - 文件不存在 → TranscriptError('not_found')(调用方映射 404)
|
||||
* - 单行解析失败:跳过该行(容错,不整体失败)
|
||||
*/
|
||||
export function readTranscript(ref: string, baseDir = transcriptDir()): TranscriptMessage[] {
|
||||
const target = assertWithinTranscriptDir(ref, baseDir);
|
||||
|
||||
let raw: string;
|
||||
try {
|
||||
raw = readFileSync(target, 'utf8');
|
||||
} catch {
|
||||
throw new TranscriptError(`转录文件不存在:${ref}`, 'not_found');
|
||||
}
|
||||
|
||||
const messages: TranscriptMessage[] = [];
|
||||
let seq = 0;
|
||||
const push = (m: Omit<TranscriptMessage, 'seq'>) => messages.push({ seq: seq++, ...m });
|
||||
|
||||
for (const line of raw.split('\n')) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
let m: Record<string, unknown>;
|
||||
try {
|
||||
m = JSON.parse(trimmed) as Record<string, unknown>;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
const type = m.type;
|
||||
|
||||
// 自定义:模型回退
|
||||
if (type === 'maestro.model_fallback') {
|
||||
push({ type: 'fallback', from: String(m.from ?? ''), to: String(m.to ?? '') });
|
||||
continue;
|
||||
}
|
||||
|
||||
// assistant:文本块 + 工具调用块(thinking / 其它块忽略)
|
||||
if (type === 'assistant') {
|
||||
const content = (m.message as { content?: unknown })?.content;
|
||||
if (Array.isArray(content)) {
|
||||
for (const block of content) {
|
||||
const b = block as Record<string, unknown>;
|
||||
if (b.type === 'text' && typeof b.text === 'string' && b.text.trim()) {
|
||||
const { text, truncated } = truncate(b.text, MAX_TEXT);
|
||||
push({ type: 'text', role: 'assistant', text, truncated });
|
||||
} else if (b.type === 'tool_use') {
|
||||
const { text, truncated } = summarizeInput(b.input);
|
||||
push({ type: 'tool_use', name: String(b.name ?? '工具'), input: text, truncated });
|
||||
}
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// user:工具结果
|
||||
if (type === 'user') {
|
||||
const content = (m.message as { content?: unknown })?.content;
|
||||
if (Array.isArray(content)) {
|
||||
for (const block of content) {
|
||||
const b = block as Record<string, unknown>;
|
||||
if (b.type === 'tool_result') {
|
||||
const { text, truncated } = truncate(extractToolResultText(b.content), MAX_TEXT);
|
||||
push({ type: 'tool_result', role: 'user', text, isError: b.is_error === true, truncated });
|
||||
}
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// result:会话结论
|
||||
if (type === 'result') {
|
||||
const txt = m.result;
|
||||
if (typeof txt === 'string' && txt.trim()) {
|
||||
const { text, truncated } = truncate(txt, MAX_TEXT);
|
||||
push({ type: 'result', text, truncated });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// system / 其它(init 等):不外传
|
||||
}
|
||||
|
||||
return messages;
|
||||
}
|
||||
@@ -639,6 +639,12 @@ export class Store {
|
||||
return rows.map(rowToRun);
|
||||
}
|
||||
|
||||
/** 单条 run(转录查看器校验 runId 合法性用);不存在 → undefined */
|
||||
getRun(runId: string): Run | undefined {
|
||||
const row = this.db.prepare(`SELECT * FROM runs WHERE id = ?`).get(runId) as RunRow | undefined;
|
||||
return row ? rowToRun(row) : undefined;
|
||||
}
|
||||
|
||||
// ---------- Events ----------
|
||||
/** 单任务全量事件(升序):状态流转/审批/运行历史,供归档详情时间线 */
|
||||
listTaskEvents(taskId: string): Event[] {
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { readTranscript, assertWithinTranscriptDir, TranscriptError } from '../src/executor/transcript.js';
|
||||
import { Store } from '../src/store/index.js';
|
||||
import { buildServer } from '../src/api/server.js';
|
||||
|
||||
/** 临时数据目录 + transcripts 子目录;返回 {dir, transcriptsDir} */
|
||||
function tmpData(): { dir: string; transcripts: string } {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'maestro-transcript-'));
|
||||
const transcripts = join(dir, 'transcripts');
|
||||
mkdirSync(transcripts, { recursive: true });
|
||||
return { dir, transcripts };
|
||||
}
|
||||
|
||||
/** 写一行原始 SDK 风格消息到 jsonl */
|
||||
function jsonl(lines: unknown[]): string {
|
||||
return lines.map((l) => JSON.stringify(l)).join('\n') + '\n';
|
||||
}
|
||||
|
||||
const SAMPLE = [
|
||||
{ type: 'system', subtype: 'init', session_id: 's1' },
|
||||
{ type: 'assistant', session_id: 's1', message: { role: 'assistant', content: [
|
||||
{ type: 'text', text: '我先看一下文件结构。' },
|
||||
{ type: 'tool_use', id: 'tu1', name: 'Bash', input: { command: 'ls -la', description: '列目录' } },
|
||||
] } },
|
||||
{ type: 'user', session_id: 's1', message: { role: 'user', content: [
|
||||
{ type: 'tool_result', tool_use_id: 'tu1', content: 'total 4\nfile.txt', is_error: false },
|
||||
] } },
|
||||
{ type: 'maestro.model_fallback', from: 'opusX', to: 'sonnetY', reason: 'not_found' },
|
||||
{ type: 'result', subtype: 'success', is_error: false, result: '完成:改了一行。' },
|
||||
];
|
||||
|
||||
test('readTranscript:样例 jsonl → 结构化消息数组', () => {
|
||||
const { transcripts } = tmpData();
|
||||
const ref = join(transcripts, 'run_abc.jsonl');
|
||||
writeFileSync(ref, jsonl(SAMPLE));
|
||||
|
||||
const msgs = readTranscript(ref, transcripts);
|
||||
const types = msgs.map((m) => m.type);
|
||||
// system/init 不外传;其余按序结构化
|
||||
assert.deepEqual(types, ['text', 'tool_use', 'tool_result', 'fallback', 'result']);
|
||||
|
||||
const text = msgs[0];
|
||||
assert.equal(text.role, 'assistant');
|
||||
assert.equal(text.text, '我先看一下文件结构。');
|
||||
|
||||
const toolUse = msgs[1];
|
||||
assert.equal(toolUse.name, 'Bash');
|
||||
assert.match(toolUse.input!, /ls -la/);
|
||||
|
||||
const toolResult = msgs[2];
|
||||
assert.match(toolResult.text!, /file\.txt/);
|
||||
assert.equal(toolResult.isError, false);
|
||||
|
||||
const fb = msgs[3];
|
||||
assert.equal(fb.from, 'opusX');
|
||||
assert.equal(fb.to, 'sonnetY');
|
||||
|
||||
assert.equal(msgs[4].text, '完成:改了一行。');
|
||||
});
|
||||
|
||||
test('脱敏:含 secret/token/env 的工具参数被剔除', () => {
|
||||
const { transcripts } = tmpData();
|
||||
const ref = join(transcripts, 'run_secret.jsonl');
|
||||
writeFileSync(ref, jsonl([
|
||||
{ type: 'assistant', message: { content: [
|
||||
{ type: 'tool_use', name: 'Deploy', input: {
|
||||
host: 'example.com',
|
||||
apiKey: 'sk-LIVE-should-not-leak',
|
||||
token: 'ghp_secrettoken',
|
||||
env: { ANTHROPIC_API_KEY: 'sk-ant-xxx' },
|
||||
password: 'hunter2',
|
||||
note: 'safe-to-show',
|
||||
} },
|
||||
] } },
|
||||
]));
|
||||
|
||||
const msgs = readTranscript(ref, transcripts);
|
||||
const input = msgs[0].input!;
|
||||
assert.doesNotMatch(input, /should-not-leak/);
|
||||
assert.doesNotMatch(input, /ghp_secrettoken/);
|
||||
assert.doesNotMatch(input, /sk-ant-xxx/);
|
||||
assert.doesNotMatch(input, /hunter2/);
|
||||
assert.match(input, /\[已脱敏\]/);
|
||||
// 非敏感字段保留
|
||||
assert.match(input, /example\.com/);
|
||||
assert.match(input, /safe-to-show/);
|
||||
});
|
||||
|
||||
test('长内容被截断并标记 truncated', () => {
|
||||
const { transcripts } = tmpData();
|
||||
const ref = join(transcripts, 'run_long.jsonl');
|
||||
const big = 'x'.repeat(10_000);
|
||||
writeFileSync(ref, jsonl([
|
||||
{ type: 'user', message: { content: [{ type: 'tool_result', content: big }] } },
|
||||
]));
|
||||
const msgs = readTranscript(ref, transcripts);
|
||||
assert.equal(msgs[0].truncated, true);
|
||||
assert.ok(msgs[0].text!.length < big.length);
|
||||
});
|
||||
|
||||
test('路径穿越被拒绝(invalid_path)', () => {
|
||||
const { transcripts } = tmpData();
|
||||
assert.throws(
|
||||
() => assertWithinTranscriptDir(join(transcripts, '..', '..', 'etc', 'passwd'), transcripts),
|
||||
(e: unknown) => e instanceof TranscriptError && e.code === 'invalid_path',
|
||||
);
|
||||
// 合法路径放行
|
||||
assert.doesNotThrow(() => assertWithinTranscriptDir(join(transcripts, 'run_ok.jsonl'), transcripts));
|
||||
});
|
||||
|
||||
test('坏行容错:非法 JSON 行被跳过,不影响其余解析', () => {
|
||||
const { transcripts } = tmpData();
|
||||
const ref = join(transcripts, 'run_bad.jsonl');
|
||||
writeFileSync(ref, 'not-json\n' + JSON.stringify({ type: 'result', result: 'ok' }) + '\n\n');
|
||||
const msgs = readTranscript(ref, transcripts);
|
||||
assert.equal(msgs.length, 1);
|
||||
assert.equal(msgs[0].text, 'ok');
|
||||
});
|
||||
|
||||
// ── 端点测试(Fastify inject)──
|
||||
test('GET /api/runs/:id/transcript:合法 run → 结构化消息', async () => {
|
||||
const { dir, transcripts } = tmpData();
|
||||
process.env.MAESTRO_DATA_DIR = dir; // transcriptDir() 在调用时读取
|
||||
const store = new Store(':memory:');
|
||||
const p = store.createProject({ name: 't', repoPath: '/tmp/t-' + Math.random() });
|
||||
const task = store.createTask({ projectId: p.id, title: 'demo', complexity: 'easy' });
|
||||
const run = store.startRun(task.id, 'executor');
|
||||
const ref = join(transcripts, run.id + '.jsonl');
|
||||
writeFileSync(ref, jsonl(SAMPLE));
|
||||
store.finishRun(run.id, 'succeeded', { transcriptRef: ref });
|
||||
|
||||
const app = buildServer({ store });
|
||||
const res = await app.inject({ method: 'GET', url: `/api/runs/${run.id}/transcript` });
|
||||
assert.equal(res.statusCode, 200);
|
||||
const body = res.json();
|
||||
assert.equal(body.runId, run.id);
|
||||
assert.equal(body.kind, 'executor');
|
||||
assert.ok(Array.isArray(body.messages));
|
||||
assert.equal(body.messages.length, 5);
|
||||
await app.close();
|
||||
store.close();
|
||||
});
|
||||
|
||||
test('GET /api/runs/:id/transcript:未知 runId → 404', async () => {
|
||||
const store = new Store(':memory:');
|
||||
const app = buildServer({ store });
|
||||
const res = await app.inject({ method: 'GET', url: '/api/runs/run_nope/transcript' });
|
||||
assert.equal(res.statusCode, 404);
|
||||
await app.close();
|
||||
store.close();
|
||||
});
|
||||
|
||||
test('GET /api/runs/:id/transcript:无转录记录 → 404', async () => {
|
||||
const store = new Store(':memory:');
|
||||
const p = store.createProject({ name: 't', repoPath: '/tmp/t-' + Math.random() });
|
||||
const task = store.createTask({ projectId: p.id, title: 'demo', complexity: 'easy' });
|
||||
const run = store.startRun(task.id, 'executor'); // 未 finishRun,transcriptRef = null
|
||||
const app = buildServer({ store });
|
||||
const res = await app.inject({ method: 'GET', url: `/api/runs/${run.id}/transcript` });
|
||||
assert.equal(res.statusCode, 404);
|
||||
await app.close();
|
||||
store.close();
|
||||
});
|
||||
|
||||
test('GET /api/runs/:id/transcript:越界路径 → 400', async () => {
|
||||
const { dir } = tmpData();
|
||||
process.env.MAESTRO_DATA_DIR = dir;
|
||||
const store = new Store(':memory:');
|
||||
const p = store.createProject({ name: 't', repoPath: '/tmp/t-' + Math.random() });
|
||||
const task = store.createTask({ projectId: p.id, title: 'demo', complexity: 'easy' });
|
||||
const run = store.startRun(task.id, 'executor');
|
||||
// 指向 transcriptDir 之外(穿越)
|
||||
store.finishRun(run.id, 'failed', { transcriptRef: '/etc/passwd' });
|
||||
const app = buildServer({ store });
|
||||
const res = await app.inject({ method: 'GET', url: `/api/runs/${run.id}/transcript` });
|
||||
assert.equal(res.statusCode, 400);
|
||||
await app.close();
|
||||
store.close();
|
||||
});
|
||||
+63
-6
@@ -585,12 +585,18 @@ function renderArchiveModal(t, runs, events) {
|
||||
.join('');
|
||||
|
||||
const runRows = runs.length ? `<div class="gate-doc-label">执行历史(${runs.length} 次 run)</div>
|
||||
<dl class="result-kv">${runs.map((x) => kv(
|
||||
`${RUN_KIND[x.kind] || esc(x.kind)} · ${RUN_ST[x.status] || esc(x.status)}`,
|
||||
`${fmtFull(x.startedAt)} → ${x.endedAt ? fmtFull(x.endedAt) : '—'} · 时长 ${fmtDuration(x.startedAt, x.endedAt)}` +
|
||||
(x.error ? `<br>错误:${esc(x.error)}` : '') +
|
||||
(x.transcriptRef ? `<br><span class="t-id">转录 ${esc(x.transcriptRef)}</span>` : ''),
|
||||
)).join('')}</dl>` : '';
|
||||
<div class="run-list">${runs.map((x) => `
|
||||
<div class="run-item">
|
||||
<div class="run-head">
|
||||
<span class="run-kind">${RUN_KIND[x.kind] || esc(x.kind)}</span>
|
||||
<span class="run-st run-st-${esc(x.status)}">${RUN_ST[x.status] || esc(x.status)}</span>
|
||||
<span class="run-time">${fmtFull(x.startedAt)} → ${x.endedAt ? fmtFull(x.endedAt) : '—'} · ${fmtDuration(x.startedAt, x.endedAt)}</span>
|
||||
<span class="spacer"></span>
|
||||
${x.transcriptRef ? `<button class="btn btn-xs btn-ghost" data-action="run-transcript" data-id="${esc(x.id)}">▶ 查看转录</button>` : ''}
|
||||
</div>
|
||||
${x.error ? `<div class="run-err">错误:${esc(x.error)}</div>` : ''}
|
||||
${x.transcriptRef ? `<div class="transcript" id="transcript-${esc(x.id)}" hidden></div>` : ''}
|
||||
</div>`).join('')}</div>` : '';
|
||||
|
||||
const resultBlock = r ? `${reviewReportBlocks(r)}
|
||||
<div class="gate-doc-label">执行结果</div>
|
||||
@@ -647,6 +653,41 @@ function renderArchiveModal(t, runs, events) {
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// 过长内容折叠为 <details>,短内容直接 <pre>
|
||||
function foldable(text, truncated, threshold = 400) {
|
||||
const body = esc(text) + (truncated ? esc('\n…(已截断)') : '');
|
||||
if ((text || '').length <= threshold) return `<pre class="t-pre">${body}</pre>`;
|
||||
return `<details class="t-fold"><summary>展开(${(text || '').length} 字符)</summary><pre class="t-pre">${body}</pre></details>`;
|
||||
}
|
||||
|
||||
// 结构化转录消息流 → HTML(按 type 分类;文本/结论复用 mdToHtml,工具调用/结果折叠)
|
||||
function renderTranscript(messages) {
|
||||
if (!messages || !messages.length) return '<div class="transcript-empty">(无可展示的转录内容)</div>';
|
||||
return `<div class="t-stream">${messages.map((m) => {
|
||||
if (m.type === 'text') {
|
||||
return `<div class="t-msg t-text"><span class="t-badge t-badge-a">assistant</span>
|
||||
<div class="doc md">${mdToHtml(m.text || '')}</div>${m.truncated ? '<div class="t-trunc">…(已截断)</div>' : ''}</div>`;
|
||||
}
|
||||
if (m.type === 'result') {
|
||||
return `<div class="t-msg t-result"><span class="t-badge t-badge-r">结论</span>
|
||||
<div class="doc md">${mdToHtml(m.text || '')}</div>${m.truncated ? '<div class="t-trunc">…(已截断)</div>' : ''}</div>`;
|
||||
}
|
||||
if (m.type === 'fallback') {
|
||||
return `<div class="t-msg t-fallback"><span class="t-badge t-badge-w">模型回退</span>
|
||||
<span class="t-fallback-txt">${esc(m.from)} → ${esc(m.to)}</span></div>`;
|
||||
}
|
||||
if (m.type === 'tool_use') {
|
||||
return `<div class="t-msg t-tool"><span class="t-badge t-badge-t">工具调用</span>
|
||||
<span class="t-tool-name">${esc(m.name)}</span>${m.input ? foldable(m.input, m.truncated) : ''}</div>`;
|
||||
}
|
||||
if (m.type === 'tool_result') {
|
||||
return `<div class="t-msg t-tool-result${m.isError ? ' is-err' : ''}">
|
||||
<span class="t-badge t-badge-tr">工具结果${m.isError ? ' · 报错' : ''}</span>${foldable(m.text || '', m.truncated)}</div>`;
|
||||
}
|
||||
return '';
|
||||
}).join('')}</div>`;
|
||||
}
|
||||
|
||||
// ── 渲染:全局预览(全屏读方案 + 就地裁决) ──
|
||||
function renderPreview() {
|
||||
const root = $('#previewRoot');
|
||||
@@ -1183,6 +1224,22 @@ document.addEventListener('click', (ev) => {
|
||||
case 'archive-detail':
|
||||
openArchiveDetail(id);
|
||||
break;
|
||||
|
||||
case 'run-transcript': {
|
||||
ev.stopPropagation();
|
||||
const box = document.getElementById(`transcript-${id}`);
|
||||
if (!box) break;
|
||||
if (!box.hidden) { box.hidden = true; el.textContent = '▶ 查看转录'; break; }
|
||||
box.hidden = false;
|
||||
el.textContent = '▼ 收起转录';
|
||||
if (!box.dataset.loaded) {
|
||||
box.innerHTML = '<div class="transcript-loading">加载中…</div>';
|
||||
api(`/api/runs/${id}/transcript`)
|
||||
.then((data) => { box.dataset.loaded = '1'; box.innerHTML = renderTranscript(data.messages); })
|
||||
.catch((e) => { box.innerHTML = `<div class="transcript-err">${esc(e.message)}</div>`; });
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'archive-modal-close':
|
||||
$('#archiveModalRoot').hidden = true;
|
||||
$('#archiveModalRoot').innerHTML = '';
|
||||
|
||||
@@ -826,3 +826,50 @@ li.ev-updated { --ev: var(--muted); }
|
||||
.detail-confirm-msg {
|
||||
font-size: 12px; color: var(--ink);
|
||||
}
|
||||
|
||||
/* ── 归档详情:执行历史 + 转录回放 ── */
|
||||
.run-list { display: flex; flex-direction: column; gap: 8px; margin-top: 6px; }
|
||||
.run-item { border: 1px solid var(--line-soft); background: var(--panel); padding: 8px 10px; }
|
||||
.run-head { display: flex; align-items: center; gap: 10px; font-size: 12px; flex-wrap: wrap; }
|
||||
.run-head .spacer { flex: 1; }
|
||||
.run-kind { color: var(--ink); font-weight: 600; }
|
||||
.run-st { font-size: 11px; padding: 1px 6px; border: 1px solid var(--line); color: var(--muted); }
|
||||
.run-st-succeeded { color: var(--green); border-color: var(--green-dim); }
|
||||
.run-st-failed { color: var(--red); border-color: var(--red-dim); }
|
||||
.run-st-started { color: var(--cyan); border-color: var(--cyan-dim); }
|
||||
.run-time { color: var(--faint); font-size: 11px; letter-spacing: 0; }
|
||||
.run-err { margin-top: 6px; font-size: 12px; color: var(--red); }
|
||||
|
||||
.transcript { margin-top: 8px; border-top: 1px dashed var(--line-soft); padding-top: 8px; }
|
||||
.transcript-loading, .transcript-empty { color: var(--muted); font-size: 12px; }
|
||||
.transcript-err { color: var(--red); font-size: 12px; }
|
||||
|
||||
.t-stream { display: flex; flex-direction: column; gap: 8px; }
|
||||
.t-msg { font-size: 12.5px; border-left: 2px solid var(--line); padding: 2px 0 2px 10px; }
|
||||
.t-msg .doc.md { margin-top: 2px; }
|
||||
.t-text { border-left-color: var(--green-dim); }
|
||||
.t-result { border-left-color: var(--green); }
|
||||
.t-tool { border-left-color: var(--cyan-dim); }
|
||||
.t-tool-result { border-left-color: var(--amber-dim); }
|
||||
.t-tool-result.is-err { border-left-color: var(--red-dim); }
|
||||
.t-fallback { border-left-color: var(--amber); }
|
||||
.t-badge {
|
||||
display: inline-block; font-size: 10px; letter-spacing: .08em;
|
||||
padding: 1px 6px; margin-right: 8px; border: 1px solid var(--line); color: var(--muted);
|
||||
}
|
||||
.t-badge-a { color: var(--green); border-color: var(--green-dim); }
|
||||
.t-badge-r { color: var(--green); border-color: var(--green); }
|
||||
.t-badge-t { color: var(--cyan); border-color: var(--cyan-dim); }
|
||||
.t-badge-tr { color: var(--amber); border-color: var(--amber-dim); }
|
||||
.t-badge-w { color: var(--amber); border-color: var(--amber); }
|
||||
.t-tool-name { color: var(--cyan); font-weight: 600; }
|
||||
.t-fallback-txt { color: var(--amber); }
|
||||
.t-trunc { color: var(--faint); font-size: 11px; margin-top: 2px; }
|
||||
.t-pre {
|
||||
margin: 4px 0 0; padding: 6px 8px; background: var(--bg-deep); border: 1px solid var(--line-soft);
|
||||
font-size: 11.5px; white-space: pre-wrap; word-break: break-word; max-height: 320px; overflow-y: auto;
|
||||
}
|
||||
.t-fold { margin-top: 4px; }
|
||||
.t-fold > summary { cursor: pointer; color: var(--muted); font-size: 11.5px; }
|
||||
.t-fold > summary:hover { color: var(--green); }
|
||||
.t-fold .t-pre { max-height: 480px; }
|
||||
|
||||
Reference in New Issue
Block a user