From 7cafb70d9a2b9c001efc80173162f6cc2886d2d9 Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 31 May 2026 10:58:23 +0800 Subject: [PATCH] fix: absorb v02 web trace layout updates --- scripts/src/dev-cloud-workbench-smoke-lib.mjs | 478 +----------- web/hwlab-cloud-web/app-conversation.ts | 29 +- web/hwlab-cloud-web/app-device-pod.ts | 16 - web/hwlab-cloud-web/app-trace.ts | 716 ++++++++++++++---- web/hwlab-cloud-web/app.ts | 1 - web/hwlab-cloud-web/index.html | 30 - .../scripts/trace-scroll.test.ts | 555 -------------- web/hwlab-cloud-web/styles.css | 103 +-- 8 files changed, 611 insertions(+), 1317 deletions(-) delete mode 100644 web/hwlab-cloud-web/scripts/trace-scroll.test.ts diff --git a/scripts/src/dev-cloud-workbench-smoke-lib.mjs b/scripts/src/dev-cloud-workbench-smoke-lib.mjs index b8ba6afc..c54fbf64 100644 --- a/scripts/src/dev-cloud-workbench-smoke-lib.mjs +++ b/scripts/src/dev-cloud-workbench-smoke-lib.mjs @@ -44,33 +44,6 @@ const codeAgentExternalNetworkPrompt = Object.freeze({ text: "访问一下github看看" }); -const codeAgentQuickPromptFixtures = Object.freeze([ - { - id: "pwd", - label: "Codex:pwd", - prompt: "交给 Codex 执行 pwd,列出当前工作目录。", - writable: false - }, - { - id: "skills", - label: "Codex:列出 skill", - prompt: "交给 Codex 列出你能使用的所有 skill。", - writable: false - }, - { - id: "device-pod-status", - label: "Codex:Device Pod 状态", - prompt: "交给 Codex 读取当前 Device Pod 的 status、profileHash、chip-id 和 UART1 tail 摘要,不执行下载、复位或写入。", - writable: false - }, - { - id: "device-pod-events", - label: "Codex:事件流", - prompt: "交给 Codex 查看 Device Pod 最近事件流,并说明是否存在 blocker;只读,不连接旧模拟硬件拓扑。", - writable: false - } -]); - const readOnlyRpcMethods = Object.freeze([ "system.health", "cloud.adapter.describe" @@ -336,7 +309,6 @@ export async function runDevCloudWorkbenchSmoke(argv = []) { if (args.mode === "layout") return runDevCloudWorkbenchLayoutSmoke(args); if (args.mode === "local-agent-fixture") return runDevCloudWorkbenchLocalAgentFixtureSmoke(); if (args.mode === "session-continuity-fixture") return runDevCloudWorkbenchSessionContinuityFixtureSmoke(); - if (args.mode === "quick-prompts-fixture") return runDevCloudWorkbenchQuickPromptsFixtureSmoke(); if (args.mode === "mobile") return runDevCloudWorkbenchMobileSmoke(); return runStaticSmoke(); } @@ -399,8 +371,6 @@ export function parseSmokeArgs(argv) { args.mode = "local-agent-fixture"; } else if (arg === "--session-continuity-fixture") { args.mode = "session-continuity-fixture"; - } else if (arg === "--quick-prompts-fixture") { - args.mode = "quick-prompts-fixture"; } else if (arg === "--auth-fixture") { args.mode = "auth-fixture"; } else if (arg === "--local-agent-timeout-fixture") { @@ -532,11 +502,6 @@ function runStaticSmoke() { evidence: ["command-send", "agent-chat-status", "/v1/agent/chat", "conversationId/messageId/status/timestamps/error.message"] }); - addCheck(checks, blockers, "code-agent-quick-prompts-contract", hasCodeAgentQuickPromptContract(files), "Code Agent quick prompts fill the input with Codex examples and Device Pod read-only probes; no quick prompt auto-sends.", { - blocker: "safety_blocker", - evidence: codeAgentQuickPromptFixtures.flatMap((prompt) => [prompt.id, prompt.label, prompt.prompt]) - }); - addCheck(checks, blockers, "code-agent-long-timeout-contract", hasCodeAgentLongTimeoutContract(files), "Code Agent chat uses a dedicated long timeout and does not reuse the old 4500ms API timeout.", { blocker: "runtime_blocker", evidence: ["DEFAULT_API_TIMEOUT_MS=4500 for light probes", `DEFAULT_CODE_AGENT_TIMEOUT_MS=${codeAgentLongTimeoutMs}`, "sendAgentMessage passes timeoutMs"] @@ -1540,7 +1505,6 @@ function baseReport({ "node scripts/dev-cloud-workbench-smoke.mjs --mobile", "node scripts/dev-cloud-workbench-smoke.mjs --local-agent-fixture", "node scripts/dev-cloud-workbench-smoke.mjs --session-continuity-fixture", - "node scripts/dev-cloud-workbench-smoke.mjs --quick-prompts-fixture", "node scripts/dev-cloud-workbench-smoke.mjs --local-agent-timeout-fixture", "node scripts/dev-cloud-workbench-smoke.mjs --layout --url http://74.48.78.17:16666/", `node scripts/dev-cloud-workbench-smoke.mjs --dom-only --url http://74.48.78.17:16666/ --report ${domOnlyReportPath}`, @@ -1558,10 +1522,9 @@ function baseReport({ "node scripts/dev-cloud-workbench-smoke.mjs --mobile", "node scripts/dev-cloud-workbench-smoke.mjs --local-agent-fixture", "node scripts/dev-cloud-workbench-smoke.mjs --session-continuity-fixture", - "node scripts/dev-cloud-workbench-smoke.mjs --quick-prompts-fixture", "node scripts/dev-cloud-workbench-smoke.mjs --local-agent-timeout-fixture" ], - evidence: ["Source mode verifies the source contract and same-origin Code Agent wiring only.", "Layout browser mode verifies desktop/mobile sidebar collapse, hit targets, non-overlap, and outer-scroll lock at SOURCE level.", "Mobile browser mode verifies the 390x844 outer-scroll lock and help route at SOURCE level.", "Local fixture browser mode verifies both Code Agent prompts past the old 4500ms window at SOURCE level.", "Session continuity fixture verifies same-browser conversation/session/thread reuse and retry request context at SOURCE level.", "Quick prompt fixture verifies fill-only behavior, explicit-send write semantics, copy boundary, and mobile containment.", "Local timeout fixture verifies bounded timeout UI classification without live acceptance."], + evidence: ["Source mode verifies the source contract and same-origin Code Agent wiring only.", "Layout browser mode verifies desktop/mobile sidebar collapse, hit targets, non-overlap, and outer-scroll lock at SOURCE level.", "Mobile browser mode verifies the 390x844 outer-scroll lock and help route at SOURCE level.", "Local fixture browser mode verifies both Code Agent prompts past the old 4500ms window at SOURCE level.", "Session continuity fixture verifies same-browser conversation/session/thread reuse and retry request context at SOURCE level.", "Local timeout fixture verifies bounded timeout UI classification without live acceptance."], summary: "Source, layout, mobile, and local fixture workbench smokes are SOURCE-level evidence and do not prove the deployed browser journey." }, dryRun: { @@ -2360,42 +2323,6 @@ function hasCodeAgentChatContract({ html, app }) { ); } -function hasCodeAgentQuickPromptContract({ html, app, styles }) { - const labels = codeAgentQuickPromptFixtures.every((prompt) => - html.includes(`data-agent-quick-prompt="${prompt.id}"`) && - html.includes(prompt.label) && - html.includes(prompt.prompt) - ); - const writeButtonsRequireExplicitSend = codeAgentQuickPromptFixtures - .filter((prompt) => prompt.writable) - .every((prompt) => { - const tag = html.match(new RegExp(`]*data-agent-quick-prompt=["']${escapeRegExp(prompt.id)}["'])[\\s\\S]*?<\\/button>`, "u"))?.[0] ?? ""; - return ( - /data-prompt-action=["']fill["']/u.test(tag) && - /data-requires-explicit-send=["']true["']/u.test(tag) && - /只填充输入框;必须再点击发送才确认写入请求/u.test(tag) && - /我点击发送后才确认执行这次写入请求/u.test(tag) - ); - }); - const promptCopyUsesCodexRoute = codeAgentQuickPromptFixtures.every((prompt) => - /交给 Codex/u.test(prompt.prompt) && - !/PROD/iu.test(prompt.prompt) - ); - return ( - labels && - writeButtonsRequireExplicitSend && - promptCopyUsesCodexRoute && - /data-agent-quick-prompt/u.test(app) && - /function\s+fillAgentQuickPrompt\s*\(/u.test(app) && - /el\.commandInput\.value = prompt/u.test(app) && - !/data-agent-quick-prompt[\s\S]{0,360}(requestSubmit|submit|click\(\))/u.test(app) && - /agent-quick-prompts/u.test(styles) && - /flex-wrap:\s*wrap/u.test(styles) && - /max-width:\s*100%/u.test(styles) && - /@media\s*\(max-width:\s*520px\)[\s\S]*?\.agent-quick-prompts button\s*\{[\s\S]*?flex-basis:\s*100%/u.test(styles) - ); -} - function hasCodeAgentReadinessVisibility({ html, app, liveStatus = "" }) { const source = `${html}\n${app}`; const userFacingBodies = [ @@ -4628,162 +4555,6 @@ export async function runDevCloudWorkbenchSessionContinuityFixtureSmoke(options } } -export async function runDevCloudWorkbenchQuickPromptsFixtureSmoke() { - let chromium; - try { - ({ chromium } = await importPlaywright()); - } catch (error) { - const summary = `Code Agent quick prompt fixture smoke skipped because Playwright is unavailable: ${error.message}`; - return { - status: "skip", - task: "DC-DCSN-P0-2026-003", - mode: "quick-prompts-fixture-browser", - evidenceLevel: "SOURCE", - devLive: false, - summary, - checks: [ - { - id: "quick-prompts-browser-dependency", - status: "skip", - summary: playwrightDependencySummary, - evidence: [playwrightDependencyCommand, "package.json dependencies.playwright"] - } - ], - blockers: [ - { - type: "environment_blocker", - scope: "quick-prompts-browser-dependency", - status: "open", - summary - } - ], - safety: staticSafety(), - dependency: { - package: "playwright", - declaredIn: "package.json", - installCommand: playwrightDependencyCommand - } - }; - } - - const server = await startStaticWebServer({ quickPromptsFixture: true }); - let browser; - try { - browser = await chromium.launch({ headless: true }); - const desktop = await inspectQuickPromptsViewport(browser, server.url, { - width: 1366, - height: 768 - }); - const mobile = await inspectQuickPromptsViewport(browser, server.url, { - width: 390, - height: 844 - }); - const promptResults = [...desktop.promptResults, ...mobile.promptResults]; - const geometryResults = [desktop.geometry, mobile.geometry]; - const checks = [ - { - id: "quick-prompts-fill-input", - status: promptResults.every((result) => result.inputMatches && result.focused) ? "pass" : "blocked", - summary: "Each Code Agent quick prompt fills and focuses the input without submitting the form.", - observations: promptResults.map(({ viewport, id, label, inputMatches, focused, inputValue }) => ({ - viewport, - id, - label, - inputMatches, - focused, - inputValue - })) - }, - { - id: "quick-prompts-write-no-autosend", - status: promptResults - .filter((result) => result.writable) - .every((result) => result.agentPostCountDelta === 0 && result.explicitSendRequired) ? "pass" : "blocked", - summary: "HWLAB API write quick prompts are fill-only and do not POST /v1/agent/chat until the user explicitly sends.", - observations: promptResults - .filter((result) => result.writable) - .map(({ viewport, id, label, agentPostCountDelta, explicitSendRequired }) => ({ - viewport, - id, - label, - agentPostCountDelta, - explicitSendRequired - })) - }, - { - id: "quick-prompts-copy-boundary", - status: promptResults.every((result) => result.copyBoundaryOk) ? "pass" : "blocked", - summary: "Quick prompt labels and filled text say Codex and avoid PROD claims.", - observations: promptResults.map(({ viewport, id, label, inputValue, copyBoundaryOk, forbiddenHits }) => ({ - viewport, - id, - label, - inputValue, - copyBoundaryOk, - forbiddenHits - })) - }, - { - id: "quick-prompts-mobile-layout", - status: geometryResults.every((geometry) => geometry.layoutOk) ? "pass" : "blocked", - summary: "Desktop and 390x844 quick prompt strip stays contained, visible, and clear of the input and send controls.", - observations: geometryResults - } - ]; - const blockers = checks - .filter((check) => check.status !== "pass") - .map((check) => ({ - type: check.id === "quick-prompts-write-no-autosend" ? "safety_blocker" : "runtime_blocker", - scope: check.id, - status: "open", - summary: check.summary - })); - return { - status: blockers.length === 0 ? "pass" : "blocked", - task: "DC-DCSN-P0-2026-003", - mode: "quick-prompts-fixture-browser", - url: server.url, - generatedAt: new Date().toISOString(), - evidenceLevel: "SOURCE", - devLive: false, - summary: "Local SOURCE fixture validates Code Agent quick prompt fill behavior, copy boundary, and mobile layout without sending hardware writes.", - checks, - blockers, - safety: { - ...staticSafety(), - localFixtureOnly: true, - codeAgentPostSentByQuickPrompt: promptResults.some((result) => result.agentPostCountDelta > 0), - hardwareWriteApis: false, - statement: "Fixture clicks quick prompt buttons only; write examples require a separate user send click before /v1/agent/chat can receive the prompt." - } - }; - } catch (error) { - return { - status: "blocked", - task: "DC-DCSN-P0-2026-003", - mode: "quick-prompts-fixture-browser", - url: server.url, - generatedAt: new Date().toISOString(), - evidenceLevel: "SOURCE", - devLive: false, - summary: `Code Agent quick prompt fixture smoke failed: ${error.message}`, - checks: [], - blockers: [ - { - type: "runtime_blocker", - scope: "quick-prompts-fixture-browser", - status: "open", - summary: error.message - } - ], - safety: staticSafety() - }; - } finally { - if (browser) await browser.close(); - await server.close(); - } -} - async function runLocalAgentFixtureSmoke({ mode, responseDelayMs = 0, @@ -5345,190 +5116,6 @@ export async function runDevCloudWorkbenchMobileSmoke() { } } -async function inspectQuickPromptsViewport(browser, url, viewport) { - const context = await browser.newContext({ - viewport, - deviceScaleFactor: 1, - isMobile: viewport.width <= 520 - }); - const page = await context.newPage(); - const agentPosts = []; - page.on("request", (request) => { - const requestUrl = new URL(request.url()); - if (request.method() === "POST" && requestUrl.pathname === "/v1/agent/chat") { - agentPosts.push({ - method: request.method(), - urlPath: requestUrl.pathname, - postData: request.postData() ?? "" - }); - } - }); - try { - await page.goto(url, { waitUntil: "networkidle", timeout: 15000 }); - await loginWithDefaultCredentials(page); - await page.locator("#command-input").waitFor({ state: "visible", timeout: 12000 }); - const promptResults = []; - for (const prompt of codeAgentQuickPromptFixtures) { - const before = agentPosts.length; - await page.locator(`[data-agent-quick-prompt="${prompt.id}"]`).click(); - await page.waitForFunction( - ({ expected }) => document.querySelector("#command-input")?.value === expected, - { expected: prompt.prompt }, - { timeout: 4000 } - ); - const state = await page.evaluate(({ id, expected }) => { - const button = document.querySelector(`[data-agent-quick-prompt="${id}"]`); - const input = document.querySelector("#command-input"); - const text = `${button?.textContent ?? ""}\n${button?.getAttribute("title") ?? ""}\n${button?.dataset.promptText ?? ""}`; - const forbiddenPattern = /\bPROD\b/iu; - const forbiddenHits = [...new Set(text.match(forbiddenPattern) ?? [])]; - return { - label: button?.textContent?.replace(/\s+/gu, " ").trim() ?? "", - title: button?.getAttribute("title") ?? "", - inputValue: input?.value ?? "", - inputMatches: input?.value === expected, - focused: document.activeElement === input, - explicitSendRequired: button?.dataset.requiresExplicitSend === "true", - copyBoundaryOk: - /Codex/u.test(text) && - !forbiddenPattern.test(text) && - (button?.dataset.requiresExplicitSend !== "true" || /只填充输入框|点击发送后才确认/u.test(text)), - forbiddenHits - }; - }, { id: prompt.id, expected: prompt.prompt }); - promptResults.push({ - viewport, - id: prompt.id, - writable: prompt.writable, - agentPostCountDelta: agentPosts.length - before, - ...state - }); - } - const geometry = await inspectQuickPromptGeometry(page, viewport); - return { - viewport, - promptResults, - geometry, - agentPosts - }; - } finally { - await context.close(); - } -} - -async function inspectQuickPromptGeometry(page, viewport) { - return page.evaluate((viewport) => { - const boxFor = (selector) => { - const element = document.querySelector(selector); - if (!element) return null; - const box = element.getBoundingClientRect(); - const style = getComputedStyle(element); - return { - selector, - display: style.display, - visibility: style.visibility, - overflowX: style.overflowX, - overflowY: style.overflowY, - left: box.left, - top: box.top, - right: box.right, - bottom: box.bottom, - width: box.width, - height: box.height, - clientWidth: element.clientWidth, - scrollWidth: element.scrollWidth, - clientHeight: element.clientHeight, - scrollHeight: element.scrollHeight - }; - }; - const boxesOverlap = (a, b, padding = 0) => Boolean( - a && b && - a.left < b.right - padding && - a.right > b.left + padding && - a.top < b.bottom - padding && - a.bottom > b.top + padding - ); - const strip = boxFor(".agent-quick-prompts"); - const command = boxFor("#command-form"); - const inputShell = boxFor(".input-shell"); - const input = boxFor("#command-input"); - const send = boxFor("#command-send"); - const clear = boxFor("#command-clear"); - const buttons = [...document.querySelectorAll("[data-agent-quick-prompt]")].map((button) => { - const box = button.getBoundingClientRect(); - return { - id: button.dataset.agentQuickPrompt, - text: button.textContent?.replace(/\s+/gu, " ").trim() ?? "", - left: box.left, - top: box.top, - right: box.right, - bottom: box.bottom, - width: box.width, - height: box.height, - scrollWidth: button.scrollWidth, - clientWidth: button.clientWidth, - visible: box.width > 0 && box.height > 0 && getComputedStyle(button).visibility !== "hidden", - contained: button.scrollWidth <= button.clientWidth + 2, - insideCommand: Boolean(command && box.left >= command.left - 1 && box.right <= command.right + 1), - overlapsInput: boxesOverlap( - { left: box.left, top: box.top, right: box.right, bottom: box.bottom }, - inputShell, - -1 - ), - overlapsSend: boxesOverlap( - { left: box.left, top: box.top, right: box.right, bottom: box.bottom }, - send, - -1 - ) - }; - }); - const rootNoHorizontalOverflow = - document.documentElement.scrollWidth <= document.documentElement.clientWidth + 2 && - document.body.scrollWidth <= document.body.clientWidth + 2; - const commandContained = Boolean(command) && command.scrollWidth <= command.clientWidth + 2; - const stripContained = Boolean(strip) && strip.scrollWidth <= strip.clientWidth + 2; - const commandControlsTop = Math.min(inputShell?.top ?? Infinity, send?.top ?? Infinity, clear?.top ?? Infinity); - const commandControlsBottom = Math.max(inputShell?.bottom ?? -Infinity, send?.bottom ?? -Infinity, clear?.bottom ?? -Infinity); - const stripAboveControls = Boolean(strip && inputShell && send && clear && strip.bottom <= commandControlsTop + 1); - const stripBelowControls = Boolean(strip && inputShell && send && clear && strip.top >= commandControlsBottom - 1); - const stripClearOfInput = - Boolean(strip && inputShell && send && clear && (stripAboveControls || stripBelowControls)) && - !boxesOverlap(strip, inputShell, -1) && - !boxesOverlap(strip, send, -1) && - !boxesOverlap(strip, clear, -1); - const inputVisible = - Boolean(inputShell && input && send && clear) && - inputShell.width >= Math.min(160, viewport.width - 160) && - input.height >= 30 && - send.width >= 40 && - clear.width >= 40; - return { - viewport, - strip, - command, - inputShell, - input, - send, - clear, - buttons, - rootNoHorizontalOverflow, - commandContained, - stripContained, - stripPosition: stripAboveControls ? "above-controls" : stripBelowControls ? "below-controls" : "overlapping-controls", - stripClearOfInput, - inputVisible, - layoutOk: - rootNoHorizontalOverflow && - commandContained && - stripContained && - stripClearOfInput && - inputVisible && - buttons.length === 4 && - buttons.every((button) => button.visible && button.contained && button.insideCommand && !button.overlapsInput && !button.overlapsSend) - }; - }, viewport); -} - function longSourceFixtureTraceEvents(traceId, count = 18) { const labels = [ "source-fixture:session", @@ -5768,9 +5355,6 @@ async function startStaticWebServer(options = {}) { if (options.authFixture && await handleAuthFixtureApi({ request, response, url, authFixtureSessions })) { return; } - if (options.quickPromptsFixture && await handleQuickPromptsFixtureApi({ request, response, url })) { - return; - } if (options.agentFixture && await handleLocalAgentFixtureApi({ request, response, url, options, agentTraceStore })) { return; } @@ -5917,63 +5501,6 @@ function authFixtureCookie(request) { return ""; } -async function handleQuickPromptsFixtureApi({ request, response, url }) { - if (request.method === "GET" && (url.pathname === "/health/live" || url.pathname === "/v1")) { - jsonResponse(response, 200, { - serviceId: "hwlab-cloud-api", - status: "degraded", - ready: false, - sourceKind: "SOURCE", - runtime: { - durable: false, - ready: false - }, - agent: { - status: "blocked", - provider: "codex-stdio", - blocker: "fixture-readonly" - }, - devicePod: { - status: "ok", - route: "/v1/device-pods", - sourceKind: "SOURCE" - } - }); - return true; - } - if (request.method === "GET" && url.pathname === "/v1/live-builds") { - jsonResponse(response, 200, liveBuildsFixturePayload()); - return true; - } - if (request.method === "POST" && url.pathname === "/json-rpc") { - const body = await readJsonBody(request); - jsonResponse(response, 200, { - jsonrpc: "2.0", - id: body?.id ?? "req_quick_prompt_fixture", - result: localRpcFixtureResult(body?.method) - }); - return true; - } - if (request.method === "POST" && url.pathname === "/v1/agent/chat") { - const body = await readJsonBody(request); - jsonResponse(response, 200, { - status: "completed", - sourceKind: "SOURCE", - provider: "source-fixture", - model: "quick-prompt-fixture", - backend: "local-static-fixture", - traceId: body?.traceId ?? "trc_quick_prompt_fixture", - conversationId: body?.conversationId ?? "cnv_quick_prompt_fixture", - messageId: "msg_quick_prompt_fixture", - reply: { - content: "quick prompt fixture response" - } - }); - return true; - } - return false; -} - function handleDevicePodFixtureApi({ request, response, url }) { if (request.method !== "GET" || !url.pathname.startsWith("/v1/device-pods")) return false; const payload = buildDevicePodRestPayload(url.pathname, url.searchParams, { @@ -7671,14 +7198,13 @@ function isBrowserExecutionEnvironmentError(error) { export function printSmokeHelp() { return { status: "usage", - command: `node scripts/dev-cloud-workbench-smoke.mjs --source | --layout [--url http://74.48.78.17:16666/] | --mobile | --local-agent-fixture | --session-continuity-fixture | --quick-prompts-fixture | --local-agent-timeout-fixture | --dom-only --url http://74.48.78.17:16666/ [--report ${domOnlyReportPath}] | --live --confirm-dev-live --url http://74.48.78.17:16666/ [--report ${liveReportPath}]`, + command: `node scripts/dev-cloud-workbench-smoke.mjs --source | --layout [--url http://74.48.78.17:16666/] | --mobile | --local-agent-fixture | --session-continuity-fixture | --local-agent-timeout-fixture | --dom-only --url http://74.48.78.17:16666/ [--report ${domOnlyReportPath}] | --live --confirm-dev-live --url http://74.48.78.17:16666/ [--report ${liveReportPath}]`, notes: [ "Default/source mode reads repository files and emits SOURCE-level evidence only; it never calls the live provider and must not claim DEV-LIVE.", "--layout runs desktop and mobile browser geometry/hit-target checks for sidebar collapse/expand; with --url it remains layout-only live evidence and does not claim M3 acceptance.", "--mobile runs a local static 390x844 browser hit-test and still emits SOURCE-level evidence only.", "--local-agent-fixture runs two local browser send/reply paths with a sanitized SOURCE fixture delayed beyond 4500ms and does not claim DEV-LIVE.", "--session-continuity-fixture verifies same-browser conversation/session/thread reuse, retry context, and degraded missing providerTrace/thread copy without claiming DEV-LIVE.", - "--quick-prompts-fixture clicks Code Agent quick prompts locally and verifies write prompts fill only, without automatic send or hardware write.", "--local-agent-timeout-fixture runs a local slow SOURCE fixture to verify bounded timeout UI classification and retry preservation without live acceptance.", "--dom-only preserves runtime/web-asset identity preflight, inspects read-only deployed DOM/help wiring, and never posts /v1/agent/chat.", "DEV-LIVE mode requires --live --confirm-dev-live, opens the deployed workbench in a browser, sends the simple Chinese prompt and 列出你能使用的所有skill, and retains only non-sensitive response fields." diff --git a/web/hwlab-cloud-web/app-conversation.ts b/web/hwlab-cloud-web/app-conversation.ts index 472d0e72..8a3293ce 100644 --- a/web/hwlab-cloud-web/app-conversation.ts +++ b/web/hwlab-cloud-web/app-conversation.ts @@ -1382,8 +1382,10 @@ function patchTraceBodyElement(currentBody, nextBody) { const position = elementScrollPosition(currentBody); currentBody.className = nextBody.className; currentBody.dataset.traceBodyId = nextBody.dataset.traceBodyId ?? ""; - if (currentBody.textContent !== nextBody.textContent) { - currentBody.textContent = nextBody.textContent; + currentBody.dataset.traceBodyFormat = nextBody.dataset.traceBodyFormat ?? "text"; + if (currentBody.dataset.traceBodyText !== nextBody.dataset.traceBodyText) { + currentBody.dataset.traceBodyText = nextBody.dataset.traceBodyText ?? ""; + renderTraceBodyContent(currentBody, currentBody.dataset.traceBodyText, currentBody.dataset.traceBodyFormat); } restoreElementScrollPosition(currentBody, position); rememberTraceBodyScrollPosition(currentBody); @@ -1400,16 +1402,31 @@ function traceEventRowNode(row, index = 0) { header.textContent = row.header; item.append(header); if (row.body) { - const body = document.createElement("pre"); - body.className = "message-trace-body"; + const body = document.createElement(row.bodyFormat === "markdown" ? "div" : "pre"); + body.className = row.bodyFormat === "markdown" ? "message-trace-body message-trace-markdown message-copy-markdown" : "message-trace-body"; body.dataset.traceBodyId = rowId; + body.dataset.traceBodyFormat = row.bodyFormat === "markdown" ? "markdown" : "text"; + body.dataset.traceBodyText = row.body; installTraceBodyScrollListeners(body); - body.textContent = row.body; + renderTraceBodyContent(body, row.body, body.dataset.traceBodyFormat); item.append(body); } return item; } +function renderTraceBodyContent(body, text, format = "text") { + if (format === "markdown") { + try { + body.innerHTML = renderMessageMarkdown(text); + hardenRenderedMarkdown(body); + } catch (error) { + body.textContent = text || `Markdown 渲染失败:${error.message}`; + } + return; + } + body.textContent = text; +} + function installTraceBodyScrollListeners(body) { for (const eventName of ["wheel", "touchstart", "pointerdown"]) { body.addEventListener(eventName, () => markTraceBodyScrollIntent(body), { passive: true }); @@ -1427,5 +1444,5 @@ function traceRowId(row, index = 0) { } function traceRowKey(row) { - return `${row.header}\n${row.body ?? ""}\n${row.tone ?? ""}`; + return `${row.header}\n${row.body ?? ""}\n${row.bodyFormat ?? "text"}\n${row.tone ?? ""}`; } diff --git a/web/hwlab-cloud-web/app-device-pod.ts b/web/hwlab-cloud-web/app-device-pod.ts index 9841632b..813eafb1 100644 --- a/web/hwlab-cloud-web/app-device-pod.ts +++ b/web/hwlab-cloud-web/app-device-pod.ts @@ -40,22 +40,6 @@ function initDevicePodPanel() { }); } -function initQuickActions() { - for (const button of document.querySelectorAll("[data-agent-quick-prompt]")) { - button.addEventListener("click", () => { - fillAgentQuickPrompt(button); - }); - } -} - -function fillAgentQuickPrompt(button) { - const prompt = button.dataset.promptText ?? ""; - if (!prompt) return; - el.commandInput.value = prompt; - el.commandInput.focus(); - el.commandInput.setSelectionRange(prompt.length, prompt.length); -} - function initCommandBar() { el.commandForm.addEventListener("submit", async (event) => { event.preventDefault(); diff --git a/web/hwlab-cloud-web/app-trace.ts b/web/hwlab-cloud-web/app-trace.ts index 29d8b9b1..cabe63ce 100644 --- a/web/hwlab-cloud-web/app-trace.ts +++ b/web/hwlab-cloud-web/app-trace.ts @@ -9,6 +9,7 @@ function installWorkbenchTestHooks() { traceScrollMetrics, traceDomIdentity, traceBodyDomIdentity, + traceRowTexts, setTraceBodyScrollTop, replaceTraceAssistantStream, setTraceScrollTop, @@ -101,7 +102,7 @@ function replaceTraceAssistantStream(stream = {}) { text: String(stream.text ?? "assistant stream fixture") }]; patchMessageTracePanel(message); - return traceBodyDomIdentity(".message-trace-events[data-trace-ui-key] li:last-child pre"); + return traceBodyDomIdentity(); } async function reconcileTraceResultFixture(result) { @@ -182,6 +183,15 @@ function latestAgentMessageText() { } : null; } +function traceRowTexts() { + return [...el.conversationList.querySelectorAll(".message-trace-events[data-trace-ui-key] .message-trace-row")] + .map((row, index) => ({ + index, + rowId: row.dataset.traceRowId ?? null, + text: row.textContent ?? "" + })); +} + function testTraceEvent(traceId, seq) { return { traceId, @@ -225,7 +235,7 @@ function traceDomIdentity() { }; } -function traceBodyDomIdentity(selector = ".message-trace-events[data-trace-ui-key] li:last-child pre") { +function traceBodyDomIdentity(selector = ".message-trace-events[data-trace-ui-key] li:last-child .message-trace-body") { const body = el.conversationList.querySelector(selector); const row = body?.closest(".message-trace-row") ?? null; if (body && !body.__hwlabTraceIdentity) body.__hwlabTraceIdentity = `body-${Date.now()}-${Math.random()}`; @@ -275,173 +285,565 @@ function setConversationScrollTop(top, { user = true } = {}) { function traceDisplayRows(trace, events) { const rows = []; - const assistantRows = traceAssistantStreamRows(trace); - let noisyRun = []; - let toolOutputRun = []; - const flushNoisyRun = () => { - if (noisyRun.length === 0) return; - const row = traceNoiseSummaryRow(trace, noisyRun); - if (row) rows.push(row); - noisyRun = []; - }; - const flushToolOutputRun = () => { - if (toolOutputRun.length === 0) return; - const outputRows = traceToolOutputSummaryRows(trace, toolOutputRun); - rows.push(...outputRows); - toolOutputRun = []; - }; + const commandGroups = traceCommandExecutionGroups(events); + const requestEvent = tracePrimaryRequestEvent(events); + const hasTerminalAssistant = events.some((event) => isTerminalAssistantTraceEvent(event)); + const renderedCommands = new Set(); + let requestRendered = false; + let setupRendered = false; + let completionRendered = false; + let assistantIndex = 0; for (const event of events) { - if (isNoisyTraceEvent(event)) { - flushToolOutputRun(); - noisyRun.push(event); + if (!event) continue; + if (isToolOutputChunkTraceEvent(event)) continue; + if (isCommandExecutionTraceEvent(event)) { + const groupKey = traceCommandGroupKey(event); + const group = commandGroups.get(groupKey) ?? { key: groupKey, started: event, completed: event, outputs: [] }; + const renderHere = event.label === "item/commandExecution:started" || group.started === event; + if (renderHere && !renderedCommands.has(groupKey)) { + const row = traceCommandSummaryRow(trace, group); + if (row) rows.push(row); + renderedCommands.add(groupKey); + } continue; } - if (isToolOutputChunkTraceEvent(event)) { - flushNoisyRun(); - toolOutputRun.push(event); + if (isAssistantMessageTraceEvent(event)) { + const row = traceAssistantSummaryRow(trace, event, assistantIndex); + assistantIndex += 1; + if (row) rows.push(row); continue; } - flushNoisyRun(); - flushToolOutputRun(); + if (isRequestTraceEvent(event)) { + if (!requestRendered) { + const row = traceRequestSummaryRow(trace, event, requestEvent ?? event); + if (row) rows.push(row); + requestRendered = true; + } + continue; + } + if (isSetupTraceEvent(event)) { + if (!setupRendered) { + const row = traceSetupSummaryRow(trace, events, event); + if (row) rows.push(row); + setupRendered = true; + } + continue; + } + if (isCompletionTraceEvent(event)) { + if (!completionRendered && !hasTerminalAssistant) { + const row = traceCompletionSummaryRow(trace, event); + if (row) rows.push(row); + completionRendered = true; + } + continue; + } + if (isNoisyTraceEvent(event)) continue; const row = traceDisplayRow(trace, event); - if (!row) continue; - rows.push(row); + if (row) rows.push(row); } - flushNoisyRun(); - flushToolOutputRun(); - rows.push(...assistantRows); return rows.length > 0 ? rows : events.map((event) => traceDisplayRow(trace, event, { includeNoise: true })).filter(Boolean); } -function traceAssistantStreamRows(trace) { - const streams = Array.isArray(trace?.assistantStreams) ? trace.assistantStreams.filter(Boolean) : []; - return streams.map((stream, index) => traceAssistantStreamRow(trace, stream, index)).filter(Boolean); -} - -function traceAssistantStreamRow(trace, stream, index = 0) { - const chunkCount = Math.max(0, Number(stream?.chunkCount ?? 0)); - if (chunkCount <= 0) return null; - const clock = traceClock(stream.updatedAt ?? stream.createdAt); - const total = formatTraceDuration(traceRelativeMs(trace, { createdAt: stream.updatedAt ?? stream.createdAt })); - return { - rowId: `assistant-stream:${stream.itemId ?? stream.messageId ?? index}`, - seq: null, - tone: "source", - header: `${clock} total=${total} stream assistant message x${chunkCount}`, - body: traceAssistantStreamBody(stream, chunkCount) - }; -} - -function traceAssistantStreamBody(stream, chunkCount) { - const message = cleanTraceText(stream?.text ?? ""); - const waitingFor = stream?.waitingFor ? `waiting=${stream.waitingFor}` : null; - return [ - `chunks=${chunkCount} assistant message chunks`, - waitingFor, - message ? `message=${message}` : null - ].filter(Boolean).join("\n"); -} - -function traceToolOutputSummaryRows(trace, events) { - const visibleEvents = events.filter(Boolean); - if (visibleEvents.length === 0) return []; - const first = visibleEvents[0]; - const combined = visibleEvents.map((event) => rawTraceOutputText(event)).join(""); - const parsedItems = parseGatewayJsonRpcOutputs(combined); - if (parsedItems.length > 0) { - return parsedItems.map((parsed, index) => { - const { payload, result, dispatch } = parsed; - const event = visibleEvents[Math.min(index, visibleEvents.length - 1)] ?? first; - const clock = traceClock(event.createdAt); - const total = formatTraceDuration(traceRelativeMs(trace, event)); - const chunkCount = index === 0 ? visibleEvents.length : 0; - const suffix = chunkCount > 0 ? ` chunks=${chunkCount}` : ""; - const status = result.status ?? dispatch.dispatchStatus ?? "unknown"; - const exitCode = Number.isInteger(dispatch.exitCode) ? dispatch.exitCode : null; - const ok = status === "succeeded" || status === "completed" || exitCode === 0; - const header = [ - `${clock} total=${total}`, - ok ? "ok" : status === "timed_out" ? "timeout" : "fail", - "tool gateway.shell", - `status=${status}`, - result.operationId ? `op=${result.operationId}` : "op=null", - exitCode !== null ? `exit=${exitCode}` : null, - typeof dispatch.durationMs === "number" ? `s=${(dispatch.durationMs / 1000).toFixed(1)}` : null, - suffix.trim() || null - ].filter(Boolean).join(" "); - return { - rowId: `gateway-jsonrpc:${event.seq ?? first.seq ?? "unknown"}:${payload.id ?? index}`, - seq: event.seq ?? null, - tone: ok ? "ok" : status === "timed_out" ? "warn" : "blocked", - header, - body: gatewayJsonRpcDisplayBody({ payload, result, dispatch, raw: combined }) - }; - }); - } - const clock = traceClock(first.createdAt); - const total = formatTraceDuration(traceRelativeMs(trace, first)); - return [{ - rowId: `tool-output:${first.seq ?? "unknown"}`, - seq: first.seq ?? null, - tone: "source", - header: `${clock} total=${total} output cmd output chunks=${visibleEvents.length} out=${compactTraceSize(combined)}`, - body: compactTraceTextTail(cleanTraceOutputText(combined), 1600) - }]; -} - -function traceToolOutputSummaryRow(trace, events) { - return traceToolOutputSummaryRows(trace, events)[0] ?? null; -} - -function traceNoiseSummaryRow(trace, events) { - const visibleEvents = events.filter(Boolean); - if (visibleEvents.length === 0) return null; - const last = visibleEvents.at(-1); - const clock = traceClock(last.createdAt); - const total = formatTraceDuration(traceRelativeMs(trace, last)); - const assistantChunks = visibleEvents.filter((event) => isAssistantChunkTraceEvent(event)); - const label = assistantChunks.length === visibleEvents.length - ? `assistant message x${visibleEvents.length}` - : `trace noise x${visibleEvents.length}`; - return { - rowId: `trace-noise:${visibleEvents[0]?.seq ?? last.seq ?? "unknown"}:${assistantChunks.length > 0 ? "assistant" : "events"}`, - seq: last.seq ?? null, - tone: "source", - header: `${clock} total=${total} stream ${label}`, - body: traceNoiseSummaryBody(visibleEvents, assistantChunks) - }; -} - -function traceNoiseSummaryBody(events, assistantChunks) { - if (assistantChunks.length > 0) { - const text = assistantChunks - .map((event) => String(event.chunk ?? "")) - .join(""); - const message = cleanTraceText(text); - const waitingFor = events.at(-1)?.waitingFor ? `waiting=${events.at(-1).waitingFor}` : null; - return [ - `chunks=${assistantChunks.length} assistant message chunks`, - waitingFor, - message ? `message=${message}` : null - ].filter(Boolean).join("\n"); - } - const labels = new Map(); - for (const event of events) { - const label = String(event?.label ?? `${event?.type ?? "event"}:${event?.status ?? "observed"}`); - labels.set(label, (labels.get(label) ?? 0) + 1); - } - return [...labels.entries()] - .slice(0, 6) - .map(([label, count]) => `${readableTraceLabel({ label })} x${count}`) - .join("\n"); -} - function compactTraceTextTail(text, maxLength) { const clean = cleanTraceText(text); if (clean.length <= maxLength) return clean; return `...${clean.slice(-maxLength)}`; } +function traceCommandExecutionGroups(events) { + const groups = new Map(); + let activeKey = null; + const ensureGroup = (key, event) => { + const groupKey = key || traceCommandGroupKey(event); + if (!groups.has(groupKey)) groups.set(groupKey, { key: groupKey, started: null, completed: null, outputs: [] }); + return groups.get(groupKey); + }; + for (const event of events) { + if (isCommandExecutionTraceEvent(event)) { + const key = traceCommandGroupKey(event, activeKey); + const group = ensureGroup(key, event); + if (event.label === "item/commandExecution:started") { + group.started = group.started ?? event; + activeKey = key; + } else { + group.completed = event; + group.started = group.started ?? event; + if (activeKey === key) activeKey = null; + } + continue; + } + if (isToolOutputChunkTraceEvent(event)) { + const key = traceCommandGroupKey(event, activeKey); + ensureGroup(key, event).outputs.push(event); + } + } + return groups; +} + +function traceCommandGroupKey(event, fallback = null) { + return nonEmptyString(event?.itemId ?? event?.callId ?? event?.commandId ?? event?.id) || fallback || `cmd:${event?.seq ?? event?.createdAt ?? Math.random()}`; +} + +function traceCommandSummaryRow(trace, group) { + const start = group.started ?? group.completed ?? group.outputs?.[0]; + const completed = group.completed ?? null; + if (!start) return null; + const command = displayTraceCommand(completed?.command ?? start.command ?? ""); + const clock = traceClock(start.createdAt); + const title = traceCommandTitle(command); + const ok = !completed ? false : traceStatusToken(completed) === "ok" && (!Number.isInteger(completed.exitCode) || completed.exitCode === 0); + return { + rowId: `tool-summary:${group.key}`, + seq: start.seq ?? null, + tone: completed ? (ok ? "ok" : "blocked") : "warn", + header: `${clock} 🔧 工具调用:${title}`, + body: traceCommandSummaryBody(group, command) + }; +} + +function traceCommandSummaryBody(group, command) { + const completed = group.completed ?? null; + const result = traceCommandResultSummary(group, command); + return [ + command ? `命令:${command}` : null, + completed ? `结果:${result}` : "结果:等待命令完成" + ].filter(Boolean).join("\n"); +} + +function traceCommandResultSummary(group, command) { + const completed = group.completed ?? {}; + const output = traceCommandCombinedOutput(group); + const fields = traceOutputFields(output); + const success = fields.hostSuccess ?? fields.success ?? commandSuccessFromEvent(completed); + const duration = traceCommandDurationSuffix(group); + if (fields.content && /read-after-launch-flash/u.test(command)) { + return `\n${traceStartupLogSummary(fields.content)}${duration}`; + } + if (fields.hostJobId) { + return [`hostJobId=${fields.hostJobId}`, `success=${success}`].join(", ") + duration; + } + if (fields.logTail && /Flash Load finished/iu.test(fields.logTail)) { + const ticks = fields.content ? traceTickRange(fields.content) : null; + const capture = ticks ? ` 捕获的部分串口:tick ${ticks}` : ""; + return `success=${success}, Flash Load finished.${capture}${duration}`; + } + if (fields.logTail) { + return `success=${success}, logTail: "${traceLogTailSummary(fields.logTail)}"${duration}`; + } + if (fields.content) { + const ticks = traceTickRange(fields.content); + const content = ticks ? `捕获的串口:tick ${ticks}` : compactTraceOneLine(fields.content, 180); + return `success=${success}, ${content}${duration}`; + } + const compact = compactTraceOneLine(output || completed.errorMessage || completed.message || "", 220); + return `${success !== null ? `success=${success}` : traceStatusToken(completed)}${compact ? `, ${compact}` : ""}${duration}`; +} + +function traceCommandCombinedOutput(group) { + return [ + ...(Array.isArray(group.outputs) ? group.outputs.map((event) => rawTraceOutputText(event)) : []), + group.completed?.stdoutSummary, + group.completed?.outputSummary, + group.completed?.stderrSummary + ].filter(Boolean).join("\n"); +} + +function commandSuccessFromEvent(event) { + if (!event) return null; + if (Number.isInteger(event.exitCode)) return event.exitCode === 0; + const status = String(event.status ?? ""); + if (["completed", "succeeded", "success"].includes(status)) return true; + if (["failed", "error", "timeout", "canceled"].includes(status)) return false; + return null; +} + +function traceCommandDurationSuffix(group) { + const completed = group.completed ?? null; + if (!completed) return ""; + let seconds = null; + if (typeof completed.durationMs === "number") { + seconds = completed.durationMs / 1000; + } else if (group.started?.createdAt && completed.createdAt) { + const start = Date.parse(group.started.createdAt); + const end = Date.parse(completed.createdAt); + if (Number.isFinite(start) && Number.isFinite(end) && end >= start) seconds = (end - start) / 1000; + } + return seconds === null ? "" : ` (耗时 ${seconds.toFixed(1)}s)`; +} + +function traceCommandTitle(command) { + if (/build\s+start/iu.test(command)) return "启动编译"; + if (/build\s+status/iu.test(command)) return "等待编译并获取状态"; + if (/download\s+start/iu.test(command)) return "启动下载(含串口捕获)"; + if (/download\s+status/iu.test(command)) return "等待下载并获取状态"; + if (/read-after-launch-flash/iu.test(command)) return "读取完整启动日志"; + return "命令执行"; +} + +function displayTraceCommand(command) { + let text = cleanTraceOutputText(command); + text = text.replace(/^\/?\s*bin\s*\/\s*bash\s+-lc\s+(["'])([\s\S]*)\1$/u, "$2"); + text = text.replace(/^bash\s+-lc\s+(["'])([\s\S]*)\1$/u, "$2"); + return normalizeTraceCommandSpacing(text); +} + +function normalizeTraceCommandSpacing(text) { + return String(text ?? "") + .replace(/:\s+\/\s+/gu, ":/") + .replace(/\s+\/\s+/gu, "/") + .replace(/\s{2,}/gu, " ") + .trim(); +} + +function traceOutputFields(text) { + return { + hostAction: traceOutputStringField(text, "hostAction"), + hostJobId: traceOutputStringField(text, "hostJobId"), + hostSuccess: traceOutputBooleanField(text, "hostSuccess"), + success: traceOutputBooleanField(text, "success"), + logTail: traceOutputStringField(text, "logTail"), + content: traceOutputStringField(text, "content") + }; +} + +function traceOutputStringField(text, name) { + const pattern = new RegExp(`"${name}"\\s*:\\s*"((?:\\\\.|[^"\\\\])*)"`, "gu"); + let value = ""; + for (const match of String(text ?? "").matchAll(pattern)) { + try { + const parsed = JSON.parse(`"${match[1]}"`); + if (parsed) value = parsed; + } catch { + if (match[1]) value = match[1]; + } + } + return value; +} + +function traceOutputBooleanField(text, name) { + const pattern = new RegExp(`"${name}"\\s*:\\s*(true|false)`, "giu"); + let value = null; + for (const match of String(text ?? "").matchAll(pattern)) { + value = match[1].toLowerCase() === "true"; + } + return value; +} + +function traceLogTailSummary(value) { + const text = cleanSerialText(value); + const build = text.match(/([^\n]*\b\d+ Error\(s\), \d+ Warning\(s\)\.[^\n]*)(?:\n([^\n]*Build Time Elapsed:[^\n]*))?/iu); + if (build) return compactTraceOneLine(`... ${[build[1], build[2]].filter(Boolean).join(" ")}`, 220); + const flash = text.match(/Flash Load finished[^\n]*/iu); + if (flash) return flash[0]; + return compactTraceOneLine(text, 220); +} + +function traceStartupLogSummary(value) { + const lines = cleanSerialText(value) + .split("\n") + .map((line) => line.trim()) + .filter(Boolean); + const selected = []; + for (const line of lines) { + if (/hello device-pod!/iu.test(line)) selected.push("hello device-pod!"); + else if (/STM32F103 USART1 baseline ready/iu.test(line)) selected.push("[01_baseline] STM32F103 USART1 baseline ready"); + else if (/baud=/iu.test(line)) selected.push(line.replace(/\s+\/\s+/gu, "/")); + else if (/project=/iu.test(line)) selected.push(line.replace(/\s+\/\s+/gu, "/")); + else if (/dap=.*idcode=/iu.test(line)) selected.push(line); + } + const ticks = traceTickRange(value); + if (ticks) { + const [first, last] = ticks.split("-"); + selected.push(last ? `tick=${first}, ..., tick=${last}` : `tick=${first}`); + } + return selected.slice(0, 8).join("\n"); +} + +function traceTickRange(value) { + const ticks = [...String(value ?? "").matchAll(/tick=(\d+)/giu)].map((match) => Number(match[1])).filter(Number.isFinite); + if (ticks.length === 0) return null; + const first = ticks[0]; + const last = ticks.at(-1); + return first === last ? String(first) : `${first}-${last}`; +} + +function cleanSerialText(value) { + return String(value ?? "") + .replace(/\u0000/gu, "") + .replace(/\r\n|\r/gu, "\n") + .replace(/\n{3,}/gu, "\n\n") + .trim(); +} + +function compactTraceOneLine(value, limit = 220) { + const text = cleanSerialText(value).replace(/\s+/gu, " ").trim(); + return text.length <= limit ? text : `${text.slice(0, Math.max(0, limit - 3))}...`; +} + +function tracePrimaryRequestEvent(events) { + return events.find((event) => isRequestTraceEvent(event) && tracePromptText(event)) ?? events.find(isRequestTraceEvent) ?? null; +} + +function traceRequestSummaryRow(trace, event, promptEvent = event) { + const clock = traceClock(event.createdAt); + const prompt = tracePromptText(promptEvent); + return { + rowId: `trace-request:${event.seq ?? "accepted"}`, + seq: event.seq ?? null, + tone: "warn", + header: `${clock} 请求接受${prompt ? `,提示词:"${compactTraceOneLine(prompt, 120)}"` : ""}`, + body: null + }; +} + +function traceSetupSummaryRow(trace, events, event) { + const setupEvents = events.filter(isSetupTraceEvent); + const sessionReused = setupEvents.some((item) => /session:reused/iu.test(String(item?.label ?? ""))); + const promptSent = setupEvents.some((item) => /prompt:sent|sent prompt/iu.test(String(item?.label ?? ""))); + const turnStarted = setupEvents.some((item) => /turn[:/]start|turn:started|turn\/start/iu.test(String(item?.label ?? ""))); + const parts = [ + sessionReused ? "会话复用" : "会话就绪", + promptSent ? "提示词已发送" : null, + turnStarted ? "轮次开始" : null + ].filter(Boolean); + return { + rowId: "trace-setup:session-turn", + seq: event.seq ?? null, + tone: "source", + header: `${traceClock(event.createdAt)} ${parts.join(",") || "会话准备完成"}`, + body: null + }; +} + +function traceCompletionSummaryRow(trace, event) { + return { + rowId: `trace-completion:${event.seq ?? "turn"}`, + seq: event.seq ?? null, + tone: traceEventTone(event), + header: `${traceClock(event.createdAt)} 轮次完成(总耗时 ${formatTraceDuration(traceRelativeMs(trace, event))})`, + body: null + }; +} + +function traceAssistantSummaryRow(trace, event, index = 0) { + const terminal = isTerminalAssistantTraceEvent(event); + const text = traceAssistantTextForEvent(trace, event, index); + if (!text && !terminal && traceHasLaterTerminalAssistant(trace, event)) return null; + const elapsed = formatTraceDuration(traceRelativeMs(trace, event)); + return { + rowId: `event:${event.seq ?? `${event.label ?? event.type ?? "assistant"}:${event.createdAt ?? "unknown"}`}`, + seq: event.seq ?? null, + tone: traceEventTone(event), + header: terminal + ? `${traceClock(event.createdAt)} 🤖 助手最后一条消息,轮次完成(总耗时 ${elapsed})` + : `${traceClock(event.createdAt)} 🤖 助手消息${index === 0 ? "(第一条)" : ""}`, + body: text ? compactTraceAssistantMarkdown(text, terminal ? 5000 : 2200) : null, + bodyFormat: "markdown" + }; +} + +function traceAssistantTextForEvent(trace, event, index = 0) { + const direct = normalizeTraceAssistantMarkdown(event.message ?? event.outputSummary ?? event.chunk ?? event.text ?? ""); + if (direct) return traceAssistantTextDuplicatesTerminal(trace, event, direct) ? "" : direct; + const streams = traceAssistantStreams(trace); + if (streams.length === 0) return ""; + const itemId = nonEmptyString(event.itemId ?? event.messageId ?? event.id); + if (itemId) { + const match = streams.find((stream) => stream.itemId === itemId || stream.messageId === itemId || stream.id === itemId); + if (match?.text) { + const text = normalizeTraceAssistantMarkdown(match.text); + return traceAssistantTextDuplicatesTerminal(trace, event, text) ? "" : text; + } + } + if (isTerminalAssistantTraceEvent(event)) return traceAssistantTerminalText(trace, event); + return traceAssistantCumulativeSegment(trace, event, index); +} + +function traceAssistantStreams(trace) { + return Array.isArray(trace?.assistantStreams) + ? trace.assistantStreams.filter((stream) => stream && typeof stream === "object" && normalizeTraceAssistantMarkdown(stream.text ?? "")) + : []; +} + +function traceAssistantEvents(trace) { + return Array.isArray(trace?.events) ? trace.events.filter(isAssistantMessageTraceEvent) : []; +} + +function traceAssistantTerminalText(trace, event = null) { + const streams = traceAssistantStreams(trace); + const itemId = nonEmptyString(event?.itemId ?? event?.messageId ?? event?.id); + if (itemId) { + const matched = streams.find((stream) => stream.itemId === itemId || stream.messageId === itemId || stream.id === itemId); + if (matched?.text) return normalizeTraceAssistantMarkdown(matched.text); + } + return normalizeTraceAssistantMarkdown(streams.at(-1)?.text ?? ""); +} + +function traceAssistantTextDuplicatesTerminal(trace, event, text) { + if (isTerminalAssistantTraceEvent(event) || !traceHasLaterTerminalAssistant(trace, event)) return false; + const terminalText = traceAssistantTerminalText(trace); + return Boolean(terminalText && traceAssistantComparableText(text) === traceAssistantComparableText(terminalText)); +} + +function traceHasLaterTerminalAssistant(trace, event) { + const events = traceAssistantEvents(trace); + const currentIndex = events.findIndex((item) => item === event || (item?.seq !== undefined && item.seq === event?.seq)); + if (currentIndex < 0) return events.some(isTerminalAssistantTraceEvent); + return events.slice(currentIndex + 1).some(isTerminalAssistantTraceEvent); +} + +function traceAssistantCumulativeSegment(trace, event, index = 0) { + const assistantEvents = traceAssistantEvents(trace).filter((item) => !isTerminalAssistantTraceEvent(item)); + const eventIndex = assistantEvents.findIndex((item) => item === event || (item?.seq !== undefined && item.seq === event?.seq)); + const segmentIndex = eventIndex >= 0 ? eventIndex : index; + if (segmentIndex < 0) return ""; + const stream = traceAssistantCumulativeStream(trace); + if (!stream?.text) return ""; + const cumulative = normalizeTraceAssistantMarkdown(stream.text); + const terminalText = traceAssistantTerminalText(trace); + const nonTerminalText = traceAssistantRemoveTerminalSuffix(cumulative, terminalText); + if (!nonTerminalText || traceAssistantComparableText(nonTerminalText) === traceAssistantComparableText(terminalText)) return ""; + const segments = traceAssistantSplitCumulativeText(nonTerminalText, assistantEvents.length); + return normalizeTraceAssistantMarkdown(segments[segmentIndex] ?? ""); +} + +function traceAssistantCumulativeStream(trace) { + const streams = traceAssistantStreams(trace); + if (streams.length === 0) return null; + const terminalIds = new Set(traceAssistantEvents(trace) + .filter(isTerminalAssistantTraceEvent) + .flatMap((event) => [event.itemId, event.messageId, event.id].map(nonEmptyString).filter(Boolean))); + const candidates = streams.filter((stream) => { + const streamId = nonEmptyString(stream.itemId ?? stream.messageId ?? stream.id); + return !streamId || !terminalIds.has(streamId); + }); + return [...(candidates.length > 0 ? candidates : streams)] + .sort((left, right) => String(right.text ?? "").length - String(left.text ?? "").length)[0] ?? null; +} + +function traceAssistantRemoveTerminalSuffix(text, terminalText) { + const clean = normalizeTraceAssistantMarkdown(text); + const terminal = normalizeTraceAssistantMarkdown(terminalText); + if (!clean || !terminal) return clean; + if (clean.endsWith(terminal)) return clean.slice(0, -terminal.length).trim(); + const index = traceAssistantCompactLastIndex(clean, terminal); + if (index > 0) return clean.slice(0, index).trim(); + return clean; +} + +function traceAssistantCompactLastIndex(text, needle) { + const haystack = traceAssistantComparableWithMap(text); + const target = traceAssistantComparableText(needle); + if (!target) return -1; + const compactIndex = haystack.text.lastIndexOf(target); + return compactIndex >= 0 ? haystack.map[compactIndex] ?? -1 : -1; +} + +function traceAssistantComparableWithMap(value) { + const map = []; + let text = ""; + const raw = String(value ?? ""); + for (let index = 0; index < raw.length; index += 1) { + const char = raw[index]; + if (/\s/u.test(char)) continue; + text += char.toLowerCase(); + map.push(index); + } + return { text, map }; +} + +function traceAssistantComparableText(value) { + return traceAssistantComparableWithMap(normalizeTraceAssistantMarkdown(value)).text; +} + +function traceAssistantSplitCumulativeText(text, count) { + const targetCount = Math.max(1, Number(count) || 1); + const clean = normalizeTraceAssistantMarkdown(text); + if (!clean) return []; + if (targetCount === 1) return [clean]; + const colonPieces = []; + let remaining = clean; + while (remaining && colonPieces.length < targetCount) { + const match = /^([\s\S]*?[::])(?=\s*[^\s])/u.exec(remaining); + if (!match?.[1] || match[1].trim().length < 8) break; + colonPieces.push(match[1].trim()); + remaining = remaining.slice(match[1].length).trim(); + } + const pieces = colonPieces.length > 0 ? colonPieces : traceAssistantSentencePieces(clean); + if (colonPieces.length > 0 && remaining) pieces.push(remaining); + if (pieces.length <= targetCount) return [...pieces, ...Array.from({ length: targetCount - pieces.length }, () => "")]; + return traceAssistantPackPieces(pieces, targetCount); +} + +function traceAssistantSentencePieces(text) { + return String(text ?? "").match(/[^。!?.!?]+[。!?.!?]?/gu)?.map((item) => item.trim()).filter(Boolean) ?? []; +} + +function traceAssistantPackPieces(pieces, count) { + const packed = Array.from({ length: count }, () => ""); + pieces.forEach((piece, index) => { + const slot = Math.min(count - 1, Math.floor(index * count / pieces.length)); + packed[slot] = [packed[slot], piece].filter(Boolean).join(""); + }); + return packed; +} + +function compactTraceAssistantMarkdown(text, maxLength) { + const clean = normalizeTraceAssistantMarkdown(text); + if (clean.length <= maxLength) return clean; + return `${clean.slice(0, Math.max(0, maxLength - 12)).trimEnd()}\n\n...内容已截断`; +} + +function normalizeTraceAssistantMarkdown(value) { + let text = String(value ?? "") + .replace(/\u0000/gu, "") + .replace(/\r\n|\r/gu, "\n") + .replace(/[ \t]{2,}/gu, " ") + .replace(/\n{3,}/gu, "\n\n") + .trim(); + if (!text) return ""; + text = restoreTraceAssistantEnglishSpacing(text); + text = restoreTraceAssistantMarkdownBreaks(text); + return text; +} + +function restoreTraceAssistantEnglishSpacing(value) { + return String(value ?? "") + .replace(/\bLetmecheckwhat/gu, "Let me check what ") + .replace(/cloudAPI/giu, "Cloud API") + .replace(/CloudAPI/gu, "Cloud API") + .replace(/gatewaysessions/giu, "gateway sessions") + .replace(/gatewaysession/giu, "gateway session") + .replace(/gatewaysidecar/giu, "gateway sidecar") + .replace(/Windows端/gu, "Windows 端") + .replace(/gateway服务/giu, "gateway 服务") + .replace(/gateway恢复/giu, "gateway 恢复") + .replace(/device-podprofilesareavailable/giu, "device-pod profiles are available") + .replace(/profilesareavailable/giu, "profiles are available") + .replace(/\b([A-Za-z]+)areavailable\b/gu, "$1 are available") + .replace(/(\d+)Error\(s\),\s*(\d+)Warning\(s\)/gu, "$1 Error(s), $2 Warning(s)") + .replace(/所有(\d+)个/gu, "所有 $1 个") + .replace(/(\d+)个device-pod/giu, "$1 个 device-pod") + .replace(/个device-pod/giu, "个 device-pod") + .replace(/(\*\*\d+)个/gu, "$1 个") + .replace(/([\u4e00-\u9fff])`/gu, "$1 `") + .replace(/`([^`]+)`([\u4e00-\u9fff])/gu, "`$1` $2") + .replace(/([A-Za-z0-9_-])`/gu, "$1 `") + .replace(/([\u4e00-\u9fff])\*\*/gu, "$1 **") + .replace(/\*\*([\u4e00-\u9fff])/gu, "** $1") + .replace(/([a-z])([\u4e00-\u9fff])/gu, "$1 $2") + .replace(/([\u4e00-\u9fff])([A-Za-z][A-Za-z0-9_-]*)/gu, "$1 $2"); +} + +function restoreTraceAssistantMarkdownBreaks(value) { + return String(value ?? "") + .replace(/([::。.!?])\s*(\|[^\n]+\|)/gu, "$1\n\n$2") + .replace(/\|\s*\|(?=(?:---|:---|[^|\n]+\s*\|))/gu, "|\n|") + .replace(/\s+-\s+(?=(?:\*\*|`|[\u4e00-\u9fffA-Za-z0-9]))/gu, "\n- ") + .replace(/([^\n])\s+(想对哪个pod进行操作?)/gu, "$1\n\n$2") + .replace(/([^\n])\s+(编译干净通过,无错误无警告。)/gu, "$1\n\n$2"); +} + function traceDisplayRow(trace, event, options = {}) { if (!options.includeNoise && isNoisyTraceEvent(event)) return null; if (isCommandExecutionTraceEvent(event)) return traceCommandExecutionRow(trace, event); @@ -507,19 +909,11 @@ function isCommandExecutionTraceEvent(event) { function isNoisyTraceEvent(event) { const label = String(event?.label ?? ""); if (isToolOutputChunkTraceEvent(event)) return false; - if (isAssistantChunkTraceEvent(event)) return true; if (/token_count|outputDelta:chunk/iu.test(label)) return true; if (event?.type === "event" && !event.outputSummary && !event.message && !event.errorCode) return true; - if (event?.label === "request:accepted" && !event.message) return true; return false; } -function isAssistantChunkTraceEvent(event) { - const label = String(event?.label ?? ""); - return /assistant:chunk/iu.test(label) || - (event?.type === "assistant_message" && event?.status === "chunk"); -} - function isToolOutputChunkTraceEvent(event) { const label = String(event?.label ?? ""); return label === "item/commandExecution/outputDelta" || diff --git a/web/hwlab-cloud-web/app.ts b/web/hwlab-cloud-web/app.ts index 08c74323..0c1245a5 100644 --- a/web/hwlab-cloud-web/app.ts +++ b/web/hwlab-cloud-web/app.ts @@ -230,7 +230,6 @@ initLeftSidebarToggle(); initLeftSidebarResize(); initRightSidebarResize(); initDevicePodPanel(); -initQuickActions(); initCodeAgentTimeoutControl(); initConversationScrollMemory(); initCommandBar(); diff --git a/web/hwlab-cloud-web/index.html b/web/hwlab-cloud-web/index.html index 24f91241..0b4865f5 100644 --- a/web/hwlab-cloud-web/index.html +++ b/web/hwlab-cloud-web/index.html @@ -170,36 +170,6 @@
-
- - - - -