From 42685be15b6dd9da7d8068668d5508de73fd7c36 Mon Sep 17 00:00:00 2001 From: lyon Date: Fri, 19 Jun 2026 00:16:30 +0800 Subject: [PATCH] fix(workbench): make trace disclosure lifecycle configurable --- deploy/deploy.schema.json | 16 +++++++ deploy/deploy.yaml | 4 ++ internal/dev-entrypoint/cloud-web-runtime.mjs | 46 ++++++++++++++++++- scripts/gitops-render.mjs | 21 +++++++++ .../scripts/workbench-trace-lifecycle.test.ts | 16 ++++--- .../src/components/agent/TraceTimeline.vue | 36 +++++++++------ .../workbench/ConversationPanel.vue | 8 +++- .../components/workbench/trace-lifecycle.ts | 13 ++++-- web/hwlab-cloud-web/src/config/runtime.ts | 9 ++++ web/hwlab-cloud-web/src/types/global.d.ts | 6 +++ .../specs/trace-rendering.spec.ts | 21 +++++++++ 11 files changed, 169 insertions(+), 27 deletions(-) create mode 100644 web/hwlab-cloud-web/src/config/runtime.ts diff --git a/deploy/deploy.schema.json b/deploy/deploy.schema.json index 71722837..be5007e0 100644 --- a/deploy/deploy.schema.json +++ b/deploy/deploy.schema.json @@ -333,6 +333,7 @@ "artifactCatalog": { "type": "string" }, "runtimePath": { "type": "string" }, "imageTagMode": { "type": "string", "enum": ["short", "full"] }, + "workbench": { "$ref": "#/$defs/workbenchUiPolicy" }, "sourceRepo": { "type": "string" }, "envReuseServices": { "type": "array", @@ -358,6 +359,21 @@ }, "additionalProperties": false }, + "workbenchUiPolicy": { + "type": "object", + "properties": { + "traceTimeline": { "$ref": "#/$defs/workbenchTraceTimelinePolicy" } + }, + "additionalProperties": false + }, + "workbenchTraceTimelinePolicy": { + "type": "object", + "properties": { + "autoExpandRunning": { "type": "boolean" }, + "autoCollapseTerminal": { "type": "boolean" } + }, + "additionalProperties": false + }, "externalPostgres": { "type": "object", "required": ["enabled", "serviceName", "endpointAddress", "port"], diff --git a/deploy/deploy.yaml b/deploy/deploy.yaml index 71e26f80..f0657d52 100644 --- a/deploy/deploy.yaml +++ b/deploy/deploy.yaml @@ -251,6 +251,10 @@ lanes: artifactCatalog: deploy/artifact-catalog.v03.json runtimePath: runtime-v03 imageTagMode: full + workbench: + traceTimeline: + autoExpandRunning: false + autoCollapseTerminal: false externalPostgres: enabled: true serviceName: g14-platform-postgres diff --git a/internal/dev-entrypoint/cloud-web-runtime.mjs b/internal/dev-entrypoint/cloud-web-runtime.mjs index 56007312..a4a92099 100644 --- a/internal/dev-entrypoint/cloud-web-runtime.mjs +++ b/internal/dev-entrypoint/cloud-web-runtime.mjs @@ -70,8 +70,8 @@ export function createCloudWebServer({ try { const info = await stat(candidate); if (!info.isFile()) continue; - const body = await readFile(candidate); const type = contentType(candidate); + const body = await staticResponseBody({ candidate, relativePath, type }); const encoded = await encodeStaticBody(request, body, type); response.writeHead(200, { "content-type": type, @@ -108,6 +108,50 @@ export function createCloudWebServer({ }); } +async function staticResponseBody({ candidate, relativePath, type }) { + const body = await readFile(candidate); + if (relativePath !== "index.html" || !String(type).startsWith("text/html")) return body; + return injectCloudWebRuntimeConfig(body); +} + +function injectCloudWebRuntimeConfig(body) { + const config = runtimeConfigFromEnv(); + if (Object.keys(config).length === 0) return body; + const html = body.toString("utf8"); + const script = ``; + if (html.includes("")) return Buffer.from(html.replace("", `${script}`), "utf8"); + if (html.includes("")) return Buffer.from(html.replace("", `${script}`), "utf8"); + return Buffer.from(`${html}${script}`, "utf8"); +} + +function runtimeConfigFromEnv() { + const config = {}; + const workbench = workbenchRuntimeConfigFromEnv(); + if (workbench) config.workbench = workbench; + return config; +} + +function workbenchRuntimeConfigFromEnv() { + const traceTimeline = {}; + const autoExpandRunning = parseEnvBoolean(process.env.HWLAB_WORKBENCH_TRACE_AUTO_EXPAND_RUNNING); + const autoCollapseTerminal = parseEnvBoolean(process.env.HWLAB_WORKBENCH_TRACE_AUTO_COLLAPSE_TERMINAL); + if (typeof autoExpandRunning === "boolean") traceTimeline.autoExpandRunning = autoExpandRunning; + if (typeof autoCollapseTerminal === "boolean") traceTimeline.autoCollapseTerminal = autoCollapseTerminal; + return Object.keys(traceTimeline).length > 0 ? { traceTimeline } : null; +} + +function parseEnvBoolean(value) { + if (value === undefined || value === null || value === "") return null; + const normalized = String(value).trim().toLowerCase(); + if (["1", "true", "yes", "on"].includes(normalized)) return true; + if (["0", "false", "no", "off"].includes(normalized)) return false; + return null; +} + +function safeInlineJson(value) { + return JSON.stringify(value).replace(/ 0 ? result : null; +} + +function booleanEnv(value) { + return value ? "1" : "0"; +} + function mergeEnvMaps(...values) { const result = {}; for (const value of values) { @@ -1188,6 +1202,13 @@ function transformWorkloads({ workloads, deploy, catalog, source, sourceBranch = if (serviceId === "hwlab-cloud-web") { upsertEnv(container.env, "HWLAB_API_BASE_URL", `http://hwlab-cloud-api.${namespace}.svc.cluster.local:6667`); upsertEnv(container.env, "HWLAB_PUBLIC_ENDPOINT", webEndpoint); + const traceTimelinePolicy = runtimeWorkbenchTraceTimelinePolicy(deploy, profile); + if (typeof traceTimelinePolicy?.autoExpandRunning === "boolean") { + upsertEnv(container.env, "HWLAB_WORKBENCH_TRACE_AUTO_EXPAND_RUNNING", booleanEnv(traceTimelinePolicy.autoExpandRunning)); + } + if (typeof traceTimelinePolicy?.autoCollapseTerminal === "boolean") { + upsertEnv(container.env, "HWLAB_WORKBENCH_TRACE_AUTO_COLLAPSE_TERMINAL", booleanEnv(traceTimelinePolicy.autoCollapseTerminal)); + } } } rewritePodSecretRefs(podTemplate.spec, profile); diff --git a/web/hwlab-cloud-web/scripts/workbench-trace-lifecycle.test.ts b/web/hwlab-cloud-web/scripts/workbench-trace-lifecycle.test.ts index 61ee4d0d..3492330a 100644 --- a/web/hwlab-cloud-web/scripts/workbench-trace-lifecycle.test.ts +++ b/web/hwlab-cloud-web/scripts/workbench-trace-lifecycle.test.ts @@ -4,16 +4,18 @@ import test from "node:test"; import type { ChatMessage } from "../src/types/index.ts"; import { traceLifecycleExpanded } from "../src/components/workbench/trace-lifecycle.ts"; -test("Trace lifecycle expands only active agent turns", () => { - assert.equal(traceLifecycleExpanded(agentMessage({ status: "running", runnerTrace: { status: "running" } })), true); - assert.equal(traceLifecycleExpanded(agentMessage({ status: "running", runnerTrace: { status: "pending" } })), true); +test("Trace lifecycle respects the workbench timeline policy", () => { + assert.equal(traceLifecycleExpanded(agentMessage({ status: "running", runnerTrace: { status: "running" } })), null); + assert.equal(traceLifecycleExpanded(agentMessage({ status: "running", runnerTrace: { status: "pending" } })), null); assert.equal(traceLifecycleExpanded(agentMessage({ status: "completed", runnerTrace: { status: "running" } })), null); - assert.equal(traceLifecycleExpanded(agentMessage({ status: "running", runnerTrace: { status: "completed" } })), true); - assert.equal(traceLifecycleExpanded(agentMessage({ status: "running", runnerTrace: { status: "running", finalResponse: "ok" } })), true); + assert.equal(traceLifecycleExpanded(agentMessage({ status: "running", runnerTrace: { status: "completed" } })), null); + assert.equal(traceLifecycleExpanded(agentMessage({ status: "running", runnerTrace: { status: "running", finalResponse: "ok" } })), null); assert.equal(traceLifecycleExpanded(agentMessage({ status: "failed", runnerTrace: { status: "failed" } })), null); assert.equal(traceLifecycleExpanded(agentMessage({ status: "completed", traceAutoLifecycle: null, runnerTrace: { status: "completed", finalResponse: "ok" } })), null); - assert.equal(traceLifecycleExpanded(agentMessage({ status: "completed", traceAutoLifecycle: "terminal", runnerTrace: { status: "completed", finalResponse: "ok" } })), false); - assert.equal(traceLifecycleExpanded(agentMessage({ status: "running", traceAutoLifecycle: "running", runnerTrace: { status: "completed", finalResponse: "ok" } })), true); + assert.equal(traceLifecycleExpanded(agentMessage({ status: "completed", traceAutoLifecycle: "terminal", runnerTrace: { status: "completed", finalResponse: "ok" } })), null); + assert.equal(traceLifecycleExpanded(agentMessage({ status: "completed", traceAutoLifecycle: "terminal", runnerTrace: { status: "completed", finalResponse: "ok" } }), { autoCollapseTerminal: true }), false); + assert.equal(traceLifecycleExpanded(agentMessage({ status: "running", traceAutoLifecycle: "running", runnerTrace: { status: "completed", finalResponse: "ok" } }), { autoExpandRunning: true }), true); + assert.equal(traceLifecycleExpanded(agentMessage({ status: "running", runnerTrace: { status: "running" } }), { autoExpandRunning: true }), true); assert.equal(traceLifecycleExpanded({ ...agentMessage({ status: "running" }), role: "user", title: "用户" }), null); }); diff --git a/web/hwlab-cloud-web/src/components/agent/TraceTimeline.vue b/web/hwlab-cloud-web/src/components/agent/TraceTimeline.vue index ede8b71b..87dc7df1 100644 --- a/web/hwlab-cloud-web/src/components/agent/TraceTimeline.vue +++ b/web/hwlab-cloud-web/src/components/agent/TraceTimeline.vue @@ -20,6 +20,7 @@ const emptyTraceLabel = computed(() => { return props.trace?.fullTraceLoaded === true ? "思考中..." : "加载中..."; }); const toolPreviewLimit = 140; +let programmaticToggleTarget: boolean | null = null; watch(() => [eventCount.value, props.trace?.status, following.value, expanded.value], async () => { if (!following.value || !expanded.value) return; @@ -27,21 +28,10 @@ watch(() => [eventCount.value, props.trace?.status, following.value, expanded.va jumpToBottom(); }); -watch(() => [props.trace?.traceId, props.storageKey, props.defaultExpanded] as const, () => { - if (typeof props.autoExpanded === "boolean") return; - expanded.value = readStoredExpanded() ?? props.defaultExpanded ?? false; +watch(() => [props.trace?.traceId, props.storageKey, props.defaultExpanded, props.autoExpanded] as const, () => { + setExpandedProgrammatically(readStoredExpanded() ?? props.autoExpanded ?? props.defaultExpanded ?? false); }, { immediate: true }); -watch(() => [props.trace?.traceId, props.autoExpanded] as const, () => { - if (typeof props.autoExpanded !== "boolean") return; - expanded.value = props.autoExpanded; -}, { immediate: true }); - -watch(expanded, (value) => { - if (!props.storageKey) return; - try { window.localStorage.setItem(props.storageKey, value ? "1" : "0"); } catch { /* ignore */ } -}); - function jumpToBottom(): void { const list = listRef.value; if (!list) return; @@ -60,7 +50,20 @@ function rowKey(row: TraceEventRow, index: number): string { function onDisclosureToggle(event: Event): void { const target = event.currentTarget; - if (target instanceof HTMLDetailsElement) expanded.value = target.open; + if (!(target instanceof HTMLDetailsElement)) return; + expanded.value = target.open; + if (programmaticToggleTarget === target.open) { + programmaticToggleTarget = null; + return; + } + programmaticToggleTarget = null; + writeStoredExpanded(target.open); +} + +function setExpandedProgrammatically(value: boolean): void { + if (expanded.value === value) return; + programmaticToggleTarget = value; + expanded.value = value; } function readStoredExpanded(): boolean | null { @@ -73,6 +76,11 @@ function readStoredExpanded(): boolean | null { return null; } +function writeStoredExpanded(value: boolean): void { + if (!props.storageKey) return; + try { window.localStorage.setItem(props.storageKey, value ? "1" : "0"); } catch { /* ignore */ } +} + function rowIsTool(row: TraceEventRow): boolean { return row.rowId.startsWith("tool:") || /commandExecution/u.test(row.header); } diff --git a/web/hwlab-cloud-web/src/components/workbench/ConversationPanel.vue b/web/hwlab-cloud-web/src/components/workbench/ConversationPanel.vue index a91bdfea..cd6ad4cb 100644 --- a/web/hwlab-cloud-web/src/components/workbench/ConversationPanel.vue +++ b/web/hwlab-cloud-web/src/components/workbench/ConversationPanel.vue @@ -11,6 +11,7 @@ import CodeAgentStatusSummary from "@/components/workbench/CodeAgentStatusSummar import MessageActions from "@/components/workbench/MessageActions.vue"; import MessageMarkdown from "@/components/workbench/MessageMarkdown.vue"; import MessageTraceDebugPanel from "@/components/workbench/MessageTraceDebugPanel.vue"; +import { workbenchTraceTimelinePolicy } from "@/config/runtime"; import { useClipboard } from "@/composables/useClipboard"; import { useWorkbenchStore } from "@/stores/workbench"; import { acknowledgeWorkbenchVisibleAfterPaint } from "@/utils/workbench-performance"; @@ -23,6 +24,7 @@ const panelRef = ref(null); const following = ref(true); const detailMessageId = ref(null); const detailMessage = computed(() => workbench.messages.find((message) => message.id === detailMessageId.value) ?? null); +const traceTimelinePolicy = computed(() => workbenchTraceTimelinePolicy()); const scrollSignature = computed(() => workbench.messages.map((message) => [ message.id, message.status, @@ -89,6 +91,10 @@ function acknowledgeVisibleMessages(): void { function traceStorageKey(message: ChatMessage): string { return `hwlab.workbench.trace-open.${message.traceId ?? message.runnerTrace?.traceId ?? message.id}`; } + +function traceAutoExpanded(message: ChatMessage): boolean | null { + return traceLifecycleExpanded(message, traceTimelinePolicy.value); +}