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:
+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 = '';
|
||||
|
||||
Reference in New Issue
Block a user