@keyou007/agent-bot 0.1.24-alpha.2 → 0.1.24-alpha.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +103 -0
- package/README.md +33 -5
- package/README.zh.md +32 -5
- package/dist/acp/AcpJsonRpcConnection.js +3 -1
- package/dist/acp/AcpJsonRpcConnection.js.map +1 -1
- package/dist/acp/AcpSessionManager.js +0 -14
- package/dist/acp/AcpSessionManager.js.map +1 -1
- package/dist/acp/acpTypes.js.map +1 -1
- package/dist/cli/SelfUpdateRunner.js.map +1 -1
- package/dist/cli/SelfUpdater.js +2 -0
- package/dist/cli/SelfUpdater.js.map +1 -1
- package/dist/codex/AppServerConnection.js +3 -1
- package/dist/codex/AppServerConnection.js.map +1 -1
- package/dist/codex/CodexEventMapper.js +28 -1
- package/dist/codex/CodexEventMapper.js.map +1 -1
- package/dist/codex/CodexRuntime.js +92 -0
- package/dist/codex/CodexRuntime.js.map +1 -1
- package/dist/commands/CommandRouter.js +5 -3
- package/dist/commands/CommandRouter.js.map +1 -1
- package/dist/feishu/CardRenderer.js +45 -23
- package/dist/feishu/CardRenderer.js.map +1 -1
- package/dist/feishu/FeishuConnector.js +2 -0
- package/dist/feishu/FeishuConnector.js.map +1 -1
- package/dist/feishu/FeishuMessageClient.js +44 -0
- package/dist/feishu/FeishuMessageClient.js.map +1 -1
- package/dist/feishu/FeishuTurnPresenter.js +114 -37
- package/dist/feishu/FeishuTurnPresenter.js.map +1 -1
- package/dist/feishu/RecentMessageContext.js +70 -0
- package/dist/feishu/RecentMessageContext.js.map +1 -0
- package/dist/index.js +62 -24
- package/dist/index.js.map +1 -1
- package/dist/local-files/DiagramPreview.js +171 -0
- package/dist/local-files/DiagramPreview.js.map +1 -0
- package/dist/local-files/LocalFileViewerServer.js +141 -3
- package/dist/local-files/LocalFileViewerServer.js.map +1 -1
- package/dist/local-files/PreviewSyntaxHighlight.js +57 -0
- package/dist/local-files/PreviewSyntaxHighlight.js.map +1 -0
- package/dist/local-files/TurnPreviewPage.js +497 -62
- package/dist/local-files/TurnPreviewPage.js.map +1 -1
- package/dist/presentation/OutboundRouter.js +18 -9
- package/dist/presentation/OutboundRouter.js.map +1 -1
- package/dist/presentation/TurnStateReducer.js +155 -6
- package/dist/presentation/TurnStateReducer.js.map +1 -1
- package/dist/presentation/turnReasoning.js +23 -0
- package/dist/presentation/turnReasoning.js.map +1 -0
- package/dist/proxy/ProxySessionController.js +466 -38
- package/dist/proxy/ProxySessionController.js.map +1 -1
- package/dist/state/StateStore.js +43 -2
- package/dist/state/StateStore.js.map +1 -1
- package/dist/state/TurnPreviewJournal.js +469 -0
- package/dist/state/TurnPreviewJournal.js.map +1 -0
- package/dist/state/migrations.js +10 -0
- package/dist/state/migrations.js.map +1 -1
- package/dist/updates/AutomaticUpdatePreparer.js +16 -7
- package/dist/updates/AutomaticUpdatePreparer.js.map +1 -1
- package/dist/updates/ManualUpdateController.js +102 -0
- package/dist/updates/ManualUpdateController.js.map +1 -0
- package/dist/updates/PublishedRelease.js +42 -4
- package/dist/updates/PublishedRelease.js.map +1 -1
- package/dist/utils/ProtocolLineWarning.js +15 -0
- package/dist/utils/ProtocolLineWarning.js.map +1 -0
- package/docs/technical-reference.md +21 -1
- package/docs/technical-reference.zh.md +21 -1
- package/npm-shrinkwrap.json +1401 -10
- package/package.json +3 -1
- package/skills/agent-bot/SKILL.md +12 -1
- package/src/acp/AcpJsonRpcConnection.ts +3 -1
- package/src/acp/AcpSessionManager.ts +0 -17
- package/src/acp/acpTypes.ts +0 -1
- package/src/cli/SelfUpdateRunner.ts +1 -0
- package/src/cli/SelfUpdater.ts +2 -0
- package/src/codex/AppServerConnection.ts +3 -1
- package/src/codex/CodexEventMapper.ts +29 -1
- package/src/codex/CodexRuntime.ts +87 -0
- package/src/commands/CommandRouter.ts +4 -3
- package/src/commands/commandTypes.ts +1 -0
- package/src/feishu/CardRenderer.ts +43 -21
- package/src/feishu/FeishuConnector.ts +2 -0
- package/src/feishu/FeishuMessageClient.ts +44 -0
- package/src/feishu/FeishuTurnPresenter.ts +107 -31
- package/src/feishu/RecentMessageContext.ts +90 -0
- package/src/feishu/types.ts +5 -0
- package/src/index.ts +62 -22
- package/src/local-files/DiagramPreview.ts +173 -0
- package/src/local-files/LocalFileViewerServer.ts +129 -2
- package/src/local-files/PreviewSyntaxHighlight.ts +58 -0
- package/src/local-files/TurnPreviewPage.ts +537 -59
- package/src/presentation/OutboundRouter.ts +25 -7
- package/src/presentation/TurnStateReducer.ts +151 -7
- package/src/presentation/turnReasoning.ts +21 -0
- package/src/presentation/turnViewTypes.ts +19 -1
- package/src/proxy/ProxySessionController.ts +457 -27
- package/src/runtime/types.ts +21 -0
- package/src/state/StateStore.ts +45 -2
- package/src/state/TurnPreviewJournal.ts +398 -0
- package/src/state/migrations.ts +10 -0
- package/src/updates/AutomaticUpdatePreparer.ts +21 -7
- package/src/updates/ManualUpdateController.ts +116 -0
- package/src/updates/PublishedRelease.ts +49 -3
- package/src/utils/ProtocolLineWarning.ts +13 -0
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import type MarkdownIt from "markdown-it";
|
|
2
|
+
|
|
3
|
+
export function enableDiagramFences(markdown: InstanceType<typeof MarkdownIt>): void {
|
|
4
|
+
const original = markdown.renderer.rules.fence!;
|
|
5
|
+
markdown.renderer.rules.fence = (tokens, index, options, environment, renderer) => {
|
|
6
|
+
const token = tokens[index]!;
|
|
7
|
+
if (!/^(mermaid|flowchart)$/iu.test(token.info.trim())) return original(tokens, index, options, environment, renderer);
|
|
8
|
+
const zh = environment?.language !== "en";
|
|
9
|
+
const source = markdown.utils.escapeHtml(token.content);
|
|
10
|
+
return `<div class="diagram" data-diagram data-diagram-size="fit" data-preview-key="diagram:${index}">
|
|
11
|
+
<div class="diagram-toolbar" role="group" aria-label="${zh ? "图表显示模式" : "Diagram display mode"}">
|
|
12
|
+
<button type="button" data-diagram-mode="preview" aria-pressed="true">${zh ? "预览" : "Preview"}</button>
|
|
13
|
+
<button type="button" data-diagram-mode="source" aria-pressed="false">${zh ? "源码" : "Source"}</button>
|
|
14
|
+
<button type="button" data-diagram-size-toggle hidden>${zh ? "原大" : "Actual size"}</button>
|
|
15
|
+
</div>
|
|
16
|
+
<p class="diagram-notice" role="status">${zh ? "正在渲染图表…" : "Rendering diagram…"}</p>
|
|
17
|
+
<div class="diagram-preview" role="img" aria-label="${zh ? "Mermaid 图表" : "Mermaid diagram"}"></div>
|
|
18
|
+
<pre class="diagram-source" hidden><code>${source}</code></pre>
|
|
19
|
+
<noscript><pre><code>${source}</code></pre></noscript>
|
|
20
|
+
</div>`;
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const DIAGRAM_PREVIEW_CSS = `
|
|
25
|
+
.diagram { --diagram-max-height:min(640px, 70vh); border:1px solid var(--border, #d8dee8); border-radius:12px; overflow:hidden; margin:12px 0; }
|
|
26
|
+
.diagram-toolbar { display:flex; flex-wrap:wrap; gap:4px; padding:6px 8px; border-bottom:1px solid var(--border, #d8dee8); }
|
|
27
|
+
.diagram-toolbar button { font:inherit; font-size:12px; color:inherit; background:transparent; border:1px solid transparent; border-radius:6px; padding:3px 10px; cursor:pointer; }
|
|
28
|
+
.diagram-toolbar button[aria-pressed="true"] { border-color:currentColor; }
|
|
29
|
+
.diagram-toolbar [data-diagram-size-toggle] { margin-left:auto; }
|
|
30
|
+
.diagram-toolbar button:focus-visible { outline:2px solid #4285d4; outline-offset:1px; }
|
|
31
|
+
.diagram-notice { font-size:12px; opacity:.75; margin:8px 12px!important; }
|
|
32
|
+
.diagram-preview { padding:12px; overflow:auto; max-height:var(--diagram-max-height); }
|
|
33
|
+
.diagram-preview svg { display:block; margin:auto; max-width:none!important; height:auto; width:min(100%, var(--diagram-width, 100%))!important; }
|
|
34
|
+
.diagram[data-diagram-size="actual"] .diagram-preview svg { width:var(--diagram-width, 100%)!important; }
|
|
35
|
+
@media (max-width:640px), (pointer:coarse) {
|
|
36
|
+
.diagram { --diagram-max-height:min(480px, 55vh); }
|
|
37
|
+
@supports (height:1svh) { .diagram { --diagram-max-height:min(480px, 55svh); } }
|
|
38
|
+
}
|
|
39
|
+
.diagram .diagram-source { margin:0; border:0; border-radius:0; max-height:30em; overflow:auto; white-space:pre; }
|
|
40
|
+
.diagram [hidden] { display:none!important; }
|
|
41
|
+
@media (hover:none), (pointer:coarse) { .diagram-preview, .diagram-source { scrollbar-width:none; } .diagram-preview::-webkit-scrollbar, .diagram-source::-webkit-scrollbar { display:none; } }
|
|
42
|
+
`;
|
|
43
|
+
|
|
44
|
+
export const DIAGRAM_PREVIEW_CLIENT_SCRIPT = String.raw`(() => {
|
|
45
|
+
const zh = /^zh/i.test(document.documentElement.lang);
|
|
46
|
+
const loading = zh ? "正在渲染图表…" : "Rendering diagram…";
|
|
47
|
+
const failed = zh ? "图表暂时无法渲染,已显示源码。" : "Diagram could not be rendered. Showing source.";
|
|
48
|
+
const states = new WeakMap();
|
|
49
|
+
let library;
|
|
50
|
+
let sequence = 0;
|
|
51
|
+
const loadLibrary = () => {
|
|
52
|
+
if (!library) library = new Promise((resolve, reject) => {
|
|
53
|
+
const url = document.body.dataset.diagramScript;
|
|
54
|
+
if (!url) { reject(new Error("Diagram renderer unavailable")); return; }
|
|
55
|
+
const script = document.createElement("script");
|
|
56
|
+
script.src = url;
|
|
57
|
+
const timeout = setTimeout(() => {
|
|
58
|
+
script.remove();
|
|
59
|
+
reject(new Error("Diagram renderer timed out"));
|
|
60
|
+
}, 15000);
|
|
61
|
+
script.onload = () => {
|
|
62
|
+
clearTimeout(timeout);
|
|
63
|
+
const mermaid = window.mermaid;
|
|
64
|
+
if (!mermaid) { reject(new Error("Diagram renderer unavailable")); return; }
|
|
65
|
+
try {
|
|
66
|
+
mermaid.initialize({
|
|
67
|
+
startOnLoad: false, securityLevel: "strict", suppressErrorRendering: true,
|
|
68
|
+
maxTextSize: 50000, maxEdges: 500, htmlLabels: false,
|
|
69
|
+
flowchart: { htmlLabels: false },
|
|
70
|
+
theme: window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "default",
|
|
71
|
+
secure: ["securityLevel", "startOnLoad", "maxTextSize", "maxEdges", "suppressErrorRendering", "htmlLabels", "flowchart", "secure"],
|
|
72
|
+
});
|
|
73
|
+
resolve(mermaid);
|
|
74
|
+
} catch (error) { reject(error); }
|
|
75
|
+
};
|
|
76
|
+
script.onerror = () => { clearTimeout(timeout); script.remove(); reject(new Error("Diagram renderer unavailable")); };
|
|
77
|
+
document.head.appendChild(script);
|
|
78
|
+
}).catch((error) => { library = undefined; throw error; });
|
|
79
|
+
return library;
|
|
80
|
+
};
|
|
81
|
+
const show = (block, state) => {
|
|
82
|
+
const mode = state.error ? "source" : state.mode;
|
|
83
|
+
block.querySelector(".diagram-preview").hidden = mode !== "preview";
|
|
84
|
+
block.querySelector(".diagram-source").hidden = mode !== "source";
|
|
85
|
+
block.dataset.diagramSize = state.size;
|
|
86
|
+
const sizeButton = block.querySelector("[data-diagram-size-toggle]");
|
|
87
|
+
sizeButton.hidden = mode !== "preview" || state.rendered !== state.source;
|
|
88
|
+
sizeButton.textContent = state.size === "actual" ? (zh ? "适应" : "Fit") : (zh ? "原大" : "Actual size");
|
|
89
|
+
sizeButton.setAttribute("aria-label", state.size === "actual"
|
|
90
|
+
? (zh ? "适应容器宽度" : "Fit diagram to width")
|
|
91
|
+
: (zh ? "按原始尺寸查看图表" : "View diagram at actual size"));
|
|
92
|
+
const notice = block.querySelector(".diagram-notice");
|
|
93
|
+
notice.hidden = !state.error && (state.rendered === state.source || mode === "source");
|
|
94
|
+
notice.textContent = state.error ? failed : loading;
|
|
95
|
+
block.querySelectorAll("[data-diagram-mode]").forEach((button) => {
|
|
96
|
+
button.setAttribute("aria-pressed", String(button.dataset.diagramMode === mode));
|
|
97
|
+
});
|
|
98
|
+
};
|
|
99
|
+
const render = async (block, state, revision) => {
|
|
100
|
+
const source = state.source;
|
|
101
|
+
let container;
|
|
102
|
+
try {
|
|
103
|
+
// Keep model-authored Mermaid config from weakening the renderer's safety policy.
|
|
104
|
+
if (source.length > 50000 || /^\s*---(?:\r?\n|$)/u.test(source) || /%%\s*\{/u.test(source)) {
|
|
105
|
+
throw new Error("Diagram configuration or size is unsupported");
|
|
106
|
+
}
|
|
107
|
+
const mermaid = await loadLibrary();
|
|
108
|
+
if (!block.isConnected || state.revision !== revision) return;
|
|
109
|
+
container = document.createElement("div");
|
|
110
|
+
container.style.cssText = "position:absolute;left:-100000px;top:0;width:1000px;visibility:hidden;pointer-events:none";
|
|
111
|
+
document.body.appendChild(container);
|
|
112
|
+
const result = await mermaid.render("agentbot-diagram-" + (++sequence), source, container);
|
|
113
|
+
if (!block.isConnected || state.revision !== revision) return;
|
|
114
|
+
const template = document.createElement("template");
|
|
115
|
+
template.innerHTML = result.svg;
|
|
116
|
+
// Mermaid's strict renderer sanitizes SVG. Also prohibit links, images and callbacks.
|
|
117
|
+
template.content.querySelectorAll("script,foreignObject,iframe,object,embed,image").forEach((node) => node.remove());
|
|
118
|
+
template.content.querySelectorAll("*").forEach((node) => {
|
|
119
|
+
for (const attr of Array.from(node.attributes)) {
|
|
120
|
+
if (/^on/i.test(attr.name) || /^(href|xlink:href|src)$/i.test(attr.name) && !attr.value.startsWith("#")) node.removeAttribute(attr.name);
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
const svg = template.content.querySelector("svg");
|
|
124
|
+
if (!svg) throw new Error("Missing SVG");
|
|
125
|
+
const viewBox = (svg.getAttribute("viewBox") || "").split(/[ ,]+/).map(Number);
|
|
126
|
+
if (viewBox.length === 4 && viewBox.every(Number.isFinite) && viewBox[2] > 0 && viewBox[3] > 0) {
|
|
127
|
+
svg.style.setProperty("--diagram-width", viewBox[2] + "px");
|
|
128
|
+
}
|
|
129
|
+
block.querySelector(".diagram-preview").replaceChildren(template.content);
|
|
130
|
+
state.rendered = source;
|
|
131
|
+
state.error = false;
|
|
132
|
+
show(block, state);
|
|
133
|
+
} catch {
|
|
134
|
+
if (block.isConnected && state.revision === revision) { state.error = true; show(block, state); }
|
|
135
|
+
} finally { if (container) container.remove(); }
|
|
136
|
+
};
|
|
137
|
+
const schedule = (block, state) => {
|
|
138
|
+
clearTimeout(state.timer);
|
|
139
|
+
state.revision += 1;
|
|
140
|
+
if (state.mode !== "preview" || state.rendered === state.source) return;
|
|
141
|
+
const revision = state.revision;
|
|
142
|
+
state.timer = setTimeout(() => void render(block, state, revision), 180);
|
|
143
|
+
};
|
|
144
|
+
const refresh = (block) => {
|
|
145
|
+
const source = block.querySelector(".diagram-source code").textContent;
|
|
146
|
+
let state = states.get(block);
|
|
147
|
+
if (!state) {
|
|
148
|
+
state = { source, mode: "preview", size: "fit", revision: 0, error: false };
|
|
149
|
+
states.set(block, state);
|
|
150
|
+
block.querySelector("[data-diagram-size-toggle]").addEventListener("click", () => {
|
|
151
|
+
state.size = state.size === "actual" ? "fit" : "actual";
|
|
152
|
+
const preview = block.querySelector(".diagram-preview");
|
|
153
|
+
preview.scrollLeft = 0;
|
|
154
|
+
preview.scrollTop = 0;
|
|
155
|
+
show(block, state);
|
|
156
|
+
});
|
|
157
|
+
block.querySelectorAll("[data-diagram-mode]").forEach((button) => {
|
|
158
|
+
button.addEventListener("click", () => {
|
|
159
|
+
state.mode = button.dataset.diagramMode;
|
|
160
|
+
if (state.mode === "preview") state.error = false;
|
|
161
|
+
show(block, state);
|
|
162
|
+
schedule(block, state);
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
} else if (state.source === source) { return; }
|
|
166
|
+
else { state.source = source; state.error = false; }
|
|
167
|
+
show(block, state);
|
|
168
|
+
schedule(block, state);
|
|
169
|
+
};
|
|
170
|
+
const scan = () => document.querySelectorAll("[data-diagram]").forEach(refresh);
|
|
171
|
+
window.agentBotDiagrams = { refresh, scan };
|
|
172
|
+
scan();
|
|
173
|
+
})();`;
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import { TurnPreviewProjection, type TurnPreviewJournal } from "../state/TurnPreviewJournal.js";
|
|
1
2
|
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
|
2
3
|
import fs from "node:fs";
|
|
4
|
+
import { createRequire } from "node:module";
|
|
3
5
|
import http, { type IncomingMessage, type ServerResponse } from "node:http";
|
|
4
6
|
import path from "node:path";
|
|
5
7
|
import hljs from "highlight.js/lib/common";
|
|
@@ -16,10 +18,15 @@ import {
|
|
|
16
18
|
isTurnPreviewState,
|
|
17
19
|
detectTurnPreviewLanguage,
|
|
18
20
|
renderTurnPreviewPage,
|
|
21
|
+
renderTurnPreviewPatch,
|
|
22
|
+
renderToolFooter,
|
|
23
|
+
renderTurnPreviewDetail,
|
|
19
24
|
renderTurnPreviewSnapshot,
|
|
20
25
|
TURN_PREVIEW_CLIENT_SCRIPT,
|
|
21
26
|
} from "./TurnPreviewPage.js";
|
|
22
27
|
|
|
28
|
+
const require = createRequire(import.meta.url);
|
|
29
|
+
let mermaidSource: string | undefined;
|
|
23
30
|
const SECRET_FILE = "secret";
|
|
24
31
|
const PORT_FILE = "port";
|
|
25
32
|
const SHORT_TOKEN_BYTES = 12;
|
|
@@ -230,7 +237,9 @@ export interface LocalFileViewerServerOptions {
|
|
|
230
237
|
port: number;
|
|
231
238
|
publicBaseUrl?: string;
|
|
232
239
|
stateDirectory: string;
|
|
240
|
+
previewJournal?: TurnPreviewJournal;
|
|
233
241
|
getTurnSnapshot?: (turnId: string) => unknown;
|
|
242
|
+
loadTurnSnapshot?: (turnId: string) => Promise<unknown>;
|
|
234
243
|
turnPreviewPollIntervalMs?: number;
|
|
235
244
|
}
|
|
236
245
|
|
|
@@ -376,6 +385,11 @@ export class LocalFileViewerServer {
|
|
|
376
385
|
this.sendJavascript(response, request.method === "HEAD", TURN_PREVIEW_CLIENT_SCRIPT);
|
|
377
386
|
return;
|
|
378
387
|
}
|
|
388
|
+
if (route === "/assets/mermaid.js") {
|
|
389
|
+
mermaidSource ??= fs.readFileSync(require.resolve("mermaid/dist/mermaid.min.js"), "utf8");
|
|
390
|
+
this.sendJavascript(response, request.method === "HEAD", mermaidSource);
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
379
393
|
const turnPreviewMatch = new RegExp(`^/turn-preview/([A-Za-z0-9_-]{${SHORT_TOKEN_LENGTH}})$`, "u").exec(route);
|
|
380
394
|
if (turnPreviewMatch) {
|
|
381
395
|
const turnId = this.verifyTurnPreviewToken(
|
|
@@ -386,23 +400,68 @@ export class LocalFileViewerServer {
|
|
|
386
400
|
this.sendHtml(response, 403, errorPage("Turn 链接无效", "签名校验失败,Agent Bot 已拒绝该请求。"), request.method === "HEAD");
|
|
387
401
|
return;
|
|
388
402
|
}
|
|
389
|
-
const
|
|
403
|
+
const journal = this.options.previewJournal;
|
|
404
|
+
const recorded = journal?.has(turnId);
|
|
405
|
+
if (recorded && requestUrl.searchParams.get("events") === "1") {
|
|
406
|
+
this.serveJournalEvents(request, response, turnId, journal!);
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
const detailKey = requestUrl.searchParams.get("detail");
|
|
410
|
+
let snapshot = recorded
|
|
411
|
+
? journal!.load(turnId, detailKey ?? false)
|
|
412
|
+
: this.options.getTurnSnapshot?.(turnId);
|
|
413
|
+
if (!recorded && this.options.loadTurnSnapshot && request.method !== "HEAD"
|
|
414
|
+
&& requestUrl.searchParams.get("events") !== "1" && !requestUrl.searchParams.has("detail")) {
|
|
415
|
+
const saved = snapshot;
|
|
416
|
+
try {
|
|
417
|
+
snapshot = await this.options.loadTurnSnapshot(turnId);
|
|
418
|
+
} catch (error) {
|
|
419
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
420
|
+
if (!isTurnPreviewState(saved)) {
|
|
421
|
+
this.sendHtml(response, 502, errorPage("历史执行详情读取失败", `${message} 刷新页面重试;任务状态未改变。`), false);
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
snapshot = { ...saved, historyDetailError: message };
|
|
425
|
+
}
|
|
426
|
+
}
|
|
390
427
|
if (!isTurnPreviewState(snapshot)) {
|
|
391
428
|
this.sendHtml(response, 404, errorPage("Turn 不存在", "这次执行可能尚未保存或已被清理。"), request.method === "HEAD");
|
|
392
429
|
return;
|
|
393
430
|
}
|
|
431
|
+
if (requestUrl.searchParams.has("detail")) {
|
|
432
|
+
const language = detectTurnPreviewLanguage(request.headers["accept-language"]);
|
|
433
|
+
const key = requestUrl.searchParams.get("detail") ?? "";
|
|
434
|
+
const tool = snapshot.activities.find((a) => a.kind === "tool" && `tool:${a.id}` === key);
|
|
435
|
+
const after = requestUrl.searchParams.get("detailAfter");
|
|
436
|
+
const outputAppend = recorded && after && tool?.kind === "tool" && tool.tool.kind === "command" && !tool.tool.error
|
|
437
|
+
&& !/repl/i.test(tool.tool.title) && snapshot.previewCursor !== undefined
|
|
438
|
+
? journal!.outputAppend(turnId, tool.id, Number(after), snapshot.previewCursor) : undefined;
|
|
439
|
+
const detail = outputAppend !== undefined && tool?.kind === "tool"
|
|
440
|
+
&& (snapshot.fullToolOutputs?.[tool.id]?.length ?? 0) > outputAppend.length
|
|
441
|
+
? { key, revision: String(tool.tool.previewRevision), content: "", outputAppend,
|
|
442
|
+
footer: renderToolFooter(tool.tool, snapshot.fullToolOutputs?.[tool.id]?.length ?? 0, language) }
|
|
443
|
+
: renderTurnPreviewDetail(snapshot, key, (filePath) => this.createFileUrl(filePath), language);
|
|
444
|
+
setSecurityHeaders(response);
|
|
445
|
+
response.statusCode = detail ? 200 : 404;
|
|
446
|
+
response.setHeader("Cache-Control", "no-store");
|
|
447
|
+
response.setHeader("Content-Type", "application/json; charset=utf-8");
|
|
448
|
+
response.end(request.method === "HEAD" ? undefined : JSON.stringify(detail ? { ...detail, cursor: snapshot.previewCursor } : { error: "Turn detail not found." }));
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
394
451
|
if (requestUrl.searchParams.get("events") === "1") {
|
|
395
452
|
this.serveTurnEvents(request, response, turnId);
|
|
396
453
|
return;
|
|
397
454
|
}
|
|
398
455
|
const eventsUrl = new URL(this.createTurnPreviewUrl(turnId)!);
|
|
399
456
|
eventsUrl.searchParams.set("events", "1");
|
|
457
|
+
if (snapshot.previewCursor !== undefined) eventsUrl.searchParams.set("after", String(snapshot.previewCursor));
|
|
400
458
|
const language = detectTurnPreviewLanguage(request.headers["accept-language"]);
|
|
401
459
|
const html = renderTurnPreviewPage({
|
|
402
460
|
state: snapshot,
|
|
403
461
|
eventsUrl: eventsUrl.toString(),
|
|
404
462
|
scriptPath: `${this.basePath}/assets/turn-preview.js`.replace(/\/{2,}/gu, "/"),
|
|
405
463
|
localFileUrl: (filePath) => this.createFileUrl(filePath),
|
|
464
|
+
diagramScriptPath: `${this.basePath}/assets/mermaid.js`.replace(/\/{2,}/gu, "/"),
|
|
406
465
|
language,
|
|
407
466
|
});
|
|
408
467
|
this.sendHtml(response, 200, html, request.method === "HEAD");
|
|
@@ -683,6 +742,74 @@ export class LocalFileViewerServer {
|
|
|
683
742
|
response.once("close", cleanup);
|
|
684
743
|
}
|
|
685
744
|
|
|
745
|
+
private serveJournalEvents(request: IncomingMessage, response: ServerResponse, turnId: string, journal: TurnPreviewJournal): void {
|
|
746
|
+
journal.flush(turnId);
|
|
747
|
+
setSecurityHeaders(response);
|
|
748
|
+
response.writeHead(200, { "Content-Type": "text/event-stream; charset=utf-8", "Cache-Control": "no-cache, no-store", "X-Accel-Buffering": "no" });
|
|
749
|
+
if (request.method === "HEAD") { response.end(); return; }
|
|
750
|
+
response.flushHeaders();
|
|
751
|
+
const language = detectTurnPreviewLanguage(request.headers["accept-language"]);
|
|
752
|
+
const projection = new TurnPreviewProjection(false);
|
|
753
|
+
let offset = 0;
|
|
754
|
+
let closed = false;
|
|
755
|
+
let initial = true;
|
|
756
|
+
const cursor = request.headers["last-event-id"] ?? new URL(request.url!, "http://localhost").searchParams.get("after");
|
|
757
|
+
if (typeof cursor === "string" && /^\d+$/u.test(cursor) && Number.isSafeInteger(Number(cursor)) && Number(cursor) > 0
|
|
758
|
+
&& Number(cursor) <= fs.statSync(journal.file(turnId)).size) {
|
|
759
|
+
while (offset < Number(cursor)) {
|
|
760
|
+
const batch = journal.read(turnId, offset, 256 * 1024, Number(cursor));
|
|
761
|
+
for (const record of batch.records) projection.apply(record, ++projection.revision);
|
|
762
|
+
if (batch.offset === offset) break;
|
|
763
|
+
offset = batch.offset;
|
|
764
|
+
}
|
|
765
|
+
initial = offset !== Number(cursor);
|
|
766
|
+
}
|
|
767
|
+
let waitingDrain = false;
|
|
768
|
+
const poll = () => {
|
|
769
|
+
if (closed || waitingDrain) return;
|
|
770
|
+
try {
|
|
771
|
+
projection.changed.clear();
|
|
772
|
+
projection.removed.clear();
|
|
773
|
+
const batch = journal.read(turnId, offset);
|
|
774
|
+
if (!batch.records.length) {
|
|
775
|
+
const state = projection.state;
|
|
776
|
+
if (state && ["completed", "cancelled", "failed"].includes(state.status)) {
|
|
777
|
+
const update = renderTurnPreviewPatch(state, new Set(), new Set(), (file) => this.createFileUrl(file), language);
|
|
778
|
+
response.write(`id: ${offset}\nevent: patch\ndata: ${JSON.stringify(update)}\n\n`);
|
|
779
|
+
cleanup(); response.end();
|
|
780
|
+
}
|
|
781
|
+
return;
|
|
782
|
+
}
|
|
783
|
+
offset = batch.offset;
|
|
784
|
+
for (const record of batch.records) projection.apply(record, ++projection.revision);
|
|
785
|
+
const state = projection.state;
|
|
786
|
+
if (!state) return;
|
|
787
|
+
const render = initial
|
|
788
|
+
? renderTurnPreviewSnapshot(state, (file) => this.createFileUrl(file), language, { deferDetails: true })
|
|
789
|
+
: renderTurnPreviewPatch(state, projection.changed, projection.removed, (file) => this.createFileUrl(file), language);
|
|
790
|
+
// A terminal record can share a batch with later metadata. Drain the captured prefix first.
|
|
791
|
+
render.terminal = render.terminal && batch.end;
|
|
792
|
+
waitingDrain = !response.write(`id: ${offset}\nevent: ${initial ? "update" : "patch"}\ndata: ${JSON.stringify(render)}\n\n`);
|
|
793
|
+
initial = false;
|
|
794
|
+
if (render.terminal) { cleanup(); response.end(); }
|
|
795
|
+
else if (!batch.end) setImmediate(poll);
|
|
796
|
+
} catch (error) {
|
|
797
|
+
writeServerSentEvent(response, "unavailable", JSON.stringify({ message: "Turn preview could not be read." }));
|
|
798
|
+
cleanup();
|
|
799
|
+
response.end();
|
|
800
|
+
}
|
|
801
|
+
};
|
|
802
|
+
const poller = setInterval(poll, Math.max(50, this.options.turnPreviewPollIntervalMs ?? 1_000));
|
|
803
|
+
poller.unref();
|
|
804
|
+
const keepAlive = setInterval(() => { if (!closed && !waitingDrain) response.write(": keep-alive\n\n"); }, 15_000);
|
|
805
|
+
keepAlive.unref();
|
|
806
|
+
const cleanup = () => { closed = true; clearInterval(poller); clearInterval(keepAlive); };
|
|
807
|
+
response.on("drain", () => { waitingDrain = false; poll(); });
|
|
808
|
+
response.once("close", cleanup);
|
|
809
|
+
request.once("aborted", cleanup);
|
|
810
|
+
poll();
|
|
811
|
+
}
|
|
812
|
+
|
|
686
813
|
private serveTurnEvents(request: IncomingMessage, response: ServerResponse, turnId: string): void {
|
|
687
814
|
setSecurityHeaders(response);
|
|
688
815
|
response.statusCode = 200;
|
|
@@ -710,7 +837,7 @@ export class LocalFileViewerServer {
|
|
|
710
837
|
const serialized = JSON.stringify(state);
|
|
711
838
|
if (serialized === previousSnapshot) return;
|
|
712
839
|
previousSnapshot = serialized;
|
|
713
|
-
const snapshot = renderTurnPreviewSnapshot(state, (filePath) => this.createFileUrl(filePath), language);
|
|
840
|
+
const snapshot = renderTurnPreviewSnapshot(state, (filePath) => this.createFileUrl(filePath), language, { deferDetails: true });
|
|
714
841
|
writeServerSentEvent(response, "update", JSON.stringify(snapshot));
|
|
715
842
|
};
|
|
716
843
|
sendSnapshot();
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import hljs from "highlight.js/lib/common";
|
|
2
|
+
import dockerfile from "highlight.js/lib/languages/dockerfile";
|
|
3
|
+
import dos from "highlight.js/lib/languages/dos";
|
|
4
|
+
import powershell from "highlight.js/lib/languages/powershell";
|
|
5
|
+
|
|
6
|
+
hljs.registerLanguage("dockerfile", dockerfile);
|
|
7
|
+
hljs.registerLanguage("dos", dos);
|
|
8
|
+
hljs.registerLanguage("powershell", powershell);
|
|
9
|
+
|
|
10
|
+
const LANGUAGE_ALIASES: Readonly<Record<string, string>> = { zsh: "bash", pwsh: "powershell" };
|
|
11
|
+
export const MAX_PREVIEW_HIGHLIGHT_BYTES = 64 * 1024;
|
|
12
|
+
const MAX_CACHE_BYTES = 1024 * 1024;
|
|
13
|
+
const MAX_CACHE_ENTRIES = 32;
|
|
14
|
+
const cache = new Map<string, { html: string; bytes: number }>();
|
|
15
|
+
let cacheBytes = 0;
|
|
16
|
+
|
|
17
|
+
// Returning an empty string asks MarkdownIt to escape the original code unchanged.
|
|
18
|
+
export function highlightPreviewCode(value: string, language: string): string {
|
|
19
|
+
const name = language.trim().toLowerCase();
|
|
20
|
+
const resolved = Object.hasOwn(LANGUAGE_ALIASES, name) ? LANGUAGE_ALIASES[name]! : name;
|
|
21
|
+
if (!resolved || !hljs.getLanguage(resolved) || Buffer.byteLength(value, "utf8") > MAX_PREVIEW_HIGHLIGHT_BYTES) return "";
|
|
22
|
+
const key = `${resolved}\0${value}`;
|
|
23
|
+
const cached = cache.get(key);
|
|
24
|
+
if (cached) {
|
|
25
|
+
cache.delete(key);
|
|
26
|
+
cache.set(key, cached);
|
|
27
|
+
return cached.html;
|
|
28
|
+
}
|
|
29
|
+
try {
|
|
30
|
+
const html = hljs.highlight(value, { language: resolved, ignoreIllegals: true }).value;
|
|
31
|
+
const bytes = Buffer.byteLength(key, "utf8") + Buffer.byteLength(html, "utf8");
|
|
32
|
+
if (bytes <= MAX_CACHE_BYTES) {
|
|
33
|
+
while (cache.size >= MAX_CACHE_ENTRIES || cacheBytes + bytes > MAX_CACHE_BYTES) {
|
|
34
|
+
const oldest = cache.keys().next().value!;
|
|
35
|
+
cacheBytes -= cache.get(oldest)!.bytes;
|
|
36
|
+
cache.delete(oldest);
|
|
37
|
+
}
|
|
38
|
+
cache.set(key, { html, bytes });
|
|
39
|
+
cacheBytes += bytes;
|
|
40
|
+
}
|
|
41
|
+
return html;
|
|
42
|
+
} catch {
|
|
43
|
+
return "";
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const PREVIEW_SYNTAX_CSS = `
|
|
48
|
+
.markdown pre { --syntax-comment:#667080; --syntax-keyword:#9639a5; --syntax-string:#087f5b; --syntax-number:#986801; --syntax-title:#005cc5; --syntax-deletion:#b31d28; }
|
|
49
|
+
.markdown pre .hljs-comment, .markdown pre .hljs-quote { color:var(--syntax-comment); }
|
|
50
|
+
.markdown pre .hljs-keyword, .markdown pre .hljs-selector-tag, .markdown pre .hljs-literal, .markdown pre .hljs-section, .markdown pre .hljs-link { color:var(--syntax-keyword); }
|
|
51
|
+
.markdown pre .hljs-string, .markdown pre .hljs-regexp, .markdown pre .hljs-name, .markdown pre .hljs-type, .markdown pre .hljs-attribute, .markdown pre .hljs-symbol, .markdown pre .hljs-bullet, .markdown pre .hljs-addition, .markdown pre .hljs-variable, .markdown pre .hljs-template-tag, .markdown pre .hljs-template-variable { color:var(--syntax-string); }
|
|
52
|
+
.markdown pre .hljs-number, .markdown pre .hljs-meta, .markdown pre .hljs-built_in, .markdown pre .hljs-builtin-name, .markdown pre .hljs-params, .markdown pre .hljs-attr { color:var(--syntax-number); }
|
|
53
|
+
.markdown pre .hljs-title { color:var(--syntax-title); }
|
|
54
|
+
.markdown pre .hljs-deletion { color:var(--syntax-deletion); }
|
|
55
|
+
@media (prefers-color-scheme:dark) {
|
|
56
|
+
.markdown pre { --syntax-comment:#8b949e; --syntax-keyword:#ff7b72; --syntax-string:#a5d6ff; --syntax-number:#d2a8ff; --syntax-title:#79c0ff; --syntax-deletion:#ffdcd7; }
|
|
57
|
+
}
|
|
58
|
+
`;
|