diff --git a/internal/dev-entrypoint/http.test.mjs b/internal/dev-entrypoint/http.test.mjs index 360d219e..9db23cdb 100644 --- a/internal/dev-entrypoint/http.test.mjs +++ b/internal/dev-entrypoint/http.test.mjs @@ -54,11 +54,11 @@ test("cloud web route policy proxies public Code Agent chat without gating other publicRoute: false, routeKey: "GET /v1/m3/status" }); - assert.deepEqual(cloudWebProxyRoutePolicy("GET", "/app.mjs"), { + assert.deepEqual(cloudWebProxyRoutePolicy("GET", "/app.js"), { proxy: false, authRequired: false, publicRoute: false, - routeKey: "GET /app.mjs" + routeKey: "GET /app.js" }); }); diff --git a/package.json b/package.json index 60ed0187..fa678024 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "validate": "node scripts/repo-reports-guard.mjs && node scripts/validate-contract.mjs && node scripts/deploy-contract-plan.mjs --check && node scripts/deploy-desired-state-plan.mjs --check && node scripts/dev-runtime-provisioning.mjs --check && node scripts/dev-runtime-migration.mjs --check && node scripts/dev-runtime-postflight.mjs --check && node scripts/dev-runtime-hotfix-audit.mjs && node scripts/rpt004-mvp-e2e-harness.mjs --check --no-write && node --check scripts/artifact-runtime-readiness-guard.mjs && node --check scripts/src/artifact-runtime-readiness-guard.mjs && node --check scripts/dev-runtime-hotfix-audit.mjs && node --check scripts/src/dev-runtime-hotfix-audit.mjs && node --test scripts/artifact-runtime-readiness-guard.test.mjs scripts/src/dev-runtime-hotfix-audit.test.mjs", - "check": "node scripts/repo-reports-guard.mjs && node --check scripts/repo-reports-guard.mjs && node --check scripts/src/report-paths.mjs && node --check internal/protocol/index.mjs && node --check internal/build-metadata.mjs && node --check internal/agent/index.mjs && node --check internal/agent/runtime.mjs && node --check internal/audit/index.mjs && node --check internal/db/runtime-store.mjs && node --check internal/db/runtime-store.test.mjs && node --check internal/mvp-gate/summary.mjs && node --check internal/cloud/db-contract.mjs && node --check internal/dev-entrypoint/cloud-web-routes.mjs && node --check internal/dev-entrypoint/cloud-web-proxy.mjs && node --check internal/dev-entrypoint/cloud-web-proxy.test.mjs && node --check internal/dev-entrypoint/http.mjs && node --check internal/dev-entrypoint/http.test.mjs && node --check internal/cloud/code-agent-contract.mjs && node --check internal/cloud/code-agent-session-registry.mjs && node --check internal/cloud/code-agent-session-registry.test.mjs && node --check internal/cloud/code-agent-trace-store.mjs && node --check internal/cloud/codex-stdio-session.mjs && node --check internal/cloud/json-rpc.mjs && node --check internal/cloud/code-agent-chat.mjs && node --check internal/cloud/m3-io-control.mjs && node --check internal/cloud/server.mjs && node --check internal/device-pod/fake-data.mjs && node --check internal/sim/model.mjs && node --check internal/sim/model.test.mjs && node --check internal/sim/http.mjs && node --check internal/sim/l2-runtime.mjs && node --check internal/patchpanel/model.mjs && node --check internal/patchpanel/runtime.mjs && node --check cmd/hwlab-cloud-api/main.mjs && node --check cmd/hwlab-cloud-api/provision.mjs && node --check cmd/hwlab-cloud-api/migrate.mjs && node --check cmd/hwlab-deepseek-responses-bridge/main.mjs && node --check cmd/hwlab-deepseek-responses-bridge/main.test.mjs && node --test cmd/hwlab-deepseek-responses-bridge/main.test.mjs && node --check cmd/hwlab-edge-proxy/main.mjs && node --check cmd/hwlab-agent-mgr/main.mjs && node --check cmd/hwlab-agent-worker/main.mjs && node --check cmd/hwlab-device-pod/main.mjs && node --check cmd/hwlab-box-simu/main.mjs && node --check cmd/hwlab-gateway/main.mjs && node --check cmd/hwlab-gateway-simu/main.mjs && node --check tools/hwlab-gateway-shell.mjs && node --check tools/hwlab-gateway-tran.mjs && node --check tools/tran.mjs && node --check tools/device-pod-cli.mjs && node --check skills/device-pod-cli/scripts/device-pod-cli.mjs && node --check skills/device-pod-cli/assets/device-host-cli.mjs && node --check scripts/cloud-api-runtime-smoke.mjs && node --check scripts/code-agent-chat-smoke.mjs && node --check scripts/dev-edge-health-smoke.mjs && node --check scripts/src/dev-edge-health-smoke-lib.mjs && node --check scripts/validate-contract.mjs && node --check scripts/deploy-contract-plan.test.mjs && node --check scripts/deploy-desired-state-plan.mjs && node --check scripts/src/deploy-desired-state-plan.mjs && node --check scripts/artifact-runtime-readiness-guard.mjs && node --check scripts/src/artifact-runtime-readiness-guard.mjs && node --check scripts/artifact-runtime-readiness-guard.test.mjs && node --check scripts/report-lifecycle.mjs && node --check scripts/report-lifecycle.test.mjs && node --check scripts/validate-dev-gate-report.mjs && node --check scripts/dev-m3-hardware-loop-smoke.test.mjs && node --check scripts/m3-io-control-e2e.mjs && node --check scripts/src/m3-io-control-e2e.mjs && node --check scripts/m3-io-control-e2e.test.mjs && node --check scripts/dev-cloud-workbench-smoke.test.mjs && node --check scripts/dev-cloud-workbench-layout-smoke.mjs && node --check scripts/rpt004-mvp-e2e-harness.mjs && node --check scripts/src/rpt004-mvp-e2e-harness.mjs && node --check scripts/src/rpt004-mvp-e2e-harness.test.mjs && node --check scripts/validate-dev-m3-cardinality.mjs && node --check scripts/validate-dev-m3-cardinality.test.mjs && node --check scripts/validate-artifact-catalog.mjs && node --check scripts/refresh-artifact-catalog.mjs && node --check scripts/refresh-artifact-catalog.test.mjs && node --check scripts/artifact-publish.mjs && node --check scripts/g14-artifact-publish.mjs && node --check scripts/dev-runtime-base-image.mjs && node --check scripts/src/dev-artifact-services.mjs && node --check scripts/src/registry-capabilities.mjs && node --check scripts/preflight-dev-base-image.mjs && node --check scripts/src/dev-base-image-preflight.mjs && node --check scripts/dev-evidence-blocker-aggregator.mjs && node --check scripts/src/dev-evidence-blocker-aggregator.mjs && node --check scripts/src/dev-evidence-blocker-aggregator.test.mjs && node --check scripts/src/dev-m4-agent-loop-smoke-lib.test.mjs && node --check scripts/d601-k3s-readonly-observability.mjs && node --check scripts/src/d601-k3s-readonly-observability.mjs && node --check scripts/dev-runtime-provisioning.mjs && node --check scripts/src/dev-runtime-provisioning.mjs && node --check scripts/src/dev-runtime-provisioning.test.mjs && node --check scripts/dev-runtime-migration.mjs && node --check scripts/src/dev-runtime-migration.mjs && node --check scripts/src/dev-runtime-migration.test.mjs && node --check scripts/dev-runtime-postflight.mjs && node --check scripts/src/dev-runtime-postflight.mjs && node --check scripts/src/dev-runtime-postflight.test.mjs && node --check scripts/dev-runtime-hotfix-audit.mjs && node --check scripts/src/dev-runtime-hotfix-audit.mjs && node --check scripts/src/dev-runtime-hotfix-audit.test.mjs && node --test scripts/src/dev-runtime-hotfix-audit.test.mjs && node --check scripts/l2-runtime-contract-smoke.mjs && node --check scripts/patch-panel-runtime-smoke.mjs && node --check scripts/l6-cli-web-smoke.mjs && node --check skills/hwlab-agent-runtime/scripts/hwlab-agent-runtime-cli.mjs && node --check skills/hwlab-agent-runtime/scripts/src/m3-io-skill-client.mjs && node --check tools/hwlab-cli/bin/hwlab-cli.mjs && node --check tools/hwlab-cli/lib/cli.mjs && node --check tools/hwlab-cli/lib/cli.test.mjs && node --check web/hwlab-cloud-web/app.mjs && node --check web/hwlab-cloud-web/code-agent-facts.mjs && node --check web/hwlab-cloud-web/code-agent-facts.test.mjs && node --check web/hwlab-cloud-web/code-agent-status.mjs && node --check web/hwlab-cloud-web/code-agent-status.test.mjs && node --check web/hwlab-cloud-web/live-status.mjs && node --check web/hwlab-cloud-web/scripts/check.mjs && node --check web/hwlab-cloud-web/scripts/build.mjs && node --check web/hwlab-cloud-web/scripts/dist-contract.mjs && node scripts/validate-contract.mjs && node scripts/validate-dev-gate-report.mjs && node scripts/report-lifecycle.test.mjs && node scripts/validate-dev-m3-cardinality.mjs && node scripts/validate-artifact-catalog.mjs && node scripts/deploy-desired-state-plan.mjs --check && node scripts/dev-runtime-provisioning.mjs --check && node scripts/dev-runtime-migration.mjs --check && node cmd/hwlab-cloud-api/provision.mjs --check && node cmd/hwlab-cloud-api/migrate.mjs --check && node scripts/dev-runtime-postflight.mjs --check && node scripts/rpt004-mvp-e2e-harness.mjs --check --no-write && node scripts/dev-evidence-blocker-aggregator.mjs --check && node scripts/l2-runtime-contract-smoke.mjs && node scripts/l6-cli-web-smoke.mjs && node web/hwlab-cloud-web/scripts/check.mjs && node scripts/code-agent-chat-smoke.mjs && node scripts/cloud-api-runtime-smoke.mjs && sh -n scripts/bootstrap-skills.sh scripts/worker-entrypoint.sh", + "check": "node scripts/repo-reports-guard.mjs && node --check scripts/repo-reports-guard.mjs && node --check scripts/src/report-paths.mjs && node --check internal/protocol/index.mjs && node --check internal/build-metadata.mjs && node --check internal/agent/index.mjs && node --check internal/agent/runtime.mjs && node --check internal/audit/index.mjs && node --check internal/db/runtime-store.mjs && node --check internal/db/runtime-store.test.mjs && node --check internal/mvp-gate/summary.mjs && node --check internal/cloud/db-contract.mjs && node --check internal/dev-entrypoint/cloud-web-routes.mjs && node --check internal/dev-entrypoint/cloud-web-proxy.mjs && node --check internal/dev-entrypoint/cloud-web-proxy.test.mjs && node --check internal/dev-entrypoint/http.mjs && node --check internal/dev-entrypoint/http.test.mjs && node --check internal/cloud/code-agent-contract.mjs && node --check internal/cloud/code-agent-session-registry.mjs && node --check internal/cloud/code-agent-session-registry.test.mjs && node --check internal/cloud/code-agent-trace-store.mjs && node --check internal/cloud/codex-stdio-session.mjs && node --check internal/cloud/json-rpc.mjs && node --check internal/cloud/code-agent-chat.mjs && node --check internal/cloud/m3-io-control.mjs && node --check internal/cloud/server.mjs && node --check internal/device-pod/fake-data.mjs && node --check internal/sim/model.mjs && node --check internal/sim/model.test.mjs && node --check internal/sim/http.mjs && node --check internal/sim/l2-runtime.mjs && node --check internal/patchpanel/model.mjs && node --check internal/patchpanel/runtime.mjs && node --check cmd/hwlab-cloud-api/main.mjs && node --check cmd/hwlab-cloud-api/provision.mjs && node --check cmd/hwlab-cloud-api/migrate.mjs && node --check cmd/hwlab-deepseek-responses-bridge/main.mjs && node --check cmd/hwlab-deepseek-responses-bridge/main.test.mjs && node --test cmd/hwlab-deepseek-responses-bridge/main.test.mjs && node --check cmd/hwlab-edge-proxy/main.mjs && node --check cmd/hwlab-agent-mgr/main.mjs && node --check cmd/hwlab-agent-worker/main.mjs && node --check cmd/hwlab-device-pod/main.mjs && node --check cmd/hwlab-box-simu/main.mjs && node --check cmd/hwlab-gateway/main.mjs && node --check cmd/hwlab-gateway-simu/main.mjs && node --check tools/hwlab-gateway-shell.mjs && node --check tools/hwlab-gateway-tran.mjs && node --check tools/tran.mjs && node --check tools/device-pod-cli.mjs && node --check skills/device-pod-cli/scripts/device-pod-cli.mjs && node --check skills/device-pod-cli/assets/device-host-cli.mjs && node --check scripts/cloud-api-runtime-smoke.mjs && node --check scripts/code-agent-chat-smoke.mjs && node --check scripts/dev-edge-health-smoke.mjs && node --check scripts/src/dev-edge-health-smoke-lib.mjs && node --check scripts/validate-contract.mjs && node --check scripts/deploy-contract-plan.test.mjs && node --check scripts/deploy-desired-state-plan.mjs && node --check scripts/src/deploy-desired-state-plan.mjs && node --check scripts/artifact-runtime-readiness-guard.mjs && node --check scripts/src/artifact-runtime-readiness-guard.mjs && node --check scripts/artifact-runtime-readiness-guard.test.mjs && node --check scripts/report-lifecycle.mjs && node --check scripts/report-lifecycle.test.mjs && node --check scripts/validate-dev-gate-report.mjs && node --check scripts/dev-m3-hardware-loop-smoke.test.mjs && node --check scripts/m3-io-control-e2e.mjs && node --check scripts/src/m3-io-control-e2e.mjs && node --check scripts/m3-io-control-e2e.test.mjs && node --check scripts/dev-cloud-workbench-smoke.test.mjs && node --check scripts/dev-cloud-workbench-layout-smoke.mjs && node --check scripts/rpt004-mvp-e2e-harness.mjs && node --check scripts/src/rpt004-mvp-e2e-harness.mjs && node --check scripts/src/rpt004-mvp-e2e-harness.test.mjs && node --check scripts/validate-dev-m3-cardinality.mjs && node --check scripts/validate-dev-m3-cardinality.test.mjs && node --check scripts/validate-artifact-catalog.mjs && node --check scripts/refresh-artifact-catalog.mjs && node --check scripts/refresh-artifact-catalog.test.mjs && node --check scripts/artifact-publish.mjs && node --check scripts/g14-artifact-publish.mjs && node --check scripts/dev-runtime-base-image.mjs && node --check scripts/src/dev-artifact-services.mjs && node --check scripts/src/registry-capabilities.mjs && node --check scripts/preflight-dev-base-image.mjs && node --check scripts/src/dev-base-image-preflight.mjs && node --check scripts/dev-evidence-blocker-aggregator.mjs && node --check scripts/src/dev-evidence-blocker-aggregator.mjs && node --check scripts/src/dev-evidence-blocker-aggregator.test.mjs && node --check scripts/src/dev-m4-agent-loop-smoke-lib.test.mjs && node --check scripts/d601-k3s-readonly-observability.mjs && node --check scripts/src/d601-k3s-readonly-observability.mjs && node --check scripts/dev-runtime-provisioning.mjs && node --check scripts/src/dev-runtime-provisioning.mjs && node --check scripts/src/dev-runtime-provisioning.test.mjs && node --check scripts/dev-runtime-migration.mjs && node --check scripts/src/dev-runtime-migration.mjs && node --check scripts/src/dev-runtime-migration.test.mjs && node --check scripts/dev-runtime-postflight.mjs && node --check scripts/src/dev-runtime-postflight.mjs && node --check scripts/src/dev-runtime-postflight.test.mjs && node --check scripts/dev-runtime-hotfix-audit.mjs && node --check scripts/src/dev-runtime-hotfix-audit.mjs && node --check scripts/src/dev-runtime-hotfix-audit.test.mjs && node --test scripts/src/dev-runtime-hotfix-audit.test.mjs && node --check scripts/l2-runtime-contract-smoke.mjs && node --check scripts/patch-panel-runtime-smoke.mjs && node --check scripts/l6-cli-web-smoke.mjs && node --check skills/hwlab-agent-runtime/scripts/hwlab-agent-runtime-cli.mjs && node --check skills/hwlab-agent-runtime/scripts/src/m3-io-skill-client.mjs && node --check tools/hwlab-cli/bin/hwlab-cli.mjs && node --check tools/hwlab-cli/lib/cli.mjs && node --check tools/hwlab-cli/lib/cli.test.mjs && (cd web/hwlab-cloud-web && bun run check) && node scripts/validate-contract.mjs && node scripts/validate-dev-gate-report.mjs && node scripts/report-lifecycle.test.mjs && node scripts/validate-dev-m3-cardinality.mjs && node scripts/validate-artifact-catalog.mjs && node scripts/deploy-desired-state-plan.mjs --check && node scripts/dev-runtime-provisioning.mjs --check && node scripts/dev-runtime-migration.mjs --check && node cmd/hwlab-cloud-api/provision.mjs --check && node cmd/hwlab-cloud-api/migrate.mjs --check && node scripts/dev-runtime-postflight.mjs --check && node scripts/rpt004-mvp-e2e-harness.mjs --check --no-write && node scripts/dev-evidence-blocker-aggregator.mjs --check && node scripts/l2-runtime-contract-smoke.mjs && node scripts/l6-cli-web-smoke.mjs && (cd web/hwlab-cloud-web && bun run check) && node scripts/code-agent-chat-smoke.mjs && node scripts/cloud-api-runtime-smoke.mjs && sh -n scripts/bootstrap-skills.sh scripts/worker-entrypoint.sh", "dev-base-image:preflight": "node scripts/preflight-dev-base-image.mjs", "dev-runtime-base:build": "node scripts/dev-runtime-base-image.mjs", "cloud-api:smoke": "node scripts/cloud-api-runtime-smoke.mjs", @@ -23,8 +23,8 @@ "cli:dry-run": "node tools/hwlab-cli/bin/hwlab-cli.mjs test e2e --env dev --mvp", "cli:projects": "node tools/hwlab-cli/bin/hwlab-cli.mjs project list", "l6:smoke": "node scripts/l6-cli-web-smoke.mjs", - "web:check": "node web/hwlab-cloud-web/scripts/check.mjs && node --test web/hwlab-cloud-web/message-markdown.test.mjs web/hwlab-cloud-web/scripts/trace-scroll.test.mjs", - "web:build": "node web/hwlab-cloud-web/scripts/build.mjs", + "web:check": "(cd web/hwlab-cloud-web && bun run check) && bun test web/hwlab-cloud-web/message-markdown.test.ts", + "web:build": "(cd web/hwlab-cloud-web && bun run build)", "artifact-catalog:preview-blocked": "node scripts/refresh-artifact-catalog.mjs --target-ref HEAD --blocked --no-write", "g14:gitops:render": "node scripts/g14-gitops-render.mjs", "cicd:jobs": "node tools/hwlab-cli/bin/hwlab-cli.mjs cicd jobs", diff --git a/scripts/artifact-publish.mjs b/scripts/artifact-publish.mjs index d503d409..1298de12 100644 --- a/scripts/artifact-publish.mjs +++ b/scripts/artifact-publish.mjs @@ -21,7 +21,6 @@ import { serviceInventoryFromServices } from "./src/dev-artifact-services.mjs"; import { probeRegistryCapabilities } from "./src/registry-capabilities.mjs"; -import { inspectCloudWebDistFreshness } from "../web/hwlab-cloud-web/scripts/dist-contract.mjs"; import { ensureNotRepoReportsPath, tempReportPath } from "./src/report-paths.mjs"; const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); @@ -516,6 +515,26 @@ async function run(command, args, options = {}) { }; } +function bunExecutable() { + return process.env.HWLAB_BUN_BINARY || process.env.BUN_BINARY || "bun"; +} + +async function inspectCloudWebDistFreshness(webRoot) { + const result = await run(bunExecutable(), ["web/hwlab-cloud-web/scripts/dist-contract.ts", "--inspect", "--root", webRoot]); + if (result.code !== 0) { + return { + status: "blocked", + distPath: "web/hwlab-cloud-web/dist", + buildCommand: "cd web/hwlab-cloud-web && bun run build", + freshnessCommand: "cd web/hwlab-cloud-web && bun run build", + files: [], + mismatches: ["dist-contract-inspect"], + error: tailText(`${result.stdout}\n${result.stderr}`) + }; + } + return JSON.parse(result.stdout); +} + function assertDevOnlyContracts(catalog, deployManifest) { assert.equal(catalog.environment, ENVIRONMENT_DEV, "catalog.environment must be dev"); assert.equal(catalog.profile, ENVIRONMENT_DEV, "catalog.profile must be dev"); @@ -1345,7 +1364,7 @@ async function buildService({ args, repo, commitId, shortCommit, service, buildC } if (service.runtimeKind === "cloud-web") { - const build = await run(process.execPath, ["web/hwlab-cloud-web/scripts/build.mjs"]); + const build = await run(bunExecutable(), ["web/hwlab-cloud-web/scripts/build.ts"]); if (build.code !== 0) { return { ...service, @@ -1359,7 +1378,7 @@ async function buildService({ args, repo, commitId, shortCommit, service, buildC type: "environment_blocker", scope: service.serviceId, summary: "cloud web dist build failed before BuildKit image build", - next: "Run node web/hwlab-cloud-web/scripts/build.mjs, fix the static asset build, then rerun --publish." + next: "Run bun run --cwd web/hwlab-cloud-web build, fix the static asset build, then rerun --publish." }), logTail: tailText(`${build.stdout}\n${build.stderr}`), cloudWebBuildDurationMs: build.durationMs @@ -1381,7 +1400,7 @@ async function buildService({ args, repo, commitId, shortCommit, service, buildC type: "contract_blocker", scope: service.serviceId, summary: `cloud web dist is stale or incomplete before BuildKit image build: ${distFreshness.mismatches.join(", ")}`, - next: "Run node web/hwlab-cloud-web/scripts/build.mjs and node web/hwlab-cloud-web/scripts/check.mjs before publishing hwlab-cloud-web." + next: "Run bun run --cwd web/hwlab-cloud-web build and bun run --cwd web/hwlab-cloud-web check before publishing hwlab-cloud-web." }) }; } diff --git a/scripts/l6-cli-web-smoke.mjs b/scripts/l6-cli-web-smoke.mjs index 5079b535..80cab408 100644 --- a/scripts/l6-cli-web-smoke.mjs +++ b/scripts/l6-cli-web-smoke.mjs @@ -55,11 +55,15 @@ assert.ok(Array.isArray(liveBlockedJson.blockers)); const activeWebSource = [ "web/hwlab-cloud-web/index.html", - "web/hwlab-cloud-web/app.mjs", - "web/hwlab-cloud-web/live-status.mjs", + "web/hwlab-cloud-web/app.ts", + "web/hwlab-cloud-web/app-device-pod.ts", + "web/hwlab-cloud-web/app-conversation.ts", + "web/hwlab-cloud-web/app-trace.ts", + "web/hwlab-cloud-web/app-helpers.ts", + "web/hwlab-cloud-web/live-status.ts", "web/hwlab-cloud-web/help.md" ].map((file) => fs.readFileSync(path.resolve(repoRoot, file), "utf8")).join("\n"); -const distContract = fs.readFileSync(path.resolve(repoRoot, "web/hwlab-cloud-web/scripts/dist-contract.mjs"), "utf8"); +const distContract = fs.readFileSync(path.resolve(repoRoot, "web/hwlab-cloud-web/scripts/dist-contract.ts"), "utf8"); assert.match(activeWebSource, /Device Pod/u); assert.match(activeWebSource, /\/v1\/device-pods/u); diff --git a/scripts/src/artifact-runtime-readiness-guard.mjs b/scripts/src/artifact-runtime-readiness-guard.mjs index 205fd6d6..93976cc7 100644 --- a/scripts/src/artifact-runtime-readiness-guard.mjs +++ b/scripts/src/artifact-runtime-readiness-guard.mjs @@ -5,11 +5,46 @@ import { fileURLToPath } from "node:url"; import { activeReportLifecycle } from "../../internal/dev-report-lifecycle.mjs"; import { DEV_ENDPOINT, DEV_FRONTEND_ENDPOINT } from "../../internal/protocol/index.mjs"; -import { inspectCloudWebDistFreshness } from "../../web/hwlab-cloud-web/scripts/dist-contract.mjs"; import { buildDesiredStatePlan } from "./deploy-desired-state-plan.mjs"; import { ensureNotRepoReportsPath, tempReportPath } from "./report-paths.mjs"; const defaultRepoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const cloudWebAppSourceFiles = Object.freeze([ + "web/hwlab-cloud-web/app.ts", + "web/hwlab-cloud-web/app-device-pod.ts", + "web/hwlab-cloud-web/app-conversation.ts", + "web/hwlab-cloud-web/app-trace.ts", + "web/hwlab-cloud-web/app-helpers.ts" +]); + +function bunExecutable() { + return process.env.HWLAB_BUN_BINARY || process.env.BUN_BINARY || "bun"; +} + +function inspectCloudWebDistFreshness(webRoot) { + try { + const stdout = execFileSync(bunExecutable(), ["web/hwlab-cloud-web/scripts/dist-contract.ts", "--inspect", "--root", webRoot], { + cwd: defaultRepoRoot, + encoding: "utf8" + }); + return JSON.parse(stdout); + } catch (error) { + return { + status: "blocked", + distPath: "web/hwlab-cloud-web/dist", + buildCommand: "cd web/hwlab-cloud-web && bun run build", + freshnessCommand: "cd web/hwlab-cloud-web && bun run build", + files: [], + mismatches: ["dist-contract-inspect"], + error: error instanceof Error ? error.message : String(error) + }; + } +} + +async function readCloudWebAppSource(repoRoot) { + const parts = await Promise.all(cloudWebAppSourceFiles.map((file) => readFile(path.join(repoRoot, file), "utf8"))); + return parts.join("\n"); +} const defaultArtifactReport = tempReportPath("dev-artifacts.json"); const defaultArtifactCatalog = "deploy/artifact-catalog.dev.json"; @@ -906,8 +941,8 @@ function summarizeCloudWebBuildFreshness(distFreshness, artifactCloudWeb) { sourceDistStatus: sourceStatus, artifactDistFreshnessStatus: artifactStatus, mismatches: Array.isArray(distFreshness?.mismatches) ? distFreshness.mismatches.slice(0, 8) : [], - buildCommand: "node web/hwlab-cloud-web/scripts/build.mjs", - checkCommand: "node web/hwlab-cloud-web/scripts/check.mjs", + buildCommand: "bun run --cwd web/hwlab-cloud-web build", + checkCommand: "bun run --cwd web/hwlab-cloud-web check", summary: sourceStatus === "pass" ? "Cloud Web source dist freshness is ready for publish review." : "Cloud Web dist is stale or missing for source or published artifact evidence." @@ -916,7 +951,7 @@ function summarizeCloudWebBuildFreshness(distFreshness, artifactCloudWeb) { async function inspectCodeAgentTimeoutReadiness(repoRoot) { const [appSource, cloudApiEntrySource] = await Promise.all([ - readFile(path.join(repoRoot, "web/hwlab-cloud-web/app.mjs"), "utf8"), + readCloudWebAppSource(repoRoot), readFile(path.join(repoRoot, "cmd/hwlab-cloud-api/main.mjs"), "utf8") ]); const frontendTimeoutMs = numericSourceConstant(appSource, "DEFAULT_CODE_AGENT_TIMEOUT_MS"); @@ -953,7 +988,7 @@ function parseTimeoutFallbackMs(source, envName) { async function inspectM3IoSourceReadiness(repoRoot) { const files = await listTrackedFiles(repoRoot); const candidateFiles = files.filter((file) => - /\.(?:mjs|js|json|md|html|css|yaml|yml)$/u.test(file) && + /\.(?:mjs|js|ts|json|md|html|css|yaml|yml)$/u.test(file) && file !== "scripts/src/artifact-runtime-readiness-guard.mjs" && file !== "scripts/artifact-runtime-readiness-guard.test.mjs" ); @@ -970,7 +1005,7 @@ async function inspectM3IoSourceReadiness(repoRoot) { } const present = matches.length > 0; const sourceReady = contractFiles.length > 0 && - matches.some((file) => file === "web/hwlab-cloud-web/app.mjs") && + matches.some((file) => cloudWebAppSourceFiles.includes(file)) && matches.some((file) => file === "internal/cloud/m3-io-control.mjs" || file === "scripts/src/m3-io-control-e2e.mjs"); return { status: present ? (sourceReady ? "pass" : "blocked") : "absent", @@ -1175,8 +1210,8 @@ function dedupeReasons(reasons) { function nextCommands({ canPublish, canApply, blockedReasons }) { if (!canPublish) { return [ - "node web/hwlab-cloud-web/scripts/build.mjs", - "node web/hwlab-cloud-web/scripts/check.mjs", + "bun run --cwd web/hwlab-cloud-web build", + "bun run --cwd web/hwlab-cloud-web check", "node scripts/g14-artifact-publish.mjs --preflight --no-report", "node scripts/refresh-artifact-catalog.mjs --target-ref origin/main --blocked --no-write", guardCommand diff --git a/scripts/src/dev-runtime-postflight.mjs b/scripts/src/dev-runtime-postflight.mjs index dd2b8bad..5c199cd0 100644 --- a/scripts/src/dev-runtime-postflight.mjs +++ b/scripts/src/dev-runtime-postflight.mjs @@ -1,10 +1,10 @@ import { createHash } from "node:crypto"; +import { execFileSync } from "node:child_process"; import { mkdir, writeFile } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { DEV_ENDPOINT, ENVIRONMENT_DEV } from "../../internal/protocol/index.mjs"; -import { classifyRuntimeDurableReadiness } from "../../web/hwlab-cloud-web/live-status.mjs"; import { buildM3IoControlSourceReport, runM3IoControlLiveReport @@ -23,6 +23,25 @@ const expectedM3Sequence = Object.freeze([ { id: "read-di1-false", action: "di.read", resultValue: false } ]); +function bunExecutable() { + return process.env.HWLAB_BUN_BINARY || process.env.BUN_BINARY || "bun"; +} + +function classifyRuntimeDurableReadiness(payload = {}, options = {}) { + const script = [ + "import { classifyRuntimeDurableReadiness } from './web/hwlab-cloud-web/live-status.ts';", + "const input = JSON.parse(await Bun.stdin.text());", + "const output = classifyRuntimeDurableReadiness(input.payload, input.options);", + "process.stdout.write(JSON.stringify(output));" + ].join("\n"); + const stdout = execFileSync(bunExecutable(), ["--eval", script], { + cwd: repoRoot, + input: JSON.stringify({ payload, options }), + encoding: "utf8" + }); + return JSON.parse(stdout); +} + export async function runDevRuntimePostflightCli(argv = process.argv.slice(2), options = {}) { const args = parseArgs(argv); if (args.help) { diff --git a/scripts/src/m3-io-control-e2e.mjs b/scripts/src/m3-io-control-e2e.mjs index 35c9a9f2..d56de1ac 100644 --- a/scripts/src/m3-io-control-e2e.mjs +++ b/scripts/src/m3-io-control-e2e.mjs @@ -228,7 +228,7 @@ export async function runM3IoControlSourceChecks({ repoRoot: root = repoRoot } = readRepoText(root, "internal/cloud/server.mjs"), readRepoText(root, "internal/cloud/json-rpc.mjs"), readRepoText(root, "internal/cloud/m3-io-control.mjs"), - readRepoText(root, "web/hwlab-cloud-web/app.mjs"), + Promise.all(["web/hwlab-cloud-web/app.ts","web/hwlab-cloud-web/app-device-pod.ts","web/hwlab-cloud-web/app-conversation.ts","web/hwlab-cloud-web/app-trace.ts","web/hwlab-cloud-web/app-helpers.ts"].map((file) => readRepoText(root, file))).then((parts) => parts.join("\n")), readRepoText(root, "web/hwlab-cloud-web/index.html"), readRepoText(root, "scripts/artifact-publish.mjs"), readRepoText(root, "internal/dev-entrypoint/cloud-web-routes.mjs") diff --git a/web/hwlab-cloud-web/app-conversation.ts b/web/hwlab-cloud-web/app-conversation.ts new file mode 100644 index 00000000..472d0e72 --- /dev/null +++ b/web/hwlab-cloud-web/app-conversation.ts @@ -0,0 +1,1431 @@ +function renderConversation() { + const renderVersion = ++state.conversationRenderVersion; + captureConversationScrollPosition(); + captureTraceScrollPositions(); + captureTraceBodyScrollPositions(); + const introMessages = [ + { + role: "system", + title: "界面模式", + text: "这里是用户工作台:可以整理任务、查看 Device Pod summary 和纯文本事件流。对话会发送到受控 Code Agent 后端;页面不会直接发送硬件变更。", + status: "source" + }, + codeAgentStatusMessage(state.codeAgentAvailability) + ]; + replaceChildren(el.conversationList, ...[...introMessages, ...state.chatMessages].map(messageCard)); + restoreConversationScrollPosition(); + restoreTraceScrollPositions(); + restoreTraceBodyScrollPositions(); + scheduleCodeAgentSessionPersist(); + window.requestAnimationFrame(() => { + if (renderVersion !== state.conversationRenderVersion) return; + restoreConversationScrollPosition({ deferred: true }); + restoreTraceScrollPositions(el.conversationList, { deferred: true }); + restoreTraceBodyScrollPositions(el.conversationList, { deferred: true }); + }); +} + +function initConversationScrollMemory() { + for (const eventName of ["wheel", "touchstart", "pointerdown"]) { + el.conversationList.addEventListener(eventName, markConversationScrollIntent, { passive: true }); + } + el.conversationList.addEventListener("keydown", (event) => { + if (isScrollIntentKey(event.key)) markConversationScrollIntent(); + }); + el.conversationList.addEventListener("scroll", () => { + captureConversationScrollPosition(); + }, { passive: true }); +} + +function markConversationScrollIntent() { + state.conversationScrollUserActiveUntil = Date.now() + SCROLL_USER_ACTIVITY_MS; +} + +function isScrollIntentKey(key) { + return ["ArrowDown", "ArrowUp", "PageDown", "PageUp", "Home", "End", " "].includes(key); +} + +function captureConversationScrollPosition() { + state.conversationScrollPosition = { + top: el.conversationList.scrollTop, + left: el.conversationList.scrollLeft, + bottomGap: scrollBottomGap(el.conversationList) + }; +} + +function restoreConversationScrollPosition(options = {}) { + if (options.deferred === true && Date.now() < state.conversationScrollUserActiveUntil) return; + const position = state.conversationScrollPosition; + if (!position) return; + writeConversationScrollTop(scrollTopForPosition(el.conversationList, position)); + el.conversationList.scrollLeft = Math.min(position.left, Math.max(0, el.conversationList.scrollWidth - el.conversationList.clientWidth)); +} + +function captureTraceScrollPositions(root = el.conversationList) { + for (const list of root.querySelectorAll(".message-trace-events[data-trace-ui-key]")) { + rememberTraceScrollPosition(list.dataset.traceUiKey, list); + } +} + +function rememberTraceScrollPosition(traceUiKey, list) { + if (!traceUiKey || !list) return; + state.traceScrollPositions.set(traceUiKey, { + top: list.scrollTop, + left: list.scrollLeft, + bottomGap: scrollBottomGap(list) + }); +} + +function restoreTraceScrollPositions(root = el.conversationList, options = {}) { + for (const list of root.querySelectorAll(".message-trace-events[data-trace-ui-key]")) { + if (options.deferred === true && isTraceScrollUserActive(list.dataset.traceUiKey)) continue; + const position = state.traceScrollPositions.get(list.dataset.traceUiKey); + if (!position) continue; + writeTraceScrollTop(list, scrollTopForPosition(list, position)); + list.scrollLeft = Math.min(position.left, Math.max(0, list.scrollWidth - list.clientWidth)); + } +} + +function captureTraceBodyScrollPositions(root = el.conversationList) { + for (const body of root.querySelectorAll(".message-trace-body")) { + rememberTraceBodyScrollPosition(body); + } +} + +function traceBodyScrollKey(body) { + const list = body?.closest?.(".message-trace-events[data-trace-ui-key]"); + const row = body?.closest?.(".message-trace-row[data-trace-row-id]"); + const traceUiKey = list?.dataset?.traceUiKey; + const rowId = row?.dataset?.traceRowId; + return traceUiKey && rowId ? `${traceUiKey}:${rowId}` : null; +} + +function rememberTraceBodyScrollPosition(body) { + const key = traceBodyScrollKey(body); + if (!key || !body) return; + state.traceBodyScrollPositions.set(key, elementScrollPosition(body)); +} + +function restoreTraceBodyScrollPositions(root = el.conversationList, options = {}) { + for (const body of root.querySelectorAll(".message-trace-body")) { + const key = traceBodyScrollKey(body); + if (!key) continue; + if (options.deferred === true && isTraceBodyScrollUserActive(key)) continue; + const position = state.traceBodyScrollPositions.get(key); + if (!position) continue; + restoreElementScrollPosition(body, position); + } +} + +function markTraceBodyScrollIntent(body) { + const key = traceBodyScrollKey(body); + if (!key) return; + state.traceBodyScrollUserActiveUntil.set(key, Date.now() + SCROLL_USER_ACTIVITY_MS); +} + +function isTraceBodyScrollUserActive(key) { + if (!key) return false; + return Date.now() < Number(state.traceBodyScrollUserActiveUntil.get(key) ?? 0); +} + +function elementScrollPosition(element) { + return { + top: element.scrollTop, + left: element.scrollLeft, + bottomGap: scrollBottomGap(element) + }; +} + +function restoreElementScrollPosition(element, position) { + if (!element || !position) return; + element.scrollTop = scrollTopForPosition(element, position); + element.scrollLeft = Math.min(position.left, Math.max(0, element.scrollWidth - element.clientWidth)); +} + +function scrollBottomGap(element) { + return Math.max(0, element.scrollHeight - element.clientHeight - element.scrollTop); +} + +function scrollTopForPosition(element, position) { + const maxTop = Math.max(0, element.scrollHeight - element.clientHeight); + if (Number(position.bottomGap) <= SCROLL_BOTTOM_PIN_PX) return maxTop; + return Math.min(position.top, maxTop); +} + +function markTraceScrollIntent(traceUiKey) { + if (!traceUiKey) return; + state.traceScrollUserActiveUntil.set(traceUiKey, Date.now() + SCROLL_USER_ACTIVITY_MS); +} + +function isTraceScrollUserActive(traceUiKey) { + if (!traceUiKey) return false; + return Date.now() < Number(state.traceScrollUserActiveUntil.get(traceUiKey) ?? 0); +} + +function writeConversationScrollTop(top) { + if (el.conversationList.scrollTop === top) return; + state.traceProgrammaticScrollWrites += 1; + el.conversationList.scrollTop = top; +} + +function writeTraceScrollTop(list, top) { + if (!list || list.scrollTop === top) return; + state.traceProgrammaticScrollWrites += 1; + list.scrollTop = top; +} + +function patchMessageTracePanel(message) { + const traceUiKey = messageTraceUiKey(message); + if (!traceUiKey) return false; + const panel = el.conversationList.querySelector(`.message-trace[data-trace-ui-key="${cssEscape(traceUiKey)}"]`); + if (!panel) return false; + const replacement = messageTracePanel(message); + if (!replacement) { + panel.remove(); + return true; + } + patchTracePanelElement(panel, replacement); + return true; +} + +function patchTracePanelElement(panel, replacement) { + panel.open = replacement.open; + const currentSummary = panel.querySelector("summary"); + const nextSummary = replacement.querySelector("summary"); + if (currentSummary && nextSummary) { + currentSummary.className = nextSummary.className; + currentSummary.textContent = nextSummary.textContent; + } + + const currentToolbar = panel.querySelector(".message-trace-toolbar"); + const nextToolbar = replacement.querySelector(".message-trace-toolbar"); + if (currentToolbar && nextToolbar) { + currentToolbar.replaceChildren(...nextToolbar.childNodes); + } else if (!currentToolbar && nextToolbar) { + const list = panel.querySelector(".message-trace-events"); + panel.insertBefore(nextToolbar, list); + } else if (currentToolbar && !nextToolbar) { + currentToolbar.remove(); + } + + const currentList = panel.querySelector(".message-trace-events[data-trace-ui-key]"); + const nextList = replacement.querySelector(".message-trace-events[data-trace-ui-key]"); + if (currentList && nextList) { + captureTraceBodyScrollPositions(currentList); + currentList.dataset.traceMode = nextList.dataset.traceMode ?? ""; + patchTraceEventList(currentList, [...nextList.children]); + restoreTraceBodyScrollPositions(currentList); + } +} + +function cssEscape(value) { + if (window.CSS && typeof window.CSS.escape === "function") return window.CSS.escape(value); + return String(value).replace(/["\\]/gu, "\\$&"); +} + + +function renderGateTable() { + const rows = filteredGateRows(); + const loading = state.gateDiagnostics.loading; + const payload = state.gateDiagnostics.payload; + const statusTone = gatePayloadTone(); + const loadedAt = state.gateDiagnostics.loadedAt; + el.gateSourceStatus.textContent = loading + ? "读取 live 后端中" + : payload + ? `live 后端 ${rows.length}/${state.gateDiagnostics.rows.length} 行` + : state.gateDiagnostics.error + ? "live 后端阻塞" + : "等待 live 后端"; + el.gateSourceStatus.className = `state-tag tone-${toneClass(statusTone)}`; + el.gateSourceStatus.title = payload + ? `route=${payload.route}; observedAt=${payload.observedAt ?? loadedAt ?? "unknown"}` + : state.gateDiagnostics.error ?? "等待 /v1/diagnostics/gate"; + + if (loading && state.gateDiagnostics.rows.length === 0) { + const tr = document.createElement("tr"); + tr.append(tableCell("正在读取 live 后端聚合。", "wrap", 8)); + replaceChildren(el.gateReviewBody, tr); + return; + } + + if (rows.length === 0) { + const tr = document.createElement("tr"); + tr.append(tableCell("没有匹配当前过滤条件的复核行。", "wrap", 8)); + replaceChildren(el.gateReviewBody, tr); + return; + } + + replaceChildren(el.gateReviewBody, ...rows.map(gateTableRow)); +} + +function filteredGateRows() { + const filter = el.gateStatusFilter.value; + const query = el.gateSearch.value.trim().toLowerCase(); + return state.gateDiagnostics.rows.filter((row) => { + if (filter !== "all" && row.status !== filter) return false; + if (!query) return true; + return [ + row.category, + row.check, + row.status, + row.owner, + row.detail, + ...(row.evidence ?? []), + row.updatedAt, + row.next + ].join(" ").toLowerCase().includes(query); + }); +} + +function gateTableRow(row) { + const tr = document.createElement("tr"); + tr.dataset.status = row.status; + tr.dataset.statusKey = row.statusKey; + tr.append(tableCell(row.category, "wrap")); + tr.append(tableCell(row.check, "wrap")); + const status = document.createElement("td"); + status.append(badge(row.status, row.statusKey)); + tr.append(status); + tr.append(tableCell(row.owner, "mono wrap")); + tr.append(tableCell(row.detail, "wrap")); + tr.append(tableCell((row.evidence ?? []).join(" / "), "mono wrap")); + tr.append(tableCell(formatTimestamp(row.updatedAt), "mono wrap")); + tr.append(tableCell(row.next, "wrap")); + return tr; +} + +function normalizeGateRow(row) { + const statusKey = normalizeGateStatusKey(row.statusKey ?? row.status); + return { + category: oneLine(row.category || "内部复核"), + check: oneLine(row.check || "未命名检查项"), + status: gateStatusLabel(row.status, statusKey), + statusKey, + owner: oneLine(row.owner || "未标明"), + detail: oneLine(row.detail || "live 后端未返回关键细节"), + evidence: normalizeEvidenceList(row.evidence), + updatedAt: row.updatedAt || new Date().toISOString(), + next: oneLine(row.next || "等待维护者补充下一步。"), + sourceKind: "LIVE-BACKEND" + }; +} + +function gateLiveBlockerRow(response) { + const now = new Date().toISOString(); + return normalizeGateRow({ + category: "健康检查", + check: "内部复核 live 后端聚合", + status: "阻塞", + statusKey: response.timeout ? "failed" : "blocked", + owner: "hwlab-cloud-api /v1/diagnostics/gate", + detail: response.error || "live 后端聚合不可用;页面不会回退到 SOURCE 静态拓扑。", + evidence: [ + "GET /v1/diagnostics/gate", + response.status ? `HTTP ${response.status}` : null, + response.timeout ? `timeoutMs=${response.timeoutMs}` : null + ].filter(Boolean), + updatedAt: now, + next: "先恢复后端聚合接口,再刷新本表。" + }); +} + +function gatePayloadTone() { + if (state.gateDiagnostics.loading) return "pending"; + if (!state.gateDiagnostics.payload) return state.gateDiagnostics.error ? "blocked" : "source"; + const rows = state.gateDiagnostics.rows; + if (rows.some((row) => row.statusKey === "failed")) return "failed"; + if (rows.some((row) => row.statusKey === "blocked")) return "blocked"; + if (rows.some((row) => row.statusKey === "pending")) return "pending"; + return "pass"; +} + +function renderAgentChatStatus(status, result = null) { + const labels = { + idle: "等待输入", + running: "处理中", + completed: "DEV-LIVE 回复", + source: "SOURCE 回复", + failed: "后端失败", + timeout: "等待超时", + canceled: "已取消", + error: "请求错误", + blocked: "服务受阻" + }; + el.agentChatStatus.textContent = agentStatusLabel(status, result, labels); + el.agentChatStatus.className = `state-tag tone-${toneClass(agentStatusTone(status, result))}`; + el.commandInput.disabled = status === "running"; + el.commandSend.disabled = status === "running"; + el.commandSend.textContent = status === "running" ? "发送中" : "发送"; + if (result?.conversationId) { + el.agentChatStatus.title = `${result.provider ?? "provider"} / ${result.model ?? "model"} / ${result.backend ?? "backend"}`; + } else { + el.agentChatStatus.removeAttribute("title"); + } +} + +function renderCodeAgentSummary() { + const summary = currentCodeAgentStatusSummary(); + el.codeAgentSummary.className = `code-agent-summary tone-border-${toneClass(summary.tone)}`; + el.codeAgentSummary.dataset.codeAgentStatusKind = summary.kind; + el.codeAgentSummaryIcon.textContent = summary.icon; + el.codeAgentSummaryIcon.className = `code-agent-summary-icon tone-${toneClass(summary.tone)}`; + el.codeAgentSummaryLabel.textContent = summary.label; + el.codeAgentSummaryLabel.className = `code-agent-summary-label tone-${toneClass(summary.tone)}`; + el.codeAgentSummaryCapability.textContent = summary.capabilityLevel; + el.codeAgentSummaryTrace.textContent = `trace ${summary.lastTraceId}`; + replaceChildren(el.codeAgentSummaryDetail, ...codeAgentSummaryRows(summary)); +} + +function currentCodeAgentStatusSummary() { + return classifyCodeAgentStatusSummary({ + availability: state.codeAgentAvailability, + latestMessage: latestChatResult(), + live: state.liveSurface + }); +} + +function codeAgentSummaryRows(summary) { + return [ + codeAgentSummaryRow("codeAgent.status", summary.codeAgentStatus, summary.tone), + codeAgentSummaryRow("provider/mode/backend", summary.providerModeBackend, "source"), + codeAgentSummaryRow("capabilityLevel", summary.capabilityLevel, summary.tone), + codeAgentSummaryRow("session", summary.sessionSummary, sessionSummaryTone(summary)), + codeAgentSummaryRow("sessionId/status", summary.sessionIdStatus, sessionSummaryTone(summary)), + codeAgentSummaryRow("会话提示", summary.sessionLifecycleHint, sessionSummaryTone(summary)), + codeAgentSummaryRow("workspace", summary.workspace, "source"), + codeAgentSummaryRow("sandbox", summary.sandbox, "source"), + codeAgentSummaryRow("runnerKind", summary.runnerKind, summary.kind === "long-lived-session" ? "ok" : "source"), + codeAgentSummaryRow("protocol", summary.protocol, summary.runtimePathTone), + codeAgentSummaryRow("implementationType", summary.implementationType, summary.runtimePathTone), + codeAgentSummaryRow("providerTrace.command", summary.providerTraceCommand, summary.runtimePathTone), + codeAgentSummaryRow("providerTrace.terminalStatus", summary.providerTraceTerminalStatus, summary.runtimePathTone), + codeAgentSummaryRow("运行路径语义", summary.runtimePathLabel, summary.runtimePathTone), + codeAgentSummaryRow("toolCalls", summary.toolCalls, summary.toolCalls === "none" ? "source" : summary.tone), + codeAgentSummaryRow("skills", summary.skills, summary.skills === "none" ? "source" : summary.tone), + codeAgentSummaryRow("conversation facts", conversationFactsSummary(summary.latestMessage?.conversationFacts), summary.latestMessage?.conversationFacts ? "source" : "blocked"), + codeAgentSummaryRow("fact traces", conversationFactTracesSummary(summary.latestMessage?.conversationFacts), summary.latestMessage?.conversationFacts ? "source" : "blocked"), + codeAgentSummaryRow("runnerTrace", summary.runnerTrace, "source"), + codeAgentSummaryRow("readiness blockers", summary.blockersLabel, summary.readinessBlockers.length > 0 ? "blocked" : "source"), + codeAgentSummaryRow("last traceId", summary.lastTraceId, "source"), + codeAgentSummaryRow("当前部署 revision", summary.deploymentRevision, "source") + ]; +} + +function codeAgentSummaryRow(label, value, tone) { + const row = document.createElement("div"); + row.className = "code-agent-summary-row"; + row.append(textSpan(label, "code-agent-summary-key"), textSpan(value, `code-agent-summary-value tone-${toneClass(tone)}`)); + return row; +} + +function sessionSummaryTone(summary) { + if (summary.kind === "long-lived-session") return "ok"; + if (summary.kind === "blocked" || summary.kind === "skill-cli-api-blocked") return "blocked"; + if (["read-only-session-tools", "stateless-one-shot", "fallback-text-chat-only", "degraded"].includes(summary.kind)) return "warn"; + return "source"; +} + +function agentStatusLabel(status, result, labels) { + if (status !== "failed") return labels[status] ?? statusLabel(status); + const presentation = agentFailurePresentation(result?.error, { result }); + return ( + { + timeout: "等待超时", + provider: "Provider 不可用", + runner_busy: "Runner 忙碌", + session_blocked: "Session 受阻", + canceled: "已取消", + runner_blocked: "Runner 受阻", + api_error: "API 错误", + needs_config: "需要配置", + capability_unavailable: "能力未开放", + security_blocked: "安全阻断", + fallback: "仍是 fallback", + retryable: "可重试" + }[presentation.category] ?? labels.failed + ); +} + +function agentStatusTone(status, result) { + if (status === "completed") return "dev-live"; + if (["failed", "blocked", "timeout", "canceled", "error"].includes(status)) return "blocked"; + if (status === "running") return "pending"; + return "source"; +} + +function sourceTitle(result) { + return `Code Agent 回复 ${shortTime(result.updatedAt ?? new Date().toISOString())}`; +} + +function sourceFixtureTitle(result) { + return `Code Agent SOURCE 回复 ${shortTime(result.updatedAt ?? new Date().toISOString())}`; +} + +function textFallbackTitle(result) { + return `Code Agent 文本 fallback 回复 ${shortTime(result.updatedAt ?? new Date().toISOString())}`; +} + +function classifyCodeAgentCompletion(result, { blockedError = null } = {}) { + if (result?.status === "canceled") { + return { + status: "canceled", + replied: false, + sourceKind: "BLOCKED", + title: "Code Agent 已取消" + }; + } + if (result?.status === "timeout") { + return { + status: "timeout", + replied: false, + sourceKind: "BLOCKED", + title: "Code Agent 无新事件超时" + }; + } + if (result?.status === "error") { + return { + status: "failed", + replied: false, + sourceKind: "BLOCKED", + title: "Code Agent 返回错误" + }; + } + if (isStructuredBlockedChatResult(result)) { + return { + status: "failed", + replied: false, + sourceKind: "BLOCKED", + title: agentFailurePresentation(structuredBlockedErrorFromResult(result), { result }).title + }; + } + if (blockedError) { + return { + status: "failed", + replied: false, + sourceKind: "BLOCKED", + title: agentFailurePresentation(blockedError, { result }).title + }; + } + if (isRealCompletedChatResult(result)) { + return { + status: "completed", + replied: true, + sourceKind: "DEV-LIVE", + title: sourceTitle(result) + }; + } + if (isSourceFixtureChatResult(result)) { + return { + status: "source", + replied: true, + sourceKind: "SOURCE", + title: sourceFixtureTitle(result) + }; + } + if (isTextFallbackChatResult(result)) { + return { + status: "source", + replied: true, + sourceKind: "TEXT-FALLBACK", + title: textFallbackTitle(result) + }; + } + return { + status: "failed", + replied: false, + sourceKind: "BLOCKED", + title: result?.status === "completed" && !result?.blocker ? "Code Agent 完成证据不足" : agentFailurePresentation(result?.error, { result }).title + }; +} + +function isStructuredBlockedChatResult(result) { + const error = result?.error && typeof result.error === "object" ? result.error : {}; + return Boolean( + result && + typeof result === "object" && + ( + result.blocker?.code || + error.blocker?.code || + result.capabilityLevel === "hwlab-api-control-blocked" + ) + ); +} + +function structuredBlockedErrorFromResult(result) { + if (!isStructuredBlockedChatResult(result)) return null; + const error = result?.error && typeof result.error === "object" ? result.error : {}; + const blocker = error.blocker ?? result?.blocker ?? {}; + const code = normalizeErrorCode(error.code ?? blocker.code ?? "code_agent_blocked"); + const selectedBlocker = blocker; + return { + ...error, + code, + layer: error.layer ?? selectedBlocker.layer, + category: error.category ?? selectedBlocker.category, + blocker: selectedBlocker, + retryable: error.retryable ?? selectedBlocker.retryable, + userMessage: error.userMessage ?? selectedBlocker.userMessage, + message: error.message ?? selectedBlocker.summary ?? selectedBlocker.zh ?? selectedBlocker.message ?? code, + traceId: error.traceId ?? result?.traceId, + route: error.route ?? selectedBlocker.route, + toolName: error.toolName ?? selectedBlocker.toolName + }; +} + + +function failureMessage(result) { + const presentation = agentFailurePresentation(structuredBlockedErrorFromResult(result) ?? result?.error, { result }); + if (presentation.category !== "unknown") { + return presentation.text; + } + if (result?.error?.code === "provider_unavailable" || result?.availability?.status === "blocked") { + const availability = result.error?.providerStatus || result.availability?.status !== "blocked" + ? codeAgentAvailabilityFromResult(result) + : result.availability ?? codeAgentAvailabilityFromResult(result); + return codeAgentBlockedSummary(availability); + } + const missing = [ + ...(result.error?.missingCommands ?? []), + ...(result.error?.missingEnv ?? []) + ].filter(Boolean); + const suffix = missing.length ? ` 缺口:${missing.join("、")}。` : ""; + return `Code Agent 调用失败:${result.error?.message ?? "未知错误"}。${suffix}请稍后重试或联系维护者补齐后端 provider。`; +} + +function agentErrorFromHttpResponse(response, traceId) { + const payloadError = response.data?.error; + const code = response.timeout + ? "client_timeout" + : normalizeErrorCode( + (payloadError && typeof payloadError === "object" ? payloadError.code : null) ?? + (typeof payloadError === "string" ? payloadError : null) ?? + response.data?.code ?? + "request_failed" + ); + const error = new Error(response.timeout + ? `Code Agent 超过 ${response.timeoutMs}ms 无新事件;已保留输入,可稍后重试。` + : payloadError?.userMessage || response.error || "Code Agent 请求失败"); + error.traceId = response.data?.traceId || traceId; + error.code = code; + error.layer = payloadError && typeof payloadError === "object" ? payloadError.layer : undefined; + error.blocker = payloadError && typeof payloadError === "object" ? payloadError.blocker : undefined; + error.retryable = payloadError && typeof payloadError === "object" ? payloadError.retryable : response.timeout ? true : undefined; + error.userMessage = payloadError && typeof payloadError === "object" ? payloadError.userMessage : undefined; + error.missingConfig = payloadError && typeof payloadError === "object" ? payloadError.missingConfig : undefined; + error.route = payloadError && typeof payloadError === "object" ? payloadError.route : undefined; + error.toolName = payloadError && typeof payloadError === "object" ? payloadError.toolName : undefined; + error.timeoutMs = response.timeoutMs ?? (payloadError && typeof payloadError === "object" ? payloadError.timeoutMs : undefined); + error.hardTimeoutMs = payloadError && typeof payloadError === "object" ? payloadError.hardTimeoutMs : undefined; + error.idleMs = response.idleMs ?? (payloadError && typeof payloadError === "object" ? payloadError.idleMs : undefined); + error.lastActivityAt = response.lastActivityAt ?? (payloadError && typeof payloadError === "object" ? payloadError.lastActivityAt : undefined); + error.waitingFor = response.waitingFor ?? (payloadError && typeof payloadError === "object" ? payloadError.waitingFor : undefined); + error.providerStatus = payloadError && typeof payloadError === "object" ? payloadError.providerStatus : response.status; + error.category = payloadError && typeof payloadError === "object" ? payloadError.category : undefined; + return error; +} + +function agentFailurePresentation(error, { result = null, traceId = null } = {}) { + const code = normalizeErrorCode(error?.code); + const message = String(error?.message ?? result?.error?.message ?? "").trim(); + const structuredBlocker = error?.blocker ?? result?.error?.blocker ?? result?.blocker ?? null; + const userMessage = String(error?.userMessage ?? result?.error?.userMessage ?? structuredBlocker?.userMessage ?? "").trim(); + const observedTraceId = result?.traceId || error?.traceId || traceId || null; + const traceSuffix = observedTraceId ? ` trace=${observedTraceId}` : ""; + const timeoutMs = error?.timeoutMs ?? result?.error?.timeoutMs ?? timeoutMsFromText(message); + const hardTimeoutMs = error?.hardTimeoutMs ?? result?.error?.hardTimeoutMs; + const idleMs = error?.idleMs ?? result?.error?.idleMs; + const lastActivityAt = error?.lastActivityAt ?? result?.error?.lastActivityAt; + const waitingFor = error?.waitingFor ?? result?.error?.waitingFor; + const providerStatus = error?.providerStatus ?? result?.error?.providerStatus; + + if (code === "codex_stdio_hard_timeout") { + return { + category: "timeout", + title: "Code Agent 达到硬性上限", + text: `Code Agent 达到硬性总时长上限${hardTimeoutMs ? ` ${hardTimeoutMs}ms` : ""};输入已保留,可稍后重试或缩小问题范围。${traceSuffix}` + }; + } + + if (isTimeoutFailure(code, message)) { + const detail = [ + typeof idleMs === "number" ? `idleMs=${idleMs}` : null, + lastActivityAt ? `lastActivityAt=${lastActivityAt}` : null, + waitingFor ? `waitingFor=${waitingFor}` : null + ].filter(Boolean).join(";"); + return { + category: "timeout", + title: "Code Agent 无新事件超时", + text: `Code Agent ${timeoutMs ? `超过 ${timeoutMs}ms ` : ""}无新事件;输入已保留,可稍后重试或缩小问题范围。${detail ? ` ${detail}。` : ""}${traceSuffix}` + }; + } + + if (code === "codex_stdio_canceled" || code === "session_canceled") { + return { + category: "canceled", + title: "Code Agent 已取消", + text: `本次 Codex stdio 请求已取消;输入、sessionId 和 traceId 已保留,可重试上一条消息。${traceSuffix}` + }; + } + + if (userMessage) { + const category = error?.category ?? result?.error?.category ?? structuredBlocker?.category ?? "runner_blocked"; + return { + category, + title: titleForBlockerCategory(category, code), + text: `${userMessage}${traceSuffix}` + }; + } + + if (code === "session_busy") { + return { + category: "runner_busy", + title: "会话忙/请等待", + text: `Codex stdio runner 正在处理上一轮请求;输入已保留,请等待当前请求完成或取消后重试。${traceSuffix}` + }; + } + + if (code === "session_expired") { + return { + category: "session_blocked", + title: "会话已过期", + text: `当前 session 已过期;输入已保留,可重新发送建立新的 Codex stdio session。${traceSuffix}` + }; + } + + if (["session_reuse_conflict", "session_failed", "session_interrupted", "session_canceled", "session_timeout", "session_error"].includes(code)) { + return { + category: "session_blocked", + title: "Code Agent Session 受阻", + text: `当前 session 已过期、失败、中断或与 conversation 绑定不一致;输入已保留,可重新发送建立新的 Codex stdio session。${traceSuffix}` + }; + } + + if ([ + "runner_unavailable", + "tool_unavailable", + "skills_unavailable", + "security_blocked", + "codex_stdio_blocked", + "codex_stdio_failed", + "codex_stdio_protocol_blocked", + "codex_stdio_empty_response", + "codex_stdio_command_probe_failed" + ].includes(code)) { + return { + category: "runner_blocked", + title: "Code Agent Runner 受阻", + text: `Codex stdio runner 返回 ${code};本次不会 fallback 冒充 Codex session。${message ? `原因:${message}。` : ""}${traceSuffix}` + }; + } + + if (["invalid_params", "parse_error", "request_failed", "cloud_api_proxy_failed", "upstream_unavailable"].includes(code)) { + return { + category: "api_error", + title: "Code Agent API 错误", + text: `Code Agent API 请求失败;输入已保留,可稍后重试。${message ? `原因:${message}。` : ""}${traceSuffix}` + }; + } + + if (code === "provider_unavailable" || providerStatus) { + return { + category: "provider", + title: "Code Agent Provider 不可用", + text: providerStatus + ? `Code Agent provider 返回 HTTP ${providerStatus};本次未生成回复,输入已保留,可稍后重试。${traceSuffix}` + : `Code Agent provider 暂不可用;本次未生成回复,输入已保留,可稍后重试。${traceSuffix}` + }; + } + + return { + category: "unknown", + title: "Code Agent 返回失败", + text: `Code Agent 调用失败:${message || "未知错误"}。请稍后重试或联系维护者补齐后端 provider。${traceSuffix}` + }; +} + +function titleForBlockerCategory(category, code) { + const value = String(category ?? "").trim(); + if (value === "needs_config") return "Code Agent 需要配置"; + if (value === "security_blocked" || code === "security_blocked") return "Code Agent 安全阻断"; + if (value === "fallback" || code === "text_chat_only_fallback") return "Code Agent 仍是 fallback"; + if (value === "timeout") return "Code Agent 无新事件超时"; + if (value === "canceled" || code === "codex_stdio_canceled") return "Code Agent 已取消"; + if (value === "runner_busy") return "Code Agent Runner 忙碌"; + if (value === "session_blocked") return "Code Agent Session 受阻"; + if (value === "runner_blocked") return "Code Agent Runner 受阻"; + if (value === "api_error") return "Code Agent API 错误"; + if (value === "capability_unavailable") return "Code Agent 能力未开放"; + if (value === "retryable") return "Code Agent 可重试"; + return "Code Agent 服务受阻"; +} + +function normalizeErrorCode(code) { + const normalized = String(code ?? "").trim(); + return normalized || "code_agent_failed"; +} + +function isTimeoutFailure(code, message) { + return ["client_timeout", "proxy_timeout", "cloud_api_proxy_timeout", "codex_stdio_timeout", "codex_stdio_idle_timeout", "codex_stdio_hard_timeout"].includes(code) || + /\b(?:timeout|timed out|abort|aborted|超时|请求超过|无新事件)\b/iu.test(message); +} + +function isCodeAgentTimeoutError(error, fallbackMessage = "") { + const code = normalizeErrorCode(error?.code); + const message = [error?.message, error?.userMessage, fallbackMessage] + .filter(Boolean) + .join(" "); + return isTimeoutFailure(code, message); +} + +function timeoutMsFromText(message) { + const match = String(message ?? "").match(/(?:timed out after|timeout after|请求超时|请求超过|超过)\s*(\d+)\s*ms/iu); + if (!match) return null; + const value = Number.parseInt(match[1], 10); + return Number.isInteger(value) && value > 0 ? value : null; +} + +function messageCard(message) { + const article = document.createElement("article"); + article.className = `message-card message-${toneClass(message.role)} status-${toneClass(message.status)}`; + article.append(textSpan(roleLabel(message.role), "message-role")); + article.append(textSpan(message.title, "message-title")); + article.append(messageCopyNode(message)); + const pendingContext = messagePendingContextPanel(message); + if (pendingContext) article.append(pendingContext); + const sessionContext = messageSessionContinuityPanel(message); + if (sessionContext) article.append(sessionContext); + const runtimePath = messageRuntimePathPanel(message); + if (runtimePath) article.append(runtimePath); + const tracePanel = messageTracePanel(message); + if (tracePanel) article.append(tracePanel); + const actions = messageActionsPanel(message); + if (actions) article.append(actions); + return article; +} + +function messageCopyNode(message) { + if (!shouldRenderMessageMarkdown(message)) { + return textSpan(message.text, "message-copy"); + } + const node = document.createElement("div"); + node.className = "message-copy message-copy-markdown"; + const markdown = String(message.text ?? ""); + try { + node.innerHTML = renderMessageMarkdown(markdown); + } catch (error) { + node.textContent = markdown || `Markdown 渲染失败:${error.message}`; + } + hardenRenderedMarkdown(node); + return node; +} + +function shouldRenderMessageMarkdown(message) { + return message?.role === "agent" && MESSAGE_MARKDOWN_STATUSES.includes(String(message.status ?? "").toLowerCase()); +} + +function messageActionsPanel(message) { + if (message.role !== "agent") return null; + const actions = []; + if (message.status === "running") { + actions.push(actionButton("取消当前请求", "cancel", () => cancelAgentMessage(message.id), "取消当前 in-flight Codex stdio 请求")); + if (message.retryInput) { + actions.push(actionButton("重试上一条", "retry", () => restartRunningAgentMessage(message.id), "先取消当前 trace,再用同一输入重新发送")); + } + } + if (canRetryTerminalAgentMessage(message)) { + actions.push(actionButton("重试上一条", "retry", () => retryAgentMessage(message.id), "保留 conversation/trace 记录并重新发送上一条输入")); + } + if (message.traceId) { + actions.push(actionButton("回放 trace", "trace", () => replayAgentTrace(message.id), "从 runnerTrace store 重新读取真实事件")); + } + if (actions.length === 0 && !message.traceReplayStatus) return null; + const panel = document.createElement("div"); + panel.className = "message-actions"; + panel.append(...actions); + if (message.traceReplayStatus) { + panel.append(textSpan(message.traceReplayStatus, "message-action-status")); + } + return panel; +} + +function actionButton(label, action, onClick, title) { + const button = document.createElement("button"); + button.type = "button"; + button.className = `message-action message-action-${action}`; + button.textContent = label; + button.title = title; + button.addEventListener("click", onClick); + return button; +} + +function canRetryTerminalAgentMessage(message) { + return Boolean( + message?.retryInput && + ["failed", "timeout", "error", "canceled"].includes(String(message.status ?? "").toLowerCase()) + ); +} + +async function restartRunningAgentMessage(messageId) { + const message = state.chatMessages.find((item) => item.id === messageId); + if (!message?.retryInput || message.status !== "running") return; + await cancelAgentMessage(messageId); + const canceled = state.chatMessages.find((item) => item.id === messageId); + if (canceled?.status !== "canceled" || state.chatPending) { + setAgentActionStatus(messageId, "取消未确认,当前 trace 仍在等待后端;已保留输入,可等待真实超时或后端结果后再重试。"); + return; + } + await submitAgentMessage(message.retryInput, { + conversationId: canceled.conversationId || state.conversationId || undefined, + sessionId: isTerminalSessionStatus(canceled.session?.status ?? canceled.runnerTrace?.sessionStatus ?? canceled.status) + ? undefined + : canceled.sessionId, + threadId: isTerminalSessionStatus(canceled.session?.status ?? canceled.runnerTrace?.sessionStatus ?? canceled.status) + ? undefined + : canceled.threadId ?? threadIdFrom(canceled), + retryOf: canceled.traceId + }); +} + +async function retryAgentMessage(messageId) { + const message = state.chatMessages.find((item) => item.id === messageId); + if (!message?.retryInput) return; + if (state.chatPending) { + setAgentActionStatus(messageId, "当前仍有请求处理中;请先取消当前请求,或等待真实超时/后端结果后再重试。"); + return; + } + await submitAgentMessage(message.retryInput, { + conversationId: message.conversationId || state.conversationId || undefined, + sessionId: isTerminalSessionStatus(message.session?.status ?? message.runnerTrace?.sessionStatus ?? message.status) + ? undefined + : message.sessionId, + threadId: isTerminalSessionStatus(message.session?.status ?? message.runnerTrace?.sessionStatus ?? message.status) + ? undefined + : message.threadId ?? threadIdFrom(message), + retryOf: message.traceId + }); +} + +function setAgentActionStatus(messageId, text) { + const index = state.chatMessages.findIndex((message) => message.id === messageId); + if (index < 0) return; + state.chatMessages[index] = { + ...state.chatMessages[index], + traceReplayStatus: text, + updatedAt: new Date().toISOString() + }; + renderConversation(); +} + +async function cancelAgentMessage(messageId) { + const index = state.chatMessages.findIndex((message) => message.id === messageId); + if (index < 0) return; + const message = state.chatMessages[index]; + const traceId = message.traceId; + const sessionId = message.sessionId || message.runnerTrace?.sessionId || state.currentRequest?.sessionId || state.sessionId; + const threadId = message.threadId || message.runnerTrace?.threadId || state.currentRequest?.threadId || state.threadId; + const response = await cancelAgentRequest({ + traceId, + conversationId: message.conversationId || state.conversationId, + sessionId, + threadId + }); + const payload = response.data ?? {}; + const runnerTrace = payload.runnerTrace ?? latestTraceSnapshot(traceId); + if (response.ok && payload.canceled === true) { + state.canceledTraces.add(traceId); + stopRunningTraceStream(traceId); + state.chatPending = false; + state.currentRequest = null; + state.sessionId = payload.sessionId || sessionId || state.sessionId; + state.sessionStatus = payload.session?.status ?? runnerTrace?.sessionStatus ?? "canceled"; + state.chatMessages[index] = { + ...message, + title: "Code Agent 已取消", + text: payload.userMessage || "当前 Codex stdio 请求已取消;输入、sessionId、traceId 和最后 trace event 已保留,可重试上一条消息。", + status: "canceled", + sessionId: payload.sessionId || sessionId || message.sessionId, + threadId: payload.threadId || threadId || message.threadId, + sessionContinuity: failedSessionContinuity(message.sessionContinuity, { code: "codex_stdio_canceled" }), + session: payload.session ?? message.session, + runnerTrace, + traceReplayStatus: lastTraceEventLabel(runnerTrace), + updatedAt: new Date().toISOString(), + error: payload.error ?? { + code: "codex_stdio_canceled", + category: "canceled", + retryable: true, + message: "user canceled current Code Agent request" + } + }; + if (message.retryInput) el.commandInput.value = message.retryInput; + } else { + state.chatMessages[index] = { + ...message, + title: "Code Agent 取消受阻", + text: payload.error?.userMessage || response.error || "当前请求没有可取消的 in-flight Codex stdio session;输入和 trace 已保留。", + status: "failed", + threadId, + sessionContinuity: failedSessionContinuity(message.sessionContinuity, { code: "cancel_blocked" }), + runnerTrace, + traceReplayStatus: lastTraceEventLabel(runnerTrace), + updatedAt: new Date().toISOString(), + error: payload.error ?? { + code: "cancel_blocked", + category: "cancel_blocked", + retryable: true, + message: response.error || "cancel blocked" + } + }; + if (message.retryInput) el.commandInput.value = message.retryInput; + } + renderAgentChatStatus(state.chatMessages[index].status, state.chatMessages[index]); + renderCodeAgentSummary(); + renderConversation(); + renderDrafts(); + renderDevicePodPanel(state.liveSurface); +} + +function stopRunningTraceStream(traceId) { + const id = nonEmptyString(traceId); + if (!id) return; + const close = state.traceStreams.get(id); + if (typeof close === "function") close(); + state.traceStreams.delete(id); +} + +async function cancelAgentRequest({ traceId, conversationId, sessionId, threadId }) { + return fetchJson("/v1/agent/chat/cancel", { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Trace-Id": traceId, + "Prefer": "respond-async" + }, + timeoutMs: CODE_AGENT_CANCEL_TIMEOUT_MS, + timeoutName: "Code Agent cancel", + body: JSON.stringify({ + traceId, + conversationId, + sessionId, + threadId + }) + }); +} + +async function replayAgentTrace(messageId) { + const index = state.chatMessages.findIndex((message) => message.id === messageId); + if (index < 0) return; + const message = state.chatMessages[index]; + if (!message.traceId) return; + const response = await fetchJson(`/v1/agent/chat/trace/${encodeURIComponent(message.traceId)}`, { + timeoutMs: Math.min(Math.max(API_TIMEOUT_MS, 5000), FULL_TRACE_REPLAY_TIMEOUT_MS), + timeoutName: "Code Agent trace replay" + }); + if (response.ok) { + const runnerTrace = runnerTraceFromSnapshot({ ...response.data, fullTraceLoaded: true }, message.runnerTrace); + state.chatMessages[index] = { + ...message, + runnerTrace, + threadId: runnerTrace.threadId ?? message.threadId, + traceReplayStatus: `完整 trace 已回放:${runnerTrace.events?.length ?? 0} 个原始 event;${lastTraceEventLabel(runnerTrace)}`, + updatedAt: response.data?.updatedAt ?? new Date().toISOString() + }; + if (shouldReconcileCodeAgentResult(state.chatMessages[index], runnerTrace)) { + reconcileCodeAgentResult(message.id, { quiet: true }); + } + } else { + state.chatMessages[index] = { + ...message, + traceReplayStatus: response.error || "trace 回放失败", + updatedAt: new Date().toISOString() + }; + } + renderCodeAgentSummary(); + renderConversation(); + renderDevicePodPanel(state.liveSurface); +} + +function maybeReplayFullTraceForMessage(message) { + if (!message || !message.traceId || message.runnerTrace?.eventsCompacted !== true) return; + if (state.fullTraceReplayInFlight.has(message.traceId)) return; + replayFullTrace(message.id, { quiet: true }); +} + +async function replayFullTrace(messageId, options = {}) { + const index = state.chatMessages.findIndex((message) => message.id === messageId); + if (index < 0) return false; + const message = state.chatMessages[index]; + if (!message.traceId) return false; + if (state.fullTraceReplayInFlight.has(message.traceId)) return false; + state.fullTraceReplayInFlight.add(message.traceId); + try { + const response = await fetchJson(`/v1/agent/chat/trace/${encodeURIComponent(message.traceId)}`, { + timeoutMs: Math.min(Math.max(API_TIMEOUT_MS, 5000), FULL_TRACE_REPLAY_TIMEOUT_MS), + timeoutName: "Code Agent full trace replay" + }); + if (!response.ok) return false; + const currentIndex = state.chatMessages.findIndex((item) => item.id === messageId); + if (currentIndex < 0) return false; + const current = state.chatMessages[currentIndex]; + const runnerTrace = runnerTraceFromSnapshot({ ...response.data, fullTraceLoaded: true }, current.runnerTrace); + state.chatMessages[currentIndex] = { + ...current, + runnerTrace, + threadId: runnerTrace.threadId ?? current.threadId, + traceReplayStatus: `完整 trace 已回放:${runnerTrace.events?.length ?? 0} 个原始 event;${lastTraceEventLabel(runnerTrace)}`, + updatedAt: response.data?.updatedAt ?? new Date().toISOString() + }; + if (shouldReconcileCodeAgentResult(state.chatMessages[currentIndex], runnerTrace)) { + reconcileCodeAgentResult(messageId, { quiet: true }); + } + if (options.quiet !== true) { + renderCodeAgentSummary(); + renderConversation(); + renderDevicePodPanel(state.liveSurface); + } else { + renderConversation(); + } + return true; + } finally { + state.fullTraceReplayInFlight.delete(message.traceId); + } +} + +function lastTraceEventLabel(runnerTrace) { + const event = runnerTrace?.lastEvent ?? (Array.isArray(runnerTrace?.events) ? runnerTrace.events.at(-1) : null); + if (!event) return "lastEvent=none"; + return `lastEvent=${event.label ?? `${event.type ?? "event"}:${event.status ?? "observed"}`}`; +} + +function messageSessionContinuityPanel(message) { + if (message.role !== "agent" || message.status === "running") return null; + const continuity = message.sessionContinuity ?? completedSessionContinuity(message, pendingSessionContinuity({ + conversationId: message.conversationId, + sessionId: message.sessionId, + threadId: message.threadId, + retryOf: message.retryOf + })); + if (!continuity) return null; + const fields = [ + ["状态", continuity.label], + ["conversation", continuity.returned?.conversationId ?? message.conversationId], + ["session", continuity.returned?.sessionId ?? message.sessionId ?? "未确认"], + ["thread", continuity.returned?.threadId ?? message.threadId ?? "未确认"], + ["lifecycle", message.sessionLifecycleStatus ?? message.sessionSummary?.status ?? message.session?.lifecycleStatus], + ["提示", message.sessionSummary?.userMessage ?? message.sessionLifecycle?.userMessage], + ["requestedSession", continuity.requested?.sessionId], + ["requestedThread", continuity.requested?.threadId], + ["retryOf", continuity.requested?.retryOf ?? message.retryOf], + ["missing", continuity.missing?.join("、")], + ["changed", continuity.changed?.join("、")] + ]; + const list = document.createElement("div"); + list.className = "message-session-grid"; + for (const [label, value] of fields) { + if (value === undefined || value === null || value === "") continue; + const item = document.createElement("div"); + item.className = "message-session-row"; + item.append(textSpan(label, "message-session-key"), textSpan(value, "message-session-value mono")); + if (label === "conversation" || label === "session" || label === "thread") { + item.append(copyButton(value, label)); + } + list.append(item); + } + return messageCompactDetails({ + className: "message-session-context", + tone: continuity.tone, + badgeLabel: continuity.label, + badgeTone: continuity.tone, + summary: continuity.summary, + dialogTitle: "会话明细", + body: list + }); +} + +function messagePendingContextPanel(message) { + if (message.status !== "running") return null; + const continuity = message.sessionContinuity ?? pendingSessionContinuity({ + conversationId: message.conversationId, + sessionId: message.sessionId, + threadId: message.threadId, + retryOf: message.retryOf + }); + const fields = [ + ["状态", continuity.label], + ["traceId", message.traceId], + ["conversation", message.conversationId], + ["session", message.sessionId ?? message.runnerTrace?.sessionId ?? "等待后端分配"], + ["thread", message.threadId ?? message.runnerTrace?.threadId ?? "等待后端分配"], + ["sessionStatus", message.runnerTrace?.sessionStatus], + ["lifecycle", message.runnerTrace?.sessionLifecycleStatus], + ["activityTimeout", `${formatTraceDuration(CODE_AGENT_TIMEOUT_MS)} 无新事件`] + ]; + const section = document.createElement("section"); + section.className = "message-pending-context tone-border-pending"; + const header = document.createElement("div"); + header.className = "message-pending-head"; + header.append(badge("处理中", "pending"), textSpan(continuity.summary, "message-pending-summary")); + const list = document.createElement("div"); + list.className = "message-pending-grid"; + for (const [label, value] of fields) { + if (value === undefined || value === null || value === "") continue; + const item = document.createElement("div"); + item.className = "message-pending-row"; + item.append(textSpan(label, "message-pending-key"), textSpan(value, "message-pending-value mono")); + if (label === "traceId" || label === "conversation" || label === "session") { + item.append(copyButton(value, label)); + } + list.append(item); + } + section.append(header, list); + return section; +} + + +function messageRuntimePathPanel(message) { + if (message.role !== "agent") return null; + const runtimePath = codeAgentRuntimePathFromMessage(message); + if (!runtimePath) return null; + const rows = document.createElement("div"); + rows.className = "message-runtime-grid"; + for (const row of runtimePath.rows) { + const item = document.createElement("div"); + item.className = `message-runtime-row${row.missing ? " message-runtime-row-missing" : ""}`; + item.append(textSpan(row.label, "message-runtime-key"), textSpan(row.value, "message-runtime-value mono")); + if (!row.missing && (row.label === "providerTrace.command" || row.label === "providerTrace.terminalStatus" || row.label === "protocol")) { + item.append(copyButton(row.value, row.label)); + } + rows.append(item); + } + if (runtimePath.missingFields.length > 0) { + const item = document.createElement("div"); + item.className = "message-runtime-row message-runtime-row-missing message-runtime-row-wide"; + item.append( + textSpan("缺失字段", "message-runtime-key"), + textSpan(runtimePath.missingFields.join(", "), "message-runtime-value mono") + ); + rows.append(item); + } + const details = messageCompactDetails({ + className: "message-runtime-path", + tone: runtimePath.tone, + badgeLabel: runtimePath.label, + badgeTone: runtimePath.tone, + summary: runtimePath.summary, + dialogTitle: "运行路径明细", + body: rows + }); + details.dataset.runtimePathKind = runtimePath.kind; + return details; +} + +function messageCompactDetails({ className, tone, badgeLabel, badgeTone, summary, dialogTitle, body }) { + const section = document.createElement("section"); + section.className = `${className} message-compact-details tone-border-${toneClass(tone)}`; + const trigger = document.createElement("button"); + trigger.type = "button"; + trigger.className = "message-compact-summary"; + trigger.setAttribute("aria-haspopup", "dialog"); + trigger.append( + badge(badgeLabel, badgeTone), + textSpan(summary, "message-compact-summary-text") + ); + trigger.addEventListener("click", () => { + openWorkbenchDialog({ + title: dialogTitle, + body, + restoreFocus: trigger + }); + }); + section.append(trigger); + return section; +} + +function boundedJson(value, maxLength = 6000) { + let text = ""; + try { + text = JSON.stringify(value, null, 2); + } catch { + text = String(value ?? ""); + } + return text.length > maxLength ? `${text.slice(0, maxLength)}\n...字段已截断` : text; +} + +function messageTracePanel(message) { + const trace = message.runnerTrace && typeof message.runnerTrace === "object" ? message.runnerTrace : null; + if (!trace && !message.traceId) return null; + const details = document.createElement("details"); + details.className = "message-trace"; + const traceUiKey = messageTraceUiKey(message); + if (traceUiKey) details.dataset.traceUiKey = traceUiKey; + const storedOpen = traceUiKey ? state.traceDetailsOpen.get(traceUiKey) : undefined; + details.open = typeof storedOpen === "boolean" ? storedOpen : defaultTraceDetailsOpen(message); + if (traceUiKey) { + details.addEventListener("toggle", () => { + state.traceDetailsOpen.set(traceUiKey, details.open); + }); + } + const summary = document.createElement("summary"); + summary.className = "message-meta"; + summary.textContent = runnerTraceHeadline(message, trace); + const list = document.createElement("ol"); + list.className = "message-trace-events"; + if (traceUiKey) { + list.dataset.traceUiKey = traceUiKey; + for (const eventName of ["wheel", "touchstart", "pointerdown"]) { + list.addEventListener(eventName, () => markTraceScrollIntent(traceUiKey), { passive: true }); + } + list.addEventListener("keydown", (event) => { + if (isScrollIntentKey(event.key)) markTraceScrollIntent(traceUiKey); + }); + list.addEventListener("scroll", () => rememberTraceScrollPosition(traceUiKey, list), { passive: true }); + } + const events = Array.isArray(trace?.events) ? trace.events : []; + const rows = traceDisplayRows(trace, events); + if (events.length === 0) { + const item = document.createElement("li"); + item.textContent = `trace=${message.traceId ?? "pending"};等待后端事件。`; + list.append(item); + } else { + const toolbar = messageTraceToolbar(message, trace, events, rows); + list.dataset.traceMode = "all"; + renderTraceEventList(list, rows); + details.append(summary, toolbar, list); + return details; + } + details.append(summary, list); + return details; +} + +function messageTraceUiKey(message) { + const traceId = nonEmptyString(message.traceId); + const messageId = nonEmptyString(message.id ?? message.messageId); + if (!traceId && !messageId) return null; + return `${messageId ?? "message"}:${traceId ?? "trace"}`; +} + +function defaultTraceDetailsOpen(message) { + return ["running", "completed", "source", "failed", "timeout", "canceled", "error"].includes(message.status); +} + +function messageTraceToolbar(message, trace, events, rows) { + const toolbar = document.createElement("div"); + toolbar.className = "message-trace-toolbar"; + const rawTotal = Number.isInteger(trace?.eventCount) ? trace.eventCount : events.length; + const count = textSpan(messageTraceCountText(trace, rawTotal, events.length, rows.length), "message-trace-count"); + toolbar.append(count); + toolbar.append(traceActionButton("复制 JSON", () => copyTextToClipboard(messageTraceJson(message, trace, events)), "复制完整 trace JSON")); + toolbar.append(traceActionButton("下载 trace", () => downloadTraceJson(message, trace, events), "下载完整 trace JSON 文件")); + return toolbar; +} + +function messageTraceCountText(trace, rawTotal, loadedTotal, readableTotal) { + if (trace?.eventsCompacted === true && trace?.fullTraceLoaded !== true) { + return `完整 trace 回放中 / 当前可读事件 ${readableTotal} / 已载入原始 ${loadedTotal} / 后端原始 ${rawTotal}`; + } + return `显示全部可读事件 ${readableTotal} / 已载入原始 ${loadedTotal} / 后端原始 ${rawTotal}`; +} + +function renderTraceEventList(list, rows) { + patchTraceEventList(list, rows.map((row, index) => traceEventRowNode(row, index))); +} + +function patchTraceEventList(list, nextItems) { + const currentById = new Map(); + for (const current of [...list.children]) { + const rowId = current.dataset.traceRowId; + if (rowId && !currentById.has(rowId)) currentById.set(rowId, current); + } + const retained = new Set(); + for (let index = 0; index < nextItems.length; index += 1) { + const next = nextItems[index]; + const rowId = next.dataset.traceRowId; + const current = rowId ? currentById.get(rowId) : null; + const item = current && !retained.has(current) ? current : next; + if (item === current) patchTraceRowElement(current, next); + const before = list.children[index] ?? null; + if (before !== item) list.insertBefore(item, before); + retained.add(item); + } + for (const current of [...list.children]) { + if (!retained.has(current)) current.remove(); + } +} + +function patchTraceRowElement(current, next) { + current.className = next.className; + current.dataset.traceRowKey = next.dataset.traceRowKey ?? ""; + current.dataset.traceRowId = next.dataset.traceRowId ?? ""; + const currentHeader = current.querySelector(".message-trace-line"); + const nextHeader = next.querySelector(".message-trace-line"); + if (currentHeader && nextHeader) { + currentHeader.className = nextHeader.className; + if (currentHeader.textContent !== nextHeader.textContent) currentHeader.textContent = nextHeader.textContent; + } else if (!currentHeader && nextHeader) { + current.prepend(nextHeader); + } else if (currentHeader && !nextHeader) { + currentHeader.remove(); + } + const currentBody = current.querySelector(".message-trace-body"); + const nextBody = next.querySelector(".message-trace-body"); + if (currentBody && nextBody) { + patchTraceBodyElement(currentBody, nextBody); + } else if (!currentBody && nextBody) { + current.append(nextBody); + } else if (currentBody && !nextBody) { + currentBody.remove(); + } +} + +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; + } + restoreElementScrollPosition(currentBody, position); + rememberTraceBodyScrollPosition(currentBody); +} + +function traceEventRowNode(row, index = 0) { + const item = document.createElement("li"); + item.className = `message-trace-row tone-border-${toneClass(row.tone)}`; + const rowId = traceRowId(row, index); + item.dataset.traceRowId = rowId; + item.dataset.traceRowKey = traceRowKey(row); + const header = document.createElement("div"); + header.className = "message-trace-line"; + header.textContent = row.header; + item.append(header); + if (row.body) { + const body = document.createElement("pre"); + body.className = "message-trace-body"; + body.dataset.traceBodyId = rowId; + installTraceBodyScrollListeners(body); + body.textContent = row.body; + item.append(body); + } + return item; +} + +function installTraceBodyScrollListeners(body) { + for (const eventName of ["wheel", "touchstart", "pointerdown"]) { + body.addEventListener(eventName, () => markTraceBodyScrollIntent(body), { passive: true }); + } + body.addEventListener("keydown", (event) => { + if (isScrollIntentKey(event.key)) markTraceBodyScrollIntent(body); + }); + body.addEventListener("scroll", () => rememberTraceBodyScrollPosition(body), { passive: true }); +} + +function traceRowId(row, index = 0) { + if (row?.rowId) return String(row.rowId); + if (row?.seq !== null && row?.seq !== undefined) return `event:${row.seq}`; + return `row:${index}:${traceRowKey(row)}`; +} + +function traceRowKey(row) { + return `${row.header}\n${row.body ?? ""}\n${row.tone ?? ""}`; +} diff --git a/web/hwlab-cloud-web/app-device-pod.ts b/web/hwlab-cloud-web/app-device-pod.ts new file mode 100644 index 00000000..9841632b --- /dev/null +++ b/web/hwlab-cloud-web/app-device-pod.ts @@ -0,0 +1,1515 @@ +function initDevicePodPanel() { + el.devicePodSelect.addEventListener("change", () => { + state.devicePod.selectedDevicePodId = el.devicePodSelect.value || "device-pod-71-freq"; + loadLiveSurface().then(renderLiveSurface); + }); + el.deviceEventFollow.addEventListener("click", () => { + setDeviceEventFollow(!state.devicePod.followEvents, { scroll: true }); + }); + el.deviceEventJump.addEventListener("click", () => { + state.devicePod.unreadEvents = 0; + setDeviceEventFollow(true); + renderDeviceEventUnreadHint(); + scrollDeviceEventsToBottom(); + }); + for (const eventName of ["wheel", "touchstart", "pointerdown"]) { + el.deviceEventScroll.addEventListener(eventName, markDeviceEventScrollIntent, { passive: true }); + } + el.deviceEventScroll.addEventListener("keydown", (event) => { + if (isScrollIntentKey(event.key)) markDeviceEventScrollIntent(); + }); + el.deviceEventScroll.addEventListener("scroll", () => { + if (!deviceEventNearBottom()) setDeviceEventFollow(false); + }, { passive: true }); + el.devicePodSummary.addEventListener("click", (event) => { + const tile = event.target.closest("[data-device-detail]"); + if (!tile) return; + showDeviceDetail(tile.dataset.deviceTitle || "Pod Summary", state.devicePod.details.get("pod")); + }); + el.devicePodSummary.addEventListener("keydown", (event) => { + if (event.key !== "Enter" && event.key !== " ") return; + const tile = event.target.closest("[data-device-detail]"); + if (!tile) return; + event.preventDefault(); + showDeviceDetail(tile.dataset.deviceTitle || "Pod Summary", state.devicePod.details.get("pod")); + }); + el.devicePodInterfaces.addEventListener("click", (event) => { + const tile = event.target.closest("[data-device-detail]"); + if (!tile) return; + showDeviceDetail(tile.dataset.deviceTitle || tile.dataset.deviceDetail || "Device Pod", state.devicePod.details.get(tile.dataset.deviceDetail)); + }); +} + +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(); + const value = el.commandInput.value.trim(); + await submitAgentMessage(value); + }); + + el.commandClear.addEventListener("click", () => { + for (const close of state.traceStreams.values()) close(); + state.traceStreams.clear(); + state.traceDetailsOpen.clear(); + state.conversationRenderVersion += 1; + state.conversationScrollUserActiveUntil = 0; + state.conversationScrollPosition = { top: 0, left: 0 }; + state.traceScrollPositions.clear(); + state.traceScrollUserActiveUntil.clear(); + state.traceProgrammaticScrollWrites = 0; + state.fullTraceReplayInFlight.clear(); + state.chatMessages = []; + state.conversationId = null; + state.sessionId = null; + state.threadId = null; + state.sessionStatus = null; + state.currentRequest = null; + state.canceledTraces.clear(); + state.chatPending = false; + clearCodeAgentSessionState(); + el.commandInput.value = ""; + renderAgentChatStatus("idle"); + renderCodeAgentSummary(); + renderConversation(); + renderDrafts(); + renderDevicePodPanel(state.liveSurface); + }); +} + +async function submitAgentMessage(value, options = {}) { + if (!value || state.chatPending) return; + const traceId = options.traceId ?? nextProtocolId("trc"); + const activeConversationId = options.conversationId ?? state.conversationId ?? nextProtocolId("cnv"); + const requestedSessionId = options.sessionId === undefined ? sessionIdForNextRequest() : options.sessionId; + const requestedThreadId = options.threadId === undefined ? threadIdForNextRequest() : options.threadId; + const pendingContinuity = pendingSessionContinuity({ + conversationId: activeConversationId, + sessionId: requestedSessionId, + threadId: requestedThreadId, + retryOf: options.retryOf + }); + state.conversationId = activeConversationId; + const userMessage = { + id: nextProtocolId("msg"), + role: "user", + title: `${options.retryOf ? "重试" : "用户"} ${shortTime(new Date().toISOString())}`, + text: value, + status: "sent", + traceId, + conversationId: activeConversationId, + sessionId: requestedSessionId, + threadId: requestedThreadId, + retryOf: options.retryOf ?? null, + createdAt: new Date().toISOString() + }; + const pendingMessage = { + id: nextProtocolId("msg"), + role: "agent", + title: options.retryOf ? "Code Agent 重试中" : "Code Agent 处理中", + text: `正在处理这次 Code Agent 请求;复杂问题可能需要几分钟。页面会保留 trace/session 并等待后端返回成功、结构化 blocker 或真实超时;不会把旧 4500ms 轻量探测窗口当作最终失败。`, + status: "running", + traceId, + conversationId: activeConversationId, + sessionId: requestedSessionId, + threadId: requestedThreadId, + sessionContinuity: pendingContinuity, + retryInput: value, + retryOf: options.retryOf ?? null, + sourceKind: "PENDING", + createdAt: new Date().toISOString() + }; + state.chatMessages.push(userMessage, pendingMessage); + state.chatPending = true; + state.currentRequest = { + traceId, + conversationId: activeConversationId, + sessionId: requestedSessionId, + threadId: requestedThreadId, + messageId: pendingMessage.id, + input: value, + lastActivityAt: Date.now(), + lastActivityIso: new Date().toISOString(), + traceEventCount: 0, + waitingFor: "agent/chat", + lastEventLabel: "request:submitted" + }; + el.commandInput.value = ""; + renderAgentChatStatus("running"); + renderCodeAgentSummary(); + renderConversation(); + renderDrafts(); + renderDevicePodPanel(state.liveSurface); + + const stopTraceStream = subscribeRunnerTrace(traceId, pendingMessage.id); + + try { + const result = await sendAgentMessage(value, activeConversationId, traceId, requestedSessionId, requestedThreadId); + stopTraceStream(); + if (state.canceledTraces.has(traceId)) return; + const index = state.chatMessages.findIndex((message) => message.id === pendingMessage.id); + const updatedMessage = applyCodeAgentResultToMessage(pendingMessage.id, result, { + baseMessage: pendingMessage, + pendingContinuity, + requestedThreadId, + retryInput: value, + retryOf: options.retryOf ?? null + }); + if (result.availability) { + state.codeAgentAvailability = result.availability; + } + if (!["completed", "source"].includes(updatedMessage?.status)) { + el.commandInput.value = value; + } + renderAgentChatStatus(updatedMessage?.status, state.chatMessages[index]); + renderCodeAgentSummary(); + } catch (error) { + stopTraceStream(); + if (state.canceledTraces.has(traceId)) return; + const index = state.chatMessages.findIndex((message) => message.id === pendingMessage.id); + const presentation = agentFailurePresentation(error, { traceId }); + const failedStatus = errorStatusFromPresentation(presentation, error); + state.chatMessages[index] = { + ...pendingMessage, + title: presentation.title, + text: presentation.text, + status: failedStatus, + traceId: error.traceId || traceId, + threadId: requestedThreadId, + sessionContinuity: failedSessionContinuity(pendingContinuity, error), + updatedAt: new Date().toISOString(), + retryInput: value, + error: { + code: error.code || "request_failed", + category: presentation.category, + layer: error.layer, + blocker: error.blocker, + retryable: error.retryable, + userMessage: error.userMessage, + message: error.message, + timeoutMs: error.timeoutMs, + hardTimeoutMs: error.hardTimeoutMs, + idleMs: error.idleMs, + lastActivityAt: error.lastActivityAt, + waitingFor: error.waitingFor, + providerStatus: error.providerStatus, + missingConfig: error.missingConfig, + route: error.route, + toolName: error.toolName + }, + runnerTrace: latestTraceSnapshot(traceId) + }; + state.sessionStatus = state.chatMessages[index].runnerTrace?.sessionStatus ?? failedStatus; + el.commandInput.value = value; + renderAgentChatStatus(failedStatus, state.chatMessages[index]); + renderCodeAgentSummary(); + } finally { + if (state.currentRequest?.traceId === traceId) { + state.currentRequest = null; + state.chatPending = false; + } else if (!state.currentRequest) { + state.chatPending = false; + } + stopTraceStream(); + renderCodeAgentSummary(); + renderConversation(); + renderDrafts(); + renderDevicePodPanel(state.liveSurface); + } +} + +function sessionIdForNextRequest() { + return isTerminalSessionStatus(state.sessionStatus) ? undefined : state.sessionId; +} + +function threadIdForNextRequest() { + return isTerminalSessionStatus(state.sessionStatus) ? undefined : state.threadId; +} + +function isTerminalSessionStatus(status) { + return ["failed", "interrupted", "timeout", "error", "canceled", "expired"].includes(String(status ?? "").toLowerCase()); +} + +function applyCodeAgentResultToMessage(messageId, result, options = {}) { + const index = state.chatMessages.findIndex((message) => message.id === messageId); + if (index < 0 || !result || typeof result !== "object") return null; + const current = options.baseMessage ?? state.chatMessages[index]; + state.conversationId = result.conversationId || result.sessionId || state.conversationId; + state.sessionId = result.sessionId || result.session?.sessionId || state.sessionId || state.conversationId; + const resultThreadId = threadIdFrom(result); + if (resultThreadId) state.threadId = resultThreadId; + state.sessionStatus = result.sessionLifecycleStatus ?? result.sessionSummary?.status ?? result.session?.lifecycleStatus ?? result.session?.status ?? result.runnerTrace?.sessionLifecycleStatus ?? result.runnerTrace?.sessionStatus ?? result.status ?? state.sessionStatus; + const pendingContinuity = options.pendingContinuity ?? current.sessionContinuity ?? pendingSessionContinuity({ + conversationId: current.conversationId || options.fallbackConversationId || state.conversationId, + sessionId: current.sessionId || state.sessionId, + threadId: current.threadId || options.fallbackThreadId || state.threadId, + retryOf: current.retryOf + }); + const sessionContinuity = completedSessionContinuity(result, pendingContinuity); + const resultBlockedError = structuredBlockedErrorFromResult(result); + const continuityBlocker = codeAgentContinuityBlocker(result, sessionContinuity); + const blockedError = resultBlockedError ?? continuityBlocker; + const completion = classifyCodeAgentCompletion(result, { blockedError }); + const status = completion.status; + state.chatMessages[index] = { + ...current, + title: completion.title, + text: completion.replied + ? result.reply?.content || "Code Agent 没有返回文本。" + : blockedError + ? failureMessage({ ...result, error: blockedError }) + : result.status === "completed" + ? untrustedCompletionMessage(result) + : failureMessage(result), + status, + traceId: result.traceId || current.traceId, + conversationId: result.conversationId || result.sessionId || state.conversationId, + sessionId: result.sessionId || result.session?.sessionId || result.sessionReuse?.sessionId || state.sessionId, + threadId: resultThreadId || options.requestedThreadId || current.threadId || state.threadId, + sessionContinuity, + messageId: result.messageId, + provider: result.provider, + model: result.model, + backend: result.backend, + workspace: result.workspace, + sandbox: result.sandbox, + session: result.session, + sessionLifecycleStatus: result.sessionLifecycleStatus, + sessionLifecycle: result.sessionLifecycle, + sessionSummary: result.sessionSummary, + sessionMode: result.sessionMode, + sessionReuse: result.sessionReuse, + implementationType: result.implementationType, + runnerLimitations: result.runnerLimitations, + codexStdioFeasibility: result.codexStdioFeasibility, + longLivedSessionGate: result.longLivedSessionGate, + toolCalls: result.toolCalls, + skills: result.skills, + runner: result.runner, + runnerTrace: result.runnerTrace ?? current.runnerTrace, + conversationFacts: result.conversationFacts, + responseType: result.responseType, + capabilityLevel: result.capabilityLevel, + sourceKind: completion.sourceKind, + providerTrace: result.providerTrace, + blocker: result.blocker, + blockers: result.blockers, + updatedAt: result.updatedAt ?? new Date().toISOString(), + retryInput: options.retryInput ?? current.retryInput, + retryOf: options.retryOf ?? current.retryOf ?? null, + error: blockedError ?? result.error ?? (result.status === "completed" && !completion.replied + ? { + code: "untrusted_completion", + message: "completed 回复缺少真实 provider/model/trace/conversation 证据,或 provider 属于 echo/mock/stub。" + } + : undefined), + availability: result.availability, + traceReplayStatus: current.traceReplayStatus + }; + maybeReplayFullTraceForMessage(state.chatMessages[index]); + return state.chatMessages[index]; +} + +function errorStatusFromPresentation(presentation, error) { + if (presentation.category === "timeout") return "timeout"; + if (presentation.category === "canceled" || error?.code === "codex_stdio_canceled") return "canceled"; + return "failed"; +} + +function pendingSessionContinuity({ conversationId, sessionId, threadId, retryOf = null } = {}) { + const hasReusableSession = Boolean(nonEmptyString(sessionId)); + const hasReusableThread = Boolean(nonEmptyString(threadId)); + const status = hasReusableSession || hasReusableThread + ? "continued" + : retryOf ? "degraded" : "new"; + return { + status, + tone: status === "continued" ? "ok" : status === "degraded" ? "warn" : "source", + label: status === "continued" ? "继续当前会话" : status === "degraded" ? "重试降级" : "新会话待建立", + summary: status === "continued" + ? "本次请求会带上已保存的 conversation/session/thread 上下文。" + : status === "degraded" + ? "上一轮未留下可复用 session/thread;重试会保留输入和 conversation,并等待后端建立新会话。" + : "这是当前浏览器会话中的首轮请求,等待后端分配 session/thread。", + requested: { + conversationId: nonEmptyString(conversationId), + sessionId: nonEmptyString(sessionId), + threadId: nonEmptyString(threadId), + retryOf: nonEmptyString(retryOf) + }, + returned: { + conversationId: null, + sessionId: null, + threadId: null + }, + missing: [], + changed: [] + }; +} + +function completedSessionContinuity(result, pending = null) { + const lifecycle = result?.sessionSummary ?? result?.sessionLifecycle ?? null; + if (lifecycle?.degraded === true || lifecycle?.fallback === true) { + const requested = pending?.requested ?? {}; + return { + status: "degraded", + tone: "warn", + label: lifecycle.label ?? "会话降级", + summary: lifecycle.userMessage ?? "当前响应为会话降级;输入已保留,可重新发送建立新会话。", + requested, + returned: { + conversationId: nonEmptyString(result?.conversationId), + sessionId: sessionIdFrom(result), + threadId: threadIdFrom(result) + }, + missing: [], + changed: [], + reused: lifecycle.reused ?? null + }; + } + const requested = pending?.requested ?? {}; + const returned = { + conversationId: nonEmptyString(result?.conversationId), + sessionId: sessionIdFrom(result), + threadId: threadIdFrom(result) + }; + const missing = []; + const changed = []; + if (!returned.conversationId) missing.push("conversationId"); + if (requiresCodexSessionEvidence(result) && !returned.sessionId) missing.push("sessionId"); + if (requiresCodexThreadEvidence(result) && !returned.threadId) missing.push("threadId"); + if (requiresCodexProviderTrace(result) && !hasProviderTrace(result)) missing.push("providerTrace"); + if (requested.conversationId && returned.conversationId && requested.conversationId !== returned.conversationId) changed.push("conversationId"); + if (requested.sessionId && returned.sessionId && requested.sessionId !== returned.sessionId) changed.push("sessionId"); + if (requested.threadId && returned.threadId && requested.threadId !== returned.threadId) changed.push("threadId"); + const reused = result?.sessionReuse?.reused ?? result?.session?.reused ?? result?.runnerTrace?.sessionReused ?? null; + const requestedReusable = Boolean(requested.sessionId || requested.threadId); + const status = missing.length > 0 || changed.length > 0 || (requestedReusable && reused === false) + ? "degraded" + : requestedReusable ? "continued" : "new"; + const label = status === "continued" ? "继续当前会话" : status === "new" ? "新会话已建立" : "新会话/会话降级"; + const summary = status === "continued" + ? "后端确认复用当前 conversation/session/thread。" + : status === "new" + ? "后端已为当前 conversation 建立新的 session/thread。" + : `后端未完整确认原 session/thread 复用;${continuityIssueText({ missing, changed, reused })}`; + return { + status, + tone: status === "continued" ? "ok" : status === "new" ? "source" : "warn", + label, + summary, + requested, + returned, + missing, + changed, + reused + }; +} + +function failedSessionContinuity(pending = null, error = null) { + const base = pending ?? pendingSessionContinuity(); + const missing = [...(base.missing ?? [])]; + if (!base.requested?.sessionId) missing.push("sessionId"); + if (!base.requested?.threadId) missing.push("threadId"); + const code = normalizeErrorCode(error?.code); + const label = code === "session_busy" + ? "会话忙/请等待" + : code === "session_expired" + ? "会话已过期" + : ["session_failed", "session_error", "session_timeout"].includes(code) + ? "需新建会话" + : "会话受阻"; + const summary = code === "session_busy" + ? "上一轮仍在处理;输入已保留,请等待当前请求完成或取消后重试。" + : code === "session_expired" + ? "当前 session 已过期;输入已保留,可重新发送建立新的 Code Agent session。" + : `本次请求没有完成可复用会话确认;输入已保留。${error?.code ? ` code=${error.code}` : ""}`; + return { + ...base, + status: "blocked", + tone: "blocked", + label, + summary, + missing: [...new Set(missing)] + }; +} + +function continuityIssueText({ missing = [], changed = [], reused = null } = {}) { + const parts = [ + missing.length ? `缺失 ${missing.join("、")}` : null, + changed.length ? `${changed.join("、")} 已变化` : null, + reused === false ? "sessionReuse=false" : null + ].filter(Boolean); + return parts.length ? parts.join(";") : "会话复用证据不足"; +} + +function codeAgentContinuityBlocker(result, continuity) { + if ( + !result || + result.status !== "completed" || + isSourceFixtureCompletion(result) || + isTextFallbackChatResult(result) || + !isTrustedCodeAgentProvider(result.provider) + ) { + return null; + } + const missing = [ + ...(!hasProviderTrace(result) ? ["providerTrace"] : []), + ...(!sessionIdFrom(result) ? ["sessionId"] : []), + ...(requiresCodexThreadEvidence(result) && !threadIdFrom(result) ? ["threadId"] : []) + ]; + if (missing.length === 0) return null; + return { + code: "code_agent_session_evidence_missing", + category: "session_blocked", + layer: "cloud-web", + retryable: true, + missingFields: missing, + traceId: result.traceId, + message: `completed response missing Code Agent session evidence: ${missing.join(", ")}`, + userMessage: `Code Agent completed 但会话证据缺失:${missing.join("、")};本次按会话降级处理,输入已保留,可重试建立新会话。`, + blocker: { + code: "code_agent_session_evidence_missing", + category: "session_blocked", + layer: "cloud-web", + retryable: true, + missingFields: missing, + traceId: result.traceId, + userMessage: `Code Agent completed 但会话证据缺失:${missing.join("、")};本次按会话降级处理,输入已保留,可重试建立新会话。`, + sessionContinuity: continuity + } + }; +} + +function hasProviderTrace(value) { + return Boolean(value?.providerTrace && typeof value.providerTrace === "object" && Object.keys(value.providerTrace).length > 0); +} + +function requiresCodexProviderTrace(value) { + return isTrustedCodeAgentProvider(value?.provider) && !isSourceFixtureCompletion(value); +} + +function requiresCodexSessionEvidence(value) { + return isTrustedCodeAgentProvider(value?.provider); +} + +function requiresCodexThreadEvidence(value) { + return isCodexStdioLongLivedShape(value); +} + +function isCodexStdioLongLivedShape(value) { + return ( + isTrustedCodeAgentProvider(value?.provider) || + String(value?.runner?.kind ?? value?.runnerTrace?.runnerKind ?? "").trim() === CODEX_APP_SERVER_RUNNER_KIND || + value?.runner?.codexStdio === true || + value?.session?.codexStdio === true + ) && ( + String(value?.sessionMode ?? value?.session?.sessionMode ?? value?.runnerTrace?.sessionMode ?? "").trim() === CODEX_APP_SERVER_SESSION_MODE || + String(value?.implementationType ?? value?.session?.implementationType ?? "").trim() === CODEX_APP_SERVER_IMPLEMENTATION_TYPE + ); +} + +function sessionIdFrom(value) { + return firstNonEmptyString(value?.sessionId, value?.session?.sessionId, value?.sessionReuse?.sessionId, value?.runner?.sessionId, value?.runnerTrace?.sessionId); +} + +function threadIdFrom(value) { + return firstNonEmptyString(value?.threadId, value?.session?.threadId, value?.sessionReuse?.threadId, value?.providerTrace?.threadId, value?.runnerTrace?.threadId); +} + +function firstNonEmptyString(...values) { + for (const value of values) { + const text = nonEmptyString(value); + if (text) return text; + } + return null; +} + +function nonEmptyString(value) { + const text = String(value ?? "").trim(); + return text ? text : null; +} + +function subscribeRunnerTrace(traceId, messageId) { + if (typeof EventSource !== "function") return pollRunnerTrace(traceId, messageId); + const existing = state.traceStreams.get(traceId); + if (existing) existing(); + let stopped = false; + let fallbackStop = null; + let firstEventSeen = false; + let fallbackTimer = null; + const source = new EventSource(`/v1/agent/chat/trace/${encodeURIComponent(traceId)}/stream`); + const close = () => { + stopped = true; + if (fallbackTimer) window.clearTimeout(fallbackTimer); + source.close(); + if (fallbackStop) fallbackStop(); + if (state.traceStreams.get(traceId) === close) state.traceStreams.delete(traceId); + }; + const markEventSeen = () => { + firstEventSeen = true; + if (fallbackTimer) { + window.clearTimeout(fallbackTimer); + fallbackTimer = null; + } + }; + const startPolling = () => { + if (stopped || fallbackStop) return; + source.close(); + fallbackStop = pollRunnerTrace(traceId, messageId); + state.traceStreams.set(traceId, close); + }; + fallbackTimer = window.setTimeout(() => { + if (!firstEventSeen) startPolling(); + }, TRACE_STREAM_FALLBACK_MS); + source.addEventListener("snapshot", (event) => { + markEventSeen(); + updateMessageTrace(messageId, parseTraceEventData(event.data)); + }); + source.addEventListener("runnerTrace", (event) => { + markEventSeen(); + const payload = parseTraceEventData(event.data); + updateMessageTrace(messageId, payload?.snapshot ?? payload); + }); + source.addEventListener("heartbeat", (event) => { + markEventSeen(); + updateMessageTrace(messageId, parseTraceEventData(event.data), { quiet: true }); + }); + source.onerror = () => { + startPolling(); + }; + state.traceStreams.set(traceId, close); + return close; +} + +function pollRunnerTrace(traceId, messageId) { + let stopped = false; + let timer = null; + const tick = async () => { + if (stopped) return; + try { + const response = await fetchJson(`/v1/agent/chat/trace/${encodeURIComponent(traceId)}`, { + timeoutMs: Math.min(API_TIMEOUT_MS, 3000), + timeoutName: "Code Agent trace" + }); + if (response.ok) updateMessageTrace(messageId, response.data); + } catch { + // Keep polling; the main /v1/agent/chat request owns terminal error handling. + } + if (!stopped) timer = window.setTimeout(tick, TRACE_POLL_INTERVAL_MS); + }; + tick(); + const stop = () => { + stopped = true; + if (timer) window.clearTimeout(timer); + if (state.traceStreams.get(traceId) === stop) state.traceStreams.delete(traceId); + }; + state.traceStreams.set(traceId, stop); + return stop; +} + +function parseTraceEventData(data) { + try { + return data ? JSON.parse(data) : null; + } catch { + return null; + } +} + +function updateMessageTrace(messageId, snapshot, options = {}) { + if (!snapshot || typeof snapshot !== "object" || !Array.isArray(snapshot.events)) return; + const index = state.chatMessages.findIndex((message) => message.id === messageId); + if (index < 0) return; + const current = state.chatMessages[index]; + const runnerTrace = runnerTraceFromSnapshot(snapshot, current.runnerTrace); + const sessionId = snapshot.sessionId ?? runnerTrace.sessionId ?? current.sessionId; + const threadId = snapshot.threadId ?? runnerTrace.threadId ?? current.threadId; + noteCurrentRequestTraceActivity(snapshot); + const nextMessage = { + ...current, + runnerTrace, + traceId: snapshot.traceId ?? current.traceId, + sessionId, + threadId, + updatedAt: snapshot.updatedAt ?? current.updatedAt + }; + state.chatMessages[index] = nextMessage; + if (sessionId) state.sessionId = sessionId; + if (threadId) state.threadId = threadId; + if (runnerTrace.sessionStatus) state.sessionStatus = runnerTrace.sessionStatus; + if (state.currentRequest?.traceId === (snapshot.traceId ?? current.traceId) && sessionId) { + state.currentRequest.sessionId = sessionId; + } + if (state.currentRequest?.traceId === (snapshot.traceId ?? current.traceId) && threadId) { + state.currentRequest.threadId = threadId; + } + if (runnerTrace.eventsCompacted === true && runnerTrace.fullTraceLoaded !== true) { + maybeReplayFullTraceForMessage(nextMessage); + } + if (shouldReconcileCodeAgentResult(nextMessage, runnerTrace)) { + reconcileCodeAgentResult(messageId, { quiet: options.quiet === true }); + } + if (options.quiet !== true) { + renderCodeAgentSummary(); + patchMessageTracePanel(nextMessage); + renderDevicePodPanel(state.liveSurface); + } +} + +function noteCurrentRequestTraceActivity(snapshot) { + const request = state.currentRequest; + if (!request || request.traceId !== snapshot.traceId) return; + const eventCount = Number.isInteger(snapshot.eventCount) ? snapshot.eventCount : snapshot.events.length; + const assistantChunkCount = traceAssistantStreamChunkCount(snapshot); + const traceUpdatedAt = Date.parse(snapshot.updatedAt ?? ""); + const previousUpdatedAt = Date.parse(request.lastTraceUpdatedAt ?? ""); + const hasAssistantActivity = assistantChunkCount > (request.assistantChunkCount ?? 0); + const hasTimestampActivity = Number.isFinite(traceUpdatedAt) && (!Number.isFinite(previousUpdatedAt) || traceUpdatedAt > previousUpdatedAt); + if (eventCount <= (request.traceEventCount ?? 0) && !hasAssistantActivity && !hasTimestampActivity) { + if (snapshot.waitingFor) request.waitingFor = snapshot.waitingFor; + return; + } + request.traceEventCount = eventCount; + request.assistantChunkCount = assistantChunkCount; + request.lastActivityAt = Date.now(); + request.lastActivityIso = new Date(request.lastActivityAt).toISOString(); + request.lastTraceUpdatedAt = snapshot.updatedAt ?? request.lastTraceUpdatedAt; + request.waitingFor = snapshot.waitingFor ?? request.waitingFor; + request.lastEventLabel = snapshot.lastEvent?.label ?? snapshot.events.at(-1)?.label ?? request.lastEventLabel; +} + +function traceAssistantStreamChunkCount(trace) { + return Array.isArray(trace?.assistantStreams) + ? trace.assistantStreams.reduce((total, stream) => total + Math.max(0, Number(stream?.chunkCount ?? 0)), 0) + : 0; +} + +function latestTraceSnapshot(traceId) { + for (const message of [...state.chatMessages].reverse()) { + if (message.traceId === traceId && message.runnerTrace) return message.runnerTrace; + } + return null; +} + +function runnerTraceFromSnapshot(snapshot, previous = null) { + return { + ...(previous && typeof previous === "object" ? previous : {}), + traceId: snapshot.traceId, + runnerKind: snapshot.runnerKind ?? previous?.runnerKind, + workspace: snapshot.workspace ?? previous?.workspace, + sandbox: snapshot.sandbox ?? previous?.sandbox, + sessionMode: snapshot.sessionMode ?? previous?.sessionMode, + sessionId: snapshot.sessionId ?? previous?.sessionId, + threadId: snapshot.threadId ?? previous?.threadId, + turnId: snapshot.turnId ?? previous?.turnId, + sessionStatus: snapshot.sessionStatus ?? previous?.sessionStatus, + implementationType: snapshot.implementationType ?? previous?.implementationType, + startedAt: snapshot.startedAt ?? previous?.startedAt, + finishedAt: snapshot.finishedAt ?? previous?.finishedAt, + updatedAt: snapshot.updatedAt ?? previous?.updatedAt, + eventCount: Number.isInteger(snapshot.eventCount) ? snapshot.eventCount : snapshot.events.length, + eventsCompacted: snapshot.eventsCompacted === true, + eventWindow: snapshot.eventWindow ?? previous?.eventWindow, + fullTraceLoaded: snapshot.fullTraceLoaded === true || (snapshot.eventsCompacted !== true && Number.isInteger(snapshot.eventCount) && snapshot.eventCount === snapshot.events.length), + elapsedMs: snapshot.elapsedMs ?? previous?.elapsedMs, + waitingFor: snapshot.waitingFor ?? previous?.waitingFor, + events: snapshot.events, + assistantStreams: Array.isArray(snapshot.assistantStreams) ? snapshot.assistantStreams : previous?.assistantStreams ?? [], + eventLabels: snapshot.eventLabels ?? snapshot.events.map((item) => item.label).filter(Boolean), + lastEvent: snapshot.lastEvent ?? snapshot.events.at(-1) ?? null, + outputTruncated: snapshot.outputTruncated === true, + valuesPrinted: false + }; +} + + +function initGateControls() { + el.gateStatusFilter.addEventListener("change", renderGateTable); + el.gateSearch.addEventListener("input", renderGateTable); + el.gateRefresh.addEventListener("click", () => { + loadGateDiagnostics(); + }); +} + +function renderStaticWorkbench() { + el.routePath.textContent = "当前浏览器会话只保存任务草稿,Device Pod 动作需通过受控后端流程。"; + + renderConversation(); + renderDrafts(); + renderDevicePodPanel(null); + renderGateTable(); + renderCodeAgentSummary(); +} + +function renderDrafts() { + el.routePath.textContent = state.chatPending + ? "Code Agent 请求进行中;输入和 trace 会保留,Device Pod 仍只显示 summary。" + : "当前浏览器会话只保存任务草稿,Device Pod 动作需通过受控后端流程。"; +} + +function renderProbePending() { + el.liveStatus.textContent = "等待验证"; + el.liveStatus.className = "tone-pending"; + el.liveDetail.textContent = "正在验证 hwlab-cloud-api /health/live、/v1、/v1/agent/chat 与 /v1/device-pods;未完成前不标为通过。"; + renderLiveBuilds(null); + renderCodeAgentSummary(); +} + +async function sendAgentMessage(message, conversationId, traceId = nextProtocolId("trc"), requestedSessionId = sessionIdForNextRequest(), requestedThreadId = threadIdForNextRequest()) { + const sessionId = requestedSessionId || undefined; + const threadId = requestedThreadId || undefined; + const response = await fetchJson("/v1/agent/chat", { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Trace-Id": traceId, + "Prefer": "respond-async", + "X-HWLAB-Short-Connection": "1" + }, + timeoutMs: CODE_AGENT_SUBMIT_TIMEOUT_MS, + timeoutName: "Code Agent", + body: JSON.stringify({ + message, + conversationId, + sessionId, + threadId, + traceId, + providerProfile: CODE_AGENT_PROVIDER_PROFILE, + gatewayShellTimeoutMs: GATEWAY_SHELL_TIMEOUT_MS, + shortConnection: true, + projectId: WORKBENCH_PROJECT_ID + }) + }); + + if (!response.ok) { + if (isBlockedAgentResponse(response.data, response.status)) { + return normalizeBlockedAgentResult(response.data, response.status, traceId, response.error); + } + const error = agentErrorFromHttpResponse(response, traceId); + throw error; + } + if (response.status === 202 || response.data?.shortConnection === true) { + return waitForAgentMessageResult(traceId, response.data); + } + return { + ...response.data, + traceId: response.data?.traceId || traceId + }; +} + +async function waitForAgentMessageResult(traceId, accepted = {}) { + const startedAt = Date.now(); + const resultPath = accepted?.resultUrl || `/v1/agent/chat/result/${encodeURIComponent(traceId)}`; + let transientPollErrors = 0; + while (true) { + const activity = readActivityRef(() => state.currentRequest?.traceId === traceId ? state.currentRequest : null, startedAt); + const idleMs = Math.max(0, Date.now() - activity.lastActivityAt); + if (idleMs >= CODE_AGENT_TIMEOUT_MS) { + const error = new Error(`Code Agent 超过 ${CODE_AGENT_TIMEOUT_MS}ms 无新事件`); + error.code = "client_trace_idle_timeout"; + error.timeoutMs = CODE_AGENT_TIMEOUT_MS; + error.idleMs = idleMs; + error.lastActivityAt = activity.lastActivityIso; + error.waitingFor = activity.waitingFor; + error.traceId = traceId; + throw error; + } + const response = await fetchJson(resultPath, { + timeoutMs: Math.min(API_TIMEOUT_MS, 3000), + timeoutName: "Code Agent result" + }); + if (response.ok && response.status === 200 && response.data?.status && response.data.status !== "running") { + return { + ...response.data, + traceId: response.data.traceId || traceId + }; + } + if (response.ok) { + transientPollErrors = 0; + } + if (!response.ok && response.status && response.status !== 404) { + if (isTransientCodeAgentResultPollError(response)) { + transientPollErrors += 1; + await refreshTraceAfterResultPollError(traceId); + await wait(Math.min(TRACE_POLL_INTERVAL_MS * Math.min(transientPollErrors, 5), 5000)); + continue; + } + const error = agentErrorFromHttpResponse(response, traceId); + throw error; + } + await wait(Math.min(TRACE_POLL_INTERVAL_MS, 1000)); + } +} + +function isTransientCodeAgentResultPollError(response) { + if (response?.timeout === true) return true; + const status = Number(response?.status ?? 0); + if ([408, 425, 429, 500, 502, 503, 504].includes(status)) return true; + return /响应不是 JSON|非 JSON|空响应|请求失败/u.test(String(response?.error ?? "")); +} + +async function refreshTraceAfterResultPollError(traceId) { + const messageId = state.currentRequest?.traceId === traceId ? state.currentRequest.messageId : null; + if (!messageId) return; + try { + const response = await fetchJson(`/v1/agent/chat/trace/${encodeURIComponent(traceId)}`, { + timeoutMs: Math.min(API_TIMEOUT_MS, 3000), + timeoutName: "Code Agent trace" + }); + if (response.ok) updateMessageTrace(messageId, response.data, { quiet: true }); + } catch { + // Result polling owns the terminal decision; trace refresh is best-effort. + } +} + +function shouldReconcileCodeAgentResult(message, runnerTrace = message?.runnerTrace) { + if (!message?.traceId || message.role !== "agent") return false; + if (state.resultReconciliationInFlight.has(message.traceId)) return false; + const status = String(message.status ?? "").toLowerCase(); + if (["completed", "canceled"].includes(status)) return false; + if (["failed", "timeout", "error"].includes(status)) return traceHasTerminalResultEvent(runnerTrace) || isRecoverableCodeAgentTerminalMessage(message); + return traceHasTerminalResultEvent(runnerTrace); +} + +function shouldReconcileRestoredCodeAgentResult(message) { + if (!message?.traceId || message.role !== "agent") return false; + if (state.resultReconciliationInFlight.has(message.traceId)) return false; + const status = String(message.status ?? "").toLowerCase(); + if (["completed", "canceled"].includes(status)) return false; + return traceHasTerminalResultEvent(message.runnerTrace); +} + +function isRecoverableCodeAgentTerminalMessage(message) { + const code = normalizeErrorCode(message?.error?.code); + return [ + "client_trace_idle_timeout", + "client_timeout", + "proxy_timeout", + "cloud_api_proxy_timeout", + "code_agent_background_failed", + "code_agent_session_evidence_missing" + ].includes(code) || isCodeAgentTimeoutError(message?.error, message?.text); +} + +function traceHasTerminalResultEvent(runnerTrace) { + const events = Array.isArray(runnerTrace?.events) ? runnerTrace.events : []; + const terminalLabel = runnerTrace?.lastEvent?.label ?? events.at(-1)?.label; + if (/^(?:result|session):(completed|failed|canceled|session_busy)/u.test(String(terminalLabel ?? ""))) return true; + return events.some((event) => /^(?:result|session):(completed|failed|canceled|session_busy)/u.test(String(event?.label ?? ""))); +} + +async function reconcileCodeAgentResult(messageId, options = {}) { + const index = state.chatMessages.findIndex((message) => message.id === messageId); + if (index < 0) return false; + const message = state.chatMessages[index]; + if (!message.traceId || state.resultReconciliationInFlight.has(message.traceId)) return false; + state.resultReconciliationInFlight.add(message.traceId); + try { + const response = await fetchJson(`/v1/agent/chat/result/${encodeURIComponent(message.traceId)}`, { + timeoutMs: Math.min(API_TIMEOUT_MS, 3000), + timeoutName: "Code Agent result reconcile" + }); + if (!response.ok || !response.data || response.data.status === "running") return false; + const updated = applyCodeAgentResultToMessage(messageId, response.data, { + retryInput: message.retryInput, + retryOf: message.retryOf, + fallbackConversationId: message.conversationId, + fallbackThreadId: message.threadId + }); + if (!updated) return false; + renderAgentChatStatus(updated.status, updated); + renderCodeAgentSummary(); + renderConversation(); + renderDrafts(); + renderDevicePodPanel(state.liveSurface); + return true; + } finally { + state.resultReconciliationInFlight.delete(message.traceId); + } +} + +function wait(ms) { + return new Promise((resolve) => window.setTimeout(resolve, ms)); +} + +async function loadLiveSurface() { + const selectedDevicePodId = state.devicePod.selectedDevicePodId || "device-pod-71-freq"; + const encodedPodId = encodeURIComponent(selectedDevicePodId); + const [healthLive, liveBuilds, restIndex, devicePods, devicePodStatus, devicePodEvents, devicePodChipId, devicePodUart, devicePodUartTail, health, adapter] = await Promise.all([ + liveSurfaceFetch("/health/live"), + liveSurfaceFetch("/v1/live-builds"), + liveSurfaceFetch("/v1"), + liveSurfaceFetch("/v1/device-pods"), + liveSurfaceFetch(`/v1/device-pods/${encodedPodId}/status`), + liveSurfaceFetch(`/v1/device-pods/${encodedPodId}/events?limit=120`), + liveSurfaceFetch(`/v1/device-pods/${encodedPodId}/debug-probe/chip-id`), + liveSurfaceFetch(`/v1/device-pods/${encodedPodId}/io-probe/uart/1`), + liveSurfaceFetch(`/v1/device-pods/${encodedPodId}/io-probe/uart/1/tail?maxBytes=12000`), + liveSurfaceRpc("system.health"), + liveSurfaceRpc("cloud.adapter.describe") + ]); + + return { + observedAt: new Date().toISOString(), + healthLive, + liveBuilds, + restIndex, + devicePods, + devicePodStatus, + devicePodEvents, + devicePodChipId, + devicePodUart, + devicePodUartTail, + health, + adapter + }; +} + +function liveSurfaceFetch(path) { + return fetchJson(path, { + timeoutMs: LIVE_SURFACE_TIMEOUT_MS, + timeoutName: `工作台实况 ${path}` + }); +} + +function liveSurfaceRpc(method, params = {}) { + return callRpc(method, params, { + timeoutMs: LIVE_SURFACE_TIMEOUT_MS, + timeoutName: `工作台实况 /json-rpc ${method}` + }); +} + +async function loadGateDiagnostics() { + if (state.gateDiagnostics.loading) return; + state.gateDiagnostics.loading = true; + state.gateDiagnostics.error = null; + renderGateTable(); + const response = await fetchJson("/v1/diagnostics/gate", { + timeoutMs: LIVE_SURFACE_TIMEOUT_MS, + timeoutName: "内部复核 live 聚合" + }); + state.gateDiagnostics.loading = false; + if (response.ok && Array.isArray(response.data?.rows)) { + state.gateDiagnostics.payload = response.data; + state.gateDiagnostics.rows = response.data.rows.map(normalizeGateRow); + state.gateDiagnostics.loadedAt = new Date().toISOString(); + } else { + state.gateDiagnostics.payload = null; + state.gateDiagnostics.rows = [gateLiveBlockerRow(response)]; + state.gateDiagnostics.error = response.error ?? "内部复核 live 后端聚合不可用"; + state.gateDiagnostics.loadedAt = new Date().toISOString(); + } + renderGateTable(); +} + +async function fetchJson(path, options = {}) { + const { + timeoutMs = API_TIMEOUT_MS, + timeoutName = path, + activityRef = null, + ...fetchOptions + } = options; + const controller = new AbortController(); + let timer = null; + let timeoutDetail = null; + const startedAt = Date.now(); + const activityTimeout = Boolean(activityRef); + const scheduleTimeout = () => { + const activity = readActivityRef(activityRef, startedAt); + const idleMs = Math.max(0, Date.now() - activity.lastActivityAt); + const remainingMs = timeoutMs - idleMs; + if (remainingMs <= 0) { + timeoutDetail = { ...activity, idleMs }; + controller.abort(); + return; + } + timer = window.setTimeout(scheduleTimeout, Math.max(1, remainingMs)); + }; + scheduleTimeout(); + try { + const response = await fetch(path, { + ...fetchOptions, + headers: { + Accept: "application/json", + ...(fetchOptions.headers ?? {}) + }, + signal: controller.signal + }); + const text = await response.text(); + let data = null; + try { + data = text ? JSON.parse(text) : null; + } catch (error) { + if (response.ok) throw error; + return { + ok: false, + path, + status: response.status, + data: null, + error: `${path} HTTP ${response.status}:响应不是 JSON` + }; + } + if (!response.ok) { + return { + ok: false, + path, + status: response.status, + data, + error: `${path} HTTP ${response.status}:${messageFromPayload(data)}` + }; + } + return { ok: true, path, status: response.status, data }; + } catch (error) { + const timedOut = error.name === "AbortError"; + const timeoutState = timeoutDetail ?? readActivityRef(activityRef, startedAt); + const idleMs = timedOut ? Math.max(0, Date.now() - timeoutState.lastActivityAt) : undefined; + return { + ok: false, + path, + timeout: timedOut, + timeoutMs, + idleMs, + lastActivityAt: timedOut ? timeoutState.lastActivityIso : undefined, + waitingFor: timedOut ? timeoutState.waitingFor : undefined, + error: timedOut + ? activityTimeout + ? `${timeoutName} 超过 ${timeoutMs}ms 无新事件` + : `${timeoutName} 请求超时 ${timeoutMs}ms` + : `请求失败:${error.message}` + }; + } finally { + if (timer) window.clearTimeout(timer); + } +} + +function readActivityRef(activityRef, fallbackMs = Date.now()) { + const ref = typeof activityRef === "function" ? activityRef() : activityRef; + const lastActivityAt = Number(ref?.lastActivityAt); + const safeLastActivityAt = Number.isFinite(lastActivityAt) && lastActivityAt > 0 ? lastActivityAt : fallbackMs; + return { + lastActivityAt: safeLastActivityAt, + lastActivityIso: typeof ref?.lastActivityIso === "string" ? ref.lastActivityIso : new Date(safeLastActivityAt).toISOString(), + waitingFor: typeof ref?.waitingFor === "string" ? ref.waitingFor : null, + lastEventLabel: typeof ref?.lastEventLabel === "string" ? ref.lastEventLabel : null + }; +} + +async function callRpc(method, params = {}, options = {}) { + const id = nextProtocolId("req"); + const traceId = nextProtocolId("trc"); + const response = await fetchJson("/json-rpc", { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id, + method, + params, + meta: { + traceId, + serviceId: "hwlab-cloud-web", + environment: "dev" + } + }), + timeoutMs: options?.timeoutMs ?? API_TIMEOUT_MS, + timeoutName: options?.timeoutName ?? `/json-rpc ${method}` + }); + + if (!response.ok) { + return { ...response, method }; + } + if (response.data?.error) { + return { + ok: false, + path: response.path, + status: response.status, + method, + error: `RPC 错误 ${method}:${response.data.error.message}`, + envelope: response.data + }; + } + return { + ok: true, + path: response.path, + status: response.status, + method, + data: response.data?.result, + envelope: response.data + }; +} + + +function nextProtocolId(prefix) { + rpcSequence += 1; + return `${prefix}_cloud-web-workbench-${Date.now()}-${rpcSequence}`; +} + +function renderLiveSurface(live) { + state.liveSurface = live; + const coreProbes = [live.healthLive, live.restIndex, live.health, live.adapter]; + const surfaceStatus = workbenchApiSurfaceStatus(live, coreProbes); + const runtimeSummary = runtimeSummaryFrom(live); + + el.liveStatus.textContent = surfaceStatus.label; + el.liveStatus.className = `tone-${toneClass(surfaceStatus.tone)}`; + state.codeAgentAvailability = codeAgentAvailabilityFrom(live); + el.liveDetail.textContent = surfaceStatus.detail ?? codeAgentAvailabilitySummary(); + + renderLiveBuilds(live.liveBuilds); + renderAgentChatStatus(deriveAgentChatStatus()); + renderCodeAgentSummary(); + renderDevicePodPanel(live); + renderConversation(); + renderDrafts(); +} + +function renderLiveBuilds(response) { + const payload = response?.ok ? response.data : null; + const services = Array.isArray(payload?.services) ? payload.services : []; + const latest = payload?.latest ?? null; + + if (latest?.build?.createdAt) { + el.liveBuildLatest.textContent = [ + `最新镜像构建时间:${formatBeijingTime(latest.build.createdAt)}`, + latest.name || latest.serviceId || "未知服务", + `tag ${latest.image?.tag || "unknown"}`, + `commit ${shortToken(latest.commit?.id)}`, + `revision ${shortToken(latest.revision)}`, + `来源 ${liveBuildSourceLabel(latest.build?.metadataSource)}` + ].join(" · "); + } else { + el.liveBuildLatest.textContent = "最新镜像构建时间:构建时间不可用"; + } + + if (!response) { + replaceChildren( + el.liveBuildList, + liveBuildUnavailableRow("等待实时元数据聚合返回。") + ); + return; + } + + if (!response.ok) { + replaceChildren( + el.liveBuildList, + liveBuildUnavailableRow(`构建时间不可用:${response.error || "实时元数据聚合不可用"}`) + ); + return; + } + + if (!services.length) { + replaceChildren( + el.liveBuildList, + liveBuildUnavailableRow("构建时间不可用:当前实时元数据未返回微服务明细。") + ); + return; + } + + replaceChildren(el.liveBuildList, ...services.map(liveBuildRow)); +} + +function liveBuildRow(service) { + const row = document.createElement("div"); + row.className = `live-build-row live-build-row-${toneClass(service.status)}`; + + const header = document.createElement("div"); + header.className = "live-build-row-head"; + header.append( + textSpan(service.name || service.serviceId || "未知服务", "live-build-service"), + textSpan(liveBuildTimeText(service), "live-build-time") + ); + + const meta = document.createElement("div"); + meta.className = "live-build-meta"; + meta.append( + textSpan(`tag ${service.image?.tag || "unknown"}`), + textSpan(`commit ${shortToken(service.commit?.id)}`), + textSpan(`revision ${shortToken(service.revision)}`), + textSpan(`来源 ${liveBuildSourceLabel(service.build?.metadataSource)}`) + ); + + const desired = liveBuildDesiredStateText(service); + if (desired) meta.append(textSpan(desired)); + + const reasonText = liveBuildReasonText(service); + if (reasonText) { + row.append(header, meta, textSpan(reasonText, "live-build-reason")); + } else { + row.append(header, meta); + } + return row; +} + +function liveBuildUnavailableRow(reason) { + return liveBuildRow({ + serviceId: "live-build-metadata", + name: "实时元数据", + kind: "hwlab", + status: "unavailable", + build: { + createdAt: null, + unavailableReason: reason + }, + image: { + tag: "unknown" + }, + commit: { + id: "unknown" + }, + revision: "unknown" + }); +} + +function liveBuildTimeText(service) { + if (service.kind === "external") return "外部镜像"; + const createdAt = service.build?.createdAt; + return createdAt ? formatBeijingTime(createdAt) : "构建时间不可用"; +} + +function liveBuildReasonText(service) { + if (service.kind === "external") return "外部镜像或非 HWLAB 构建产物,不参与最新 HWLAB 构建时间计算。"; + if (service.build?.liveHealthMissingReason) return service.build.liveHealthMissingReason; + if (service.build?.unavailableReason) return service.build.unavailableReason; + if (service.error?.message) return `构建时间不可用:${service.error.message}`; + return ""; +} + +function liveBuildSourceLabel(source) { + const text = String(source ?? "").trim(); + if (text.includes("artifact-catalog")) return "artifact catalog"; + if (text.includes("deploy")) return "deploy metadata"; + if (text.includes("runtime-env") || text.includes("health")) return "live health"; + if (text.includes("external")) return "external image"; + if (!text || text === "unavailable") return "不可用"; + return text; +} + +function liveBuildDesiredStateText(service) { + if (!service.desiredState || service.kind === "external") return ""; + const parts = []; + if (Number.isFinite(Number(service.desiredState.replicas))) { + parts.push(`replicas ${Number(service.desiredState.replicas)}`); + } + if (service.desiredState.healthPath) parts.push(service.desiredState.healthPath); + return parts.length ? `desired ${parts.join(" / ")}` : ""; +} + +function renderDevicePodPanel(live) { + state.devicePod.list = live?.devicePods ?? null; + state.devicePod.status = live?.devicePodStatus ?? null; + state.devicePod.events = live?.devicePodEvents ?? null; + state.devicePod.chipId = live?.devicePodChipId ?? null; + state.devicePod.uart = live?.devicePodUart ?? null; + state.devicePod.uartTail = live?.devicePodUartTail ?? null; + renderDevicePodSelector(); + renderDevicePodSummary(); + renderDevicePodInterfaces(); + renderDeviceEventStream(); +} + +function renderDevicePodSelector() { + const payload = state.devicePod.list?.ok ? state.devicePod.list.data : null; + const pods = Array.isArray(payload?.devicePods) && payload.devicePods.length > 0 + ? payload.devicePods + : [{ devicePodId: state.devicePod.selectedDevicePodId || "device-pod-71-freq" }]; + const current = state.devicePod.selectedDevicePodId || pods[0]?.devicePodId || "device-pod-71-freq"; + const existing = [...el.devicePodSelect.options].map((option) => option.value).join("\n"); + const next = pods.map((pod) => pod.devicePodId).join("\n"); + if (existing !== next) { + replaceChildren(el.devicePodSelect, ...pods.map((pod) => { + const option = document.createElement("option"); + option.value = pod.devicePodId; + option.textContent = pod.devicePodId; + return option; + })); + } + el.devicePodSelect.value = current; +} + +function renderDevicePodSummary() { + const response = state.devicePod.status; + const payload = response?.ok ? response.data : null; + const pod = payload?.devicePod ?? null; + const summary = payload?.summary ?? null; + const status = pod?.status ?? summary?.status ?? (response?.ok ? "ok" : "blocked"); + const tone = response?.ok ? toneForDeviceStatus(status, summary?.blocker ?? pod?.blocker) : "blocked"; + el.devicePodStatusTag.textContent = response?.ok ? statusLabel(status) : "未接入"; + el.devicePodStatusTag.className = `state-tag tone-${toneClass(tone)}`; + el.devicePodId.textContent = pod?.devicePodId ?? state.devicePod.selectedDevicePodId ?? "device-pod-71-freq"; + el.devicePodTarget.textContent = pod?.target?.targetId ?? summary?.targetId ?? "未观测"; + el.devicePodProfile.textContent = shortHash(pod?.profile?.profileHash ?? summary?.profileHash ?? "未观测"); + el.devicePodFreshness.textContent = freshnessLabel(pod?.freshness ?? summary?.freshness); + state.devicePod.details.set("pod", payload ?? response ?? { status: "waiting" }); +} + +function renderDevicePodInterfaces() { + const pod = state.devicePod.status?.ok ? state.devicePod.status.data?.devicePod : null; + const chip = state.devicePod.chipId?.ok ? state.devicePod.chipId.data : null; + const uart = state.devicePod.uart?.ok ? state.devicePod.uart.data : null; + const tail = state.devicePod.uartTail?.ok ? state.devicePod.uartTail.data : null; + const tiles = [ + { key: "target", title: "Target", value: pod?.target?.targetId ?? "未观测", detail: pod?.target?.lock?.status ? `lock=${pod.target.lock.status}` : "等待 Device Pod status", tone: pod ? "ok" : "pending", raw: pod?.target ?? null }, + { key: "workspace", title: "Workspace", value: pod?.projectWorkspace?.latestBuild?.artifact ?? "未观测", detail: pod?.projectWorkspace?.workspaceRoot ?? "等待 workspace 摘要", tone: pod?.projectWorkspace?.status ?? "pending", raw: pod?.projectWorkspace ?? null }, + { key: "debug", title: "Debug", value: chip?.chipId ?? pod?.debugInterface?.chipId ?? "未观测", detail: chip?.probe ?? pod?.debugInterface?.probe ?? "等待 probe 摘要", tone: chip?.status ?? pod?.debugInterface?.status ?? "pending", raw: chip ?? pod?.debugInterface ?? null }, + { key: "io", title: "IO", value: uart?.uart?.status ?? pod?.ioInterface?.uart1?.status ?? "未观测", detail: tail?.text ? compactOneLine(tail.text, 92) : "等待 UART1 tail", tone: uart?.status ?? pod?.ioInterface?.status ?? "pending", raw: { uart, tail, ioInterface: pod?.ioInterface ?? null } } + ]; + state.devicePod.details.clear(); + state.devicePod.details.set("pod", state.devicePod.status?.data ?? state.devicePod.status ?? null); + replaceChildren(el.devicePodInterfaces, ...tiles.map(deviceSummaryTile)); +} + +function deviceSummaryTile(tile) { + state.devicePod.details.set(tile.key, tile.raw ?? tile); + const article = document.createElement("article"); + article.className = `summary-tile tone-border-${toneClass(tile.tone)}`; + article.tabIndex = 0; + article.dataset.deviceDetail = tile.key; + article.dataset.deviceTitle = tile.title; + article.setAttribute("role", "button"); + article.setAttribute("aria-label", `查看 ${tile.title} 详情`); + article.addEventListener("keydown", (event) => { + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + showDeviceDetail(tile.title, state.devicePod.details.get(tile.key)); + }); + article.append(textSpan(tile.title, "summary-title")); + article.append(textSpan(tile.value, "summary-value mono wrap")); + article.append(textSpan(tile.detail, "summary-detail")); + return article; +} + +function renderDeviceEventStream() { + const payload = state.devicePod.events?.ok ? state.devicePod.events.data : null; + const lines = Array.isArray(payload?.lines) ? payload.lines : []; + const nextText = lines.length > 0 ? lines.join("\n") : "等待 /v1/device-pods 事件流。"; + const wasNearBottom = deviceEventNearBottom(); + const previousLineCount = state.devicePod.lastEventLineCount; + const nextLineCount = lines.length; + if (el.deviceEventText.textContent !== nextText) { + el.deviceEventText.textContent = nextText; + if (state.devicePod.followEvents && wasNearBottom && !deviceEventUserActive()) { + scrollDeviceEventsToBottom(); + } else if (nextLineCount > previousLineCount) { + state.devicePod.unreadEvents += nextLineCount - previousLineCount; + } + } + state.devicePod.lastEventLineCount = nextLineCount; + renderDeviceEventUnreadHint(); +} + +function markDeviceEventScrollIntent() { + state.devicePod.eventScrollUserActiveUntil = Date.now() + SCROLL_USER_ACTIVITY_MS; +} + +function deviceEventUserActive() { + return Date.now() < state.devicePod.eventScrollUserActiveUntil; +} + +function setDeviceEventFollow(enabled, options = {}) { + state.devicePod.followEvents = enabled === true; + el.deviceEventFollow.setAttribute("aria-pressed", state.devicePod.followEvents ? "true" : "false"); + el.deviceEventFollow.textContent = state.devicePod.followEvents ? "跟随" : "暂停跟随"; + if (state.devicePod.followEvents && options.scroll === true) scrollDeviceEventsToBottom(); +} + +function deviceEventNearBottom() { + return scrollBottomGap(el.deviceEventScroll) < 24; +} + +function scrollDeviceEventsToBottom() { + el.deviceEventScroll.scrollTop = el.deviceEventScroll.scrollHeight; +} + +function renderDeviceEventUnreadHint() { + const count = state.devicePod.unreadEvents; + el.deviceEventJump.hidden = count <= 0; + el.deviceEventJump.textContent = `有 ${count} 条新事件`; +} + +function showDeviceDetail(title, payload) { + el.deviceDetailTitle.textContent = title || "Device Pod 详情"; + el.deviceDetailBody.textContent = JSON.stringify(payload ?? { status: "empty" }, null, 2); + if (typeof el.deviceDetailDialog.showModal === "function") { + el.deviceDetailDialog.showModal(); + } else { + el.deviceDetailDialog.setAttribute("open", ""); + } +} + +function toneForDeviceStatus(status, blocker = null) { + if (blocker) return "blocked"; + const value = String(status ?? "").toLowerCase(); + if (["ok", "ready", "live", "pass", "healthy"].includes(value)) return "ok"; + if (["blocked", "failed", "error"].includes(value)) return "blocked"; + return "pending"; +} + +function freshnessLabel(freshness) { + if (!freshness) return "未观测"; + const ageMs = Number(freshness.ageMs); + const age = Number.isFinite(ageMs) ? `${Math.round(ageMs / 1000)}s` : "未知"; + return `${freshness.stale ? "过期" : "新鲜"} / age=${age}`; +} + +function shortHash(value) { + const text = String(value ?? "").trim(); + if (!text) return "未观测"; + return text.length > 26 ? `${text.slice(0, 23)}...` : text; +} + +function compactOneLine(value, maxLength = 120) { + const text = String(value ?? "").replace(/\s+/gu, " ").trim(); + return text.length > maxLength ? `${text.slice(0, maxLength - 3)}...` : text; +} + +function formatBeijingTime(value) { + const date = new Date(value); + if (Number.isNaN(date.getTime())) return "构建时间不可用"; + const parts = Object.fromEntries(new Intl.DateTimeFormat("zh-CN", { + timeZone: "Asia/Shanghai", + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false + }).formatToParts(date).map((part) => [part.type, part.value])); + return `${parts.year}-${parts.month}-${parts.day} ${parts.hour}:${parts.minute}:${parts.second} 北京时间`; +} + +function shortToken(value) { + const text = String(value ?? "unknown").trim() || "unknown"; + return text.length > 12 ? text.slice(0, 12) : text; +} + +function workbenchApiSurfaceStatus(live, coreProbes = [live.healthLive, live.restIndex, live.health, live.adapter]) { + const status = classifyWorkbenchLiveStatus({ + ...live, + healthLive: live.healthLive ?? coreProbes[0], + restIndex: live.restIndex ?? coreProbes[1], + health: live.health ?? coreProbes[2], + adapter: live.adapter ?? coreProbes[3] + }); + return { + ...status, + methodTone: status.kind === "pass" ? "live" : status.kind + }; +} diff --git a/web/hwlab-cloud-web/app-helpers.ts b/web/hwlab-cloud-web/app-helpers.ts new file mode 100644 index 00000000..177b12be --- /dev/null +++ b/web/hwlab-cloud-web/app-helpers.ts @@ -0,0 +1,629 @@ +function traceEventMeta(event) { + return [ + event.toolName ? `tool=${event.toolName}` : null, + event.waitingFor ? `waiting=${event.waitingFor}` : null, + typeof event.timeoutMs === "number" ? `timeoutMs=${event.timeoutMs}` : null, + typeof event.hardTimeoutMs === "number" ? `hardTimeoutMs=${event.hardTimeoutMs}` : null, + typeof event.idleMs === "number" ? `idleMs=${event.idleMs}` : null, + event.lastActivityAt ? `lastActivityAt=${event.lastActivityAt}` : null, + typeof event.elapsedMs === "number" ? `${event.elapsedMs}ms` : null, + event.errorCode ? `error=${event.errorCode}` : null, + event.outputSummary, + event.message + ].filter(Boolean).join(" / "); +} + +function toolCallsSummary(toolCalls) { + const summary = compactToolCallsSummary(toolCalls); + return summary === "none" ? null : summary; +} + +function skillsSummary(skills) { + const summary = compactSkillsSummary(skills); + return summary === "none" ? null : summary; +} + +function runnerTraceSummary(runnerTrace) { + const summary = compactRunnerTraceSummary(runnerTrace); + return summary === "none" ? null : summary; +} + +function conversationFactsSummary(conversationFacts) { + if (!conversationFacts || typeof conversationFacts !== "object") return "none"; + return [ + recordField("turns", conversationFacts.turnCount), + recordField("sessionId", conversationFacts.sessionId), + recordField("status", conversationFacts.sessionStatus), + recordField("workspace", conversationFacts.workspace), + recordField("mode", conversationFacts.sessionMode), + conversationFacts.valuesRedacted === true ? "valuesRedacted=true" : null + ].filter(Boolean).join(" / ") || "none"; +} + +function conversationFactTracesSummary(conversationFacts) { + if (!conversationFacts || typeof conversationFacts !== "object") return "none"; + const traces = Array.isArray(conversationFacts.traceIds) ? conversationFacts.traceIds.filter(Boolean) : []; + return traces.length ? traces.slice(-4).join(",") : conversationFacts.latestTraceId ?? "none"; +} + +function conversationFactSkillsSummary(conversationFacts) { + const skills = conversationFacts?.latestSkills; + if (!skills || typeof skills !== "object") return "none"; + const names = Array.isArray(skills.names) ? skills.names.filter(Boolean).slice(0, 6).join(",") : ""; + return [ + skills.status ?? "unknown", + recordField("count", skills.totalCount ?? skills.count), + names ? `items=${names}` : null + ].filter(Boolean).join(":") || "none"; +} + +function conversationFactToolsSummary(conversationFacts) { + const toolCalls = Array.isArray(conversationFacts?.recentToolCalls) ? conversationFacts.recentToolCalls : []; + if (toolCalls.length === 0) return "none"; + return toolCalls + .map((toolCall) => [toolCall.name ?? "tool", toolCall.status ? `(${toolCall.status})` : null].filter(Boolean).join("")) + .slice(-5) + .join(","); +} + +function sessionSummary(session) { + if (!session || typeof session !== "object") return null; + const parts = [ + session.sessionId, + session.status, + typeof session.turn === "number" ? `turn${session.turn}` : null, + session.threadId ? `thread=${session.threadId}` : null, + session.lastTraceId ? `lastTrace=${session.lastTraceId}` : null + ].filter(Boolean); + return parts.join(":"); +} + +function statusCard(item) { + const article = document.createElement("article"); + article.className = "status-card"; + article.append(textSpan(item.title, "metric-label")); + article.append(textSpan(String(item.value), `status-value tone-${toneClass(item.tone)}`)); + article.append(textSpan(item.meta, "status-meta")); + return article; +} + +function infoCard(item) { + const article = document.createElement("article"); + article.className = "info-card"; + article.append(badge(statusLabel(item.tone), item.tone)); + const body = document.createElement("div"); + body.append(textSpan(item.title, "info-title")); + body.append(textSpan(item.detail, "info-detail")); + article.append(body); + return article; +} + +function badge(text, tone = text) { + const span = document.createElement("span"); + span.className = `badge tone-${toneClass(tone)}`; + span.textContent = text ?? "无"; + return span; +} + +function copyButton(value, label = "value") { + const button = document.createElement("button"); + button.className = "copy-chip"; + button.type = "button"; + button.textContent = "复制"; + button.title = `复制 ${label}`; + button.addEventListener("click", async () => { + await copyTextToClipboard(value); + button.textContent = "已复制"; + window.setTimeout(() => { + button.textContent = "复制"; + }, 1200); + }); + return button; +} + +async function copyTextToClipboard(value) { + const text = String(value ?? ""); + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(text); + return; + } + const input = document.createElement("textarea"); + input.value = text; + input.setAttribute("readonly", ""); + input.style.position = "fixed"; + input.style.left = "-9999px"; + document.body.append(input); + input.select(); + document.execCommand("copy"); + input.remove(); +} + +function safeLink(href, text, className) { + const anchor = document.createElement("a"); + anchor.href = href; + anchor.textContent = text ?? href; + anchor.rel = "noreferrer"; + if (className) anchor.className = className; + return anchor; +} + +function textSpan(text, className) { + const span = document.createElement("span"); + span.textContent = text ?? "无"; + if (className) span.className = className; + return span; +} + +function cell(text, className) { + const td = document.createElement("td"); + td.textContent = text ?? "无"; + if (className) td.className = className; + return td; +} + +function formatTimestamp(value) { + const date = new Date(value); + if (Number.isNaN(date.getTime())) return "未知"; + return shortTime(date.toISOString()); +} + +function tableCell(text, className, colspan = 1) { + const td = cell(text, className); + if (colspan > 1) td.colSpan = colspan; + return td; +} + +function runtimeSummaryFrom(live) { + return live.health.data?.runtime ?? live.healthLive.data?.runtime ?? live.adapter.data?.runtime ?? live.restIndex.data?.runtime ?? null; +} + +function messageFromPayload(payload) { + if (!payload || typeof payload !== "object") return "非 JSON 或空响应"; + if (payload.error && typeof payload.error === "object") { + return payload.error.message || payload.error.code || payload.reason || payload.message || JSON.stringify(payload.error).slice(0, 160); + } + if (typeof payload.error === "string") { + return payload.message ? `${payload.error}:${payload.message}` : payload.error; + } + return payload.code || payload.message || payload.reason || JSON.stringify(payload).slice(0, 160); +} + +function normalizeBlockedAgentResult(payload, httpStatus, traceId, fallbackMessage) { + const { reply, ...safePayload } = payload; + const payloadError = payload.error && typeof payload.error === "object" ? payload.error : {}; + const payloadErrorCode = payloadError.code ?? (typeof payload.error === "string" ? payload.error : null); + const providerStatus = payloadError.providerStatus ?? httpStatus; + const error = { + ...payloadError, + code: payloadErrorCode ?? "provider_unavailable", + message: payloadError.message ?? fallbackMessage ?? `Code Agent provider HTTP ${httpStatus}`, + providerStatus + }; + return { + ...safePayload, + status: "failed", + traceId: payload.traceId || traceId, + error, + availability: payload.availability ?? codeAgentAvailabilityFromResult({ error }) + }; +} + +function isBlockedAgentResponse(payload, httpStatus) { + return Boolean( + payload && + typeof payload === "object" && + ( + isHttpNon2xxStatus(httpStatus) || + isHttpNon2xxStatus(payload.error?.providerStatus) || + payload.status === "blocked" || + payload.error?.code === "provider_unavailable" || + payload.error?.code === "security_blocked" || + payload.error?.code === "tool_unavailable" || + payload.error?.code === "skills_unavailable" || + payload.availability?.status === "blocked" + ) + ); +} + +function isHttpNon2xxStatus(value) { + return typeof value === "number" && Number.isInteger(value) && (value < 200 || value > 299); +} + +function codeAgentAvailabilityFrom(live) { + return sanitizeCodeAgentAvailability(codeAgentAvailabilityFromLive(live)); +} + +function codeAgentAvailabilityFromResult(result) { + const missingEnv = Array.isArray(result?.error?.missingEnv) ? result.error.missingEnv : []; + const providerStatus = result?.error?.providerStatus; + const credentialBlocked = missingEnv.includes("OPENAI_API_KEY"); + return { + status: "blocked", + blocker: credentialBlocked ? "凭证缺口" : "provider_unavailable", + reason: result?.error?.code ?? "provider_unavailable", + summary: providerStatus + ? `真实后端已接入,但当前 provider 上游返回 HTTP ${providerStatus};本次保持 BLOCKED/provider_unavailable,不会冒充真实 Code Agent 回复。` + : "真实后端已接入,但当前 DEV provider Secret hwlab-code-agent-provider/openai-api-key 未注入,因此不能返回真实回复。", + missingEnv, + secretRefs: credentialBlocked ? [ + { + env: "OPENAI_API_KEY", + secretName: "hwlab-code-agent-provider", + secretKey: "openai-api-key", + redacted: true + } + ] : [] + }; +} + +function sanitizeCodeAgentAvailability(availability) { + if (!availability || typeof availability !== "object") return availability; + if (availability.status !== "blocked") return availability; + return { + ...availability, + blocker: "服务受阻", + summary: codeAgentBlockedSummary(availability) + }; +} + +function codeAgentStatusMessage(availability) { + if (availability?.longLivedSessionGate?.status === "pass" && availability?.codexStdioFeasibility?.canStartLongLivedCodexStdio === true) { + return { + role: "system", + title: "Code Agent 状态:Codex stdio 长会话可用", + text: "当前通道可创建和复用 repo-owned Codex stdio session,并暴露 cancel、reap、idle timeout 与 trace capture;硬件控制仍只走 cloud-api/HWLAB API/skill CLI。", + status: "source" + }; + } + if (availability?.runner?.ready === true) { + return { + role: "system", + title: "Code Agent 状态:Codex stdio gate 未通过", + text: `同源服务可响应,但完整 Codex stdio 长会话仍未通过 gate;不会用本地技能发现、shell/file 快捷命令或文本 fallback 冒充回复。${codeAgentBlockerDetail(availability)}`, + status: "blocked" + }; + } + if (availability?.status === "blocked") { + return { + role: "system", + title: "Code Agent 状态:服务受阻", + text: codeAgentBlockedSummary(availability), + status: "blocked" + }; + } + if (String(availability?.sourceKind ?? availability?.evidenceLevel ?? "").toUpperCase() === "SOURCE") { + return { + role: "system", + title: "Code Agent 状态:本地验证记录", + text: "当前只用于本地浏览器验证:可以检查发送、处理中、回复渲染和证据展示,但不会标记为实况完成。", + status: "source" + }; + } + if (availability?.status === "available") { + return { + role: "system", + title: "Code Agent 状态:真实通道已接入", + text: "受控对话通道已接入;发送后只有真实完成的回复才会标记为已完成。", + status: "source" + }; + } + return { + role: "system", + title: "Code Agent 状态:等待 Codex stdio 探测", + text: "正在确认服务状态;首屏不会宣称 Code Agent 可用,也不会把失败或静态内容当成真实回复。", + status: "source" + }; +} + +function codeAgentPromptText(availability) { + if (availability?.status === "blocked") { + return "可以继续输入并保留草稿;发送会走受控对话通道。服务恢复前会显示结构化失败,不会冒充真实 Codex 回复。"; + } + return "请在底部输入中文消息并点击发送;完成态只来自真实完成的后端回复。"; +} + +function codeAgentControlSummary(availability) { + if (latestCompletedAgentMessage()) { + return state.conversationId + ? `当前会话 ${state.conversationId};最近一次真实完成回复来自受控 Code Agent 接口,并带有可核验记录。` + : "最近一次真实完成回复来自受控 Code Agent 接口,并带有可核验记录。"; + } + if (isSourceFixtureCompletedChatMessage(latestChatResult())) { + return "最近一次为本地验证回复;它只证明浏览器对话体验,不作为实况完成证据。"; + } + if (availability?.status === "blocked") { + return codeAgentBlockedSummary(availability); + } + if (availability?.partialReady === true || availability?.runner?.ready === true) { + return `输入区会调用受控接口;完整 Codex stdio 长会话尚未可用时只返回结构化失败,不能用本地 shortcut 或 text fallback 冒充回复。${codeAgentBlockerDetail(availability)}`; + } + return "输入区会调用受控 Code Agent 接口;只有真实完成回复才显示为完成,不能因为只有会话编号就当成实况完成。"; +} + +function codeAgentBlockedSummary(availability) { + const structured = availability?.error?.userMessage ?? availability?.blocker?.userMessage; + if (structured) return structured; + const providerStatus = availability?.error?.providerStatus ?? availability?.providerStatus; + if (providerStatus) { + return `真实后端已接入,但当前上游响应 HTTP ${providerStatus};工作台保持只读和可重试,不会冒充真实 Code Agent 回复。`; + } + const blocker = codeAgentBlockerDetail(availability); + return `真实后端已接入,但 Code Agent 服务暂不可用;完整 Codex stdio Code Agent 仍受阻,工作台保持只读和可重试,不会冒充真实回复。${blocker}`; +} + +function codeAgentBlockerDetail(availability) { + const blockers = codeAgentBlockerCodes(availability); + if (blockers.length === 0 && availability?.reason) blockers.push(availability.reason); + if (blockers.length === 0) return ""; + const labels = blockers.slice(0, 3).map(codeAgentBlockerChineseLabel); + return ` 阻塞:${labels.join(";")}。证据 code=${blockers.slice(0, 3).join(",")}。`; +} + +function codeAgentBlockerCodes(availability) { + const values = [ + ...(Array.isArray(availability?.blockers) ? availability.blockers.map((blocker) => blocker?.code) : []), + ...(Array.isArray(availability?.blockerCodes) ? availability.blockerCodes : []), + ...(Array.isArray(availability?.longLivedSessionGate?.blockers) ? availability.longLivedSessionGate.blockers.map((blocker) => blocker?.code) : []), + ...(Array.isArray(availability?.codexStdioFeasibility?.blockers) ? availability.codexStdioFeasibility.blockers.map((blocker) => blocker?.code) : []), + ...(Array.isArray(availability?.runtimeContract?.stdioProtocol?.missingTools) && availability.runtimeContract.stdioProtocol.missingTools.length > 0 ? ["stdio_protocol_not_wired"] : []), + availability?.runtimeContract?.binary?.status === "missing" ? "codex_cli_binary_missing" : null, + availability?.runtimeContract?.lifecycleSupervisor?.status === "blocked" ? "runner_lifecycle_missing" : null + ].filter(Boolean).map((code) => String(code)); + return [...new Set(values)]; +} + +function codeAgentBlockerChineseLabel(code) { + return { + codex_cli_binary_missing: "未找到受控 Codex CLI binary", + codex_cli_not_executable: "Codex CLI 无法执行 --version", + codex_cli_native_dependency_missing: "Codex CLI native 依赖缺失", + runner_lifecycle_missing: "缺少 repo-owned lifecycle supervisor", + stdio_protocol_not_wired: "Codex stdio 协议尚未接入", + codex_stdio_supervisor_disabled: "Codex stdio supervisor 未启用", + codex_stdio_blocked_readonly_session_available: "旧 partial runner 不能作为 Codex stdio", + controlled_readonly_not_long_lived_stdio: "非 Codex stdio 长会话", + openai_responses_fallback_not_session: "文本 fallback 不是长会话", + one_shot_runner_not_long_lived: "一次性执行不是可复用 session", + provider_token_boundary: "token 边界未配置", + codex_home_missing: "CODEX_HOME 不存在或不可读", + codex_home_write_blocked: "CODEX_HOME 不可写", + workspace_mount_missing: "工作区挂载不可读", + workspace_write_boundary_blocked: "workspace-write 沙箱不可写", + codex_stdio_egress_boundary: "DEV egress 边界不合规" + }[code] ?? code; +} + +function untrustedCompletionMessage(result) { + const provider = result?.provider ?? "unknown"; + return `Code Agent 返回 completed,但缺少真实 provider/model/trace/conversation 证据,或 provider=${provider} 属于 echo/mock/stub;本次不会标记为真实完成。SOURCE fixture 只可显示为 SOURCE 回复,不能冒充 DEV-LIVE。`; +} + +function codeAgentAvailabilitySummary() { + if (state.codeAgentAvailability?.longLivedSessionGate?.status === "pass" && state.codeAgentAvailability?.codexStdioFeasibility?.canStartLongLivedCodexStdio === true) { + return "同源 API 可响应;Codex stdio 长会话 gate 已通过,支持真实 session 复用和 trace capture。"; + } + if (state.codeAgentAvailability?.runner?.ready === true) { + return `同源 API 可响应;完整 Codex stdio 长会话仍受阻,本地 shortcut 与文本 fallback 不会冒充真实 Code Agent 回复。${codeAgentBlockerDetail(state.codeAgentAvailability)}`; + } + if (state.codeAgentAvailability?.status === "blocked") { + return "同源 API 可响应;Code Agent 服务暂不可用,工作台保持只读和可重试。"; + } + if (String(state.codeAgentAvailability?.sourceKind ?? state.codeAgentAvailability?.evidenceLevel ?? "").toUpperCase() === "SOURCE") { + return "同源 API 可响应;当前 Code Agent 回复来源是本地验证记录,仅用于浏览器验证,不是实况完成。"; + } + if (state.codeAgentAvailability?.status === "available") { + return "同源 API 可响应;Code Agent 真实通道已接入,只有真实完成回复才显示为完成。"; + } + return "同源接口可响应。技术复核详情已放在二级页面。"; +} + +function latestCompletedAgentMessage() { + return [...state.chatMessages].reverse().find((message) => + message.role === "agent" && + isRealCompletedChatMessage(message) + ) ?? null; +} + +function isRealCompletedChatResult(result) { + const assistantReply = typeof result?.reply?.content === "string" ? result.reply.content.trim() : ""; + return result?.status === "completed" && assistantReply.length > 0 && !isSourceFixtureCompletion(result) && hasRealCodeAgentEvidence(result); +} + +function isSourceFixtureChatResult(result) { + const assistantReply = typeof result?.reply?.content === "string" ? result.reply.content.trim() : ""; + return result?.status === "completed" && assistantReply.length > 0 && isSourceFixtureCompletion(result); +} + +function isTextFallbackChatResult(result) { + const assistantReply = typeof result?.reply?.content === "string" ? result.reply.content.trim() : ""; + return result?.status === "completed" && + assistantReply.length > 0 && + result?.provider === "openai-responses" && + (result?.capabilityLevel === "text-chat-only" || result?.runner?.kind === "openai-responses-fallback") && + result?.longLivedSessionGate?.status !== "pass"; +} + +function isRealCompletedChatMessage(message) { + return message?.status === "completed" && !message.error && hasRealCodeAgentEvidence(message); +} + +function isSourceFixtureCompletedChatMessage(message) { + return message?.status === "source" && !message.error && message.sourceKind === "SOURCE"; +} + +function hasRealCodeAgentEvidence(value) { + if (isCodexRunnerCapableEvidence(value)) { + return true; + } + if (CODEX_RUNNER_CAPABLE_PROVIDERS.includes(String(value?.provider ?? "").trim().toLowerCase())) { + return false; + } + if (isTextFallbackChatResult(value)) { + return false; + } + return ( + isTrustedCodeAgentProvider(value?.provider) && + Boolean(value?.model) && + Boolean(value?.backend) && + Boolean(value?.traceId) && + Boolean(value?.conversationId || value?.sessionId) && + hasProviderTrace(value) && + Boolean(sessionIdFrom(value)) && + (!requiresCodexThreadEvidence(value) || Boolean(threadIdFrom(value))) && + !UNTRUSTED_CODE_AGENT_PROVIDER_PATTERN.test(`${value?.provider ?? ""} ${value?.backend ?? ""}`) + ); +} + +function isCodexRunnerCapableEvidence(value) { + return ( + CODEX_RUNNER_CAPABLE_PROVIDERS.includes(String(value?.provider ?? "").trim().toLowerCase()) && + value?.runner?.kind === CODEX_APP_SERVER_RUNNER_KIND && + value?.capabilityLevel === "long-lived-codex-stdio-session" && + value?.session?.status === "idle" && + typeof value?.session?.idleTimeoutMs === "number" && + Boolean(value?.session?.lastTraceId) && + value?.sessionMode === CODEX_APP_SERVER_SESSION_MODE && + value?.implementationType === CODEX_APP_SERVER_IMPLEMENTATION_TYPE && + Boolean(value?.sessionReuse) && + hasProviderTrace(value) && + Boolean(sessionIdFrom(value)) && + Boolean(threadIdFrom(value)) && + value?.session?.longLivedSession === true && + value?.session?.codexStdio === true && + value?.runner?.codexStdio === true && + value?.runner?.writeCapable === true && + value?.runner?.durableSession === true && + Boolean(value?.workspace || value?.runner?.workspace) && + ["workspace-write", "danger-full-access"].includes(value?.sandbox || value?.runner?.sandbox) && + Array.isArray(value?.toolCalls) && + value.toolCalls.some((tool) => tool?.status === "completed") && + value?.longLivedSessionGate?.status === "pass" && + value?.providerTrace?.protocol === CODEX_APP_SERVER_PROTOCOL && + /\bcodex\s+app-server\s+--listen\s+stdio:\/\//u.test(String(value?.providerTrace?.command ?? "")) && + ["completed", "succeeded"].includes(String(value?.providerTrace?.terminalStatus ?? "").toLowerCase()) && + Boolean(value?.runnerTrace) && + Boolean(value?.skills) + ); +} + +function isTrustedCodeAgentProvider(provider) { + const normalized = String(provider ?? "").trim().toLowerCase(); + return TRUSTED_CODE_AGENT_PROVIDERS.includes(normalized); +} + +function isSourceFixtureCompletion(value) { + const sourceKind = String(value?.sourceKind ?? value?.evidenceLevel ?? value?.providerTrace?.sourceKind ?? "").trim().toUpperCase(); + const traceSource = String(value?.providerTrace?.source ?? value?.providerTrace?.mode ?? "").trim().toUpperCase(); + return sourceKind === "SOURCE" || traceSource.startsWith("SOURCE"); +} + +function latestChatResult() { + return [...state.chatMessages].reverse().find((message) => message.role === "agent") ?? null; +} + +function deriveAgentChatStatus() { + if (state.chatPending) return "running"; + const latest = latestChatResult(); + if (latest?.status === "completed") return "completed"; + if (latest?.status === "source") return "source"; + if (latest?.status === "canceled") return "canceled"; + if (latest?.status === "timeout") return "timeout"; + if (latest?.status === "error") return "error"; + if (latest?.status === "failed") return latest.error?.code === "provider_unavailable" ? "blocked" : "failed"; + if (state.codeAgentAvailability?.status === "blocked") return "blocked"; + return "idle"; +} + +function currentConversationTone() { + if (latestCompletedAgentMessage()) return "dev-live"; + if (isSourceFixtureCompletedChatMessage(latestChatResult())) return "source"; + if (state.codeAgentAvailability?.status === "blocked" || ["failed", "timeout", "canceled", "error"].includes(latestChatResult()?.status)) return "blocked"; + return "source"; +} + +function statusLabel(value) { + const key = String(value ?? "unknown").trim(); + const normalized = key.toLowerCase(); + return STATUS_LABELS[normalized] ?? key; +} + +function gateStatusLabel(value, statusKey) { + const explicit = String(value ?? "").trim(); + const labels = { + pass: "通过", + blocked: "阻塞", + failed: "失败", + pending: "待验证", + info: "信息" + }; + return labels[statusKey] ?? (["通过", "阻塞", "失败", "待验证", "信息"].includes(explicit) ? explicit : "阻塞"); +} + +function normalizeGateStatusKey(value) { + const text = String(value ?? "").trim().toLowerCase(); + if (["通过", "pass", "passed", "ok", "ready"].includes(text)) return "pass"; + if (["失败", "failed", "failure", "error"].includes(text)) return "failed"; + if (["待验证", "pending", "loading", "unknown"].includes(text)) return "pending"; + if (["信息", "info", "information"].includes(text)) return "info"; + return "blocked"; +} + +function normalizeEvidenceList(value) { + const items = Array.isArray(value) ? value : [value]; + const normalized = items.map(oneLine).filter(Boolean); + return normalized.length > 0 ? normalized : ["live 后端未返回 evidence 字段"]; +} + +function oneLine(value) { + return String(value ?? "").replace(/\s+/gu, " ").trim().slice(0, 240); +} + +function roleLabel(role) { + return ( + { + agent: "Agent", + blocker: "阻塞", + system: "系统", + user: "用户" + }[String(role ?? "").toLowerCase()] ?? statusLabel(role) + ); +} + +function recordKindLabel(kind) { + return ( + { + audit: "审计", + evidence: "证据", + operation: "操作", + record: "记录", + report: "报告", + trace: "轨迹" + }[String(kind ?? "").toLowerCase()] ?? statusLabel(kind) + ); +} + +function internalGatePathnames() { + return new Set(["/gate", "/diagnostics/gate"]); +} + +function helpPathnames() { + return new Set(["/help"]); +} + +function valueOrUnavailable(liveValue, sourceValue) { + if (liveValue !== undefined && liveValue !== null) return liveValue; + if (sourceValue !== undefined && sourceValue !== null) return `${sourceValue} 来源 SOURCE`; + return "不可用"; +} + +function shortTime(value) { + return new Date(value).toISOString().replace("T", " ").replace(/\.\d{3}Z$/, "Z"); +} + +function toneClass(tone) { + return String(tone ?? "unknown").replace(/[^a-z0-9_-]/gi, "-").toLowerCase(); +} + +function replaceChildren(parent, ...children) { + parent.replaceChildren(...children.filter((child) => child && typeof child !== "string")); +} diff --git a/web/hwlab-cloud-web/app-trace.ts b/web/hwlab-cloud-web/app-trace.ts new file mode 100644 index 00000000..29d8b9b1 --- /dev/null +++ b/web/hwlab-cloud-web/app-trace.ts @@ -0,0 +1,777 @@ +function installWorkbenchTestHooks() { + if (!isLocalWorkbenchTestHost() || !new URLSearchParams(window.location.search).has("hwlab-test-hooks")) return; + window.__hwlabWorkbenchTestHooks = { + seedCompletedAgentMessage, + seedTraceMessage, + appendTraceEvents, + reconcileTraceResultFixture, + latestAgentMessageText, + traceScrollMetrics, + traceDomIdentity, + traceBodyDomIdentity, + setTraceBodyScrollTop, + replaceTraceAssistantStream, + setTraceScrollTop, + setConversationScrollTop, + resetProgrammaticScrollWriteCount + }; +} + +function isLocalWorkbenchTestHost() { + return ["127.0.0.1", "localhost", "::1", "[::1]"].includes(window.location.hostname); +} + +function seedCompletedAgentMessage(options = {}) { + state.chatMessages = [{ + id: options.messageId ?? "msg_markdown_contract", + role: "agent", + title: options.title ?? "Code Agent 回复", + text: String(options.text ?? ""), + status: options.status ?? "completed", + traceId: options.traceId ?? "trc_markdown_contract", + conversationId: options.conversationId ?? "cnv_markdown_contract", + sessionId: options.sessionId ?? "ses_markdown_contract" + }]; + renderConversation(); +} + +function seedTraceMessage(options = {}) { + const traceId = options.traceId ?? "trc_scroll_contract"; + const messageId = options.messageId ?? "msg_scroll_contract"; + const count = Math.max(1, Number(options.count ?? 80)); + const events = Array.isArray(options.events) + ? options.events.map((event, index) => normalizeTestTraceEvent(traceId, event, index + 1)) + : Array.from({ length: count }, (_, index) => testTraceEvent(traceId, index + 1)); + state.chatMessages = [{ + id: messageId, + role: "agent", + title: "Code Agent 处理中", + text: "Trace scroll contract fixture", + status: "running", + traceId, + runnerTrace: { + traceId, + events, + assistantStreams: Array.isArray(options.assistantStreams) ? options.assistantStreams : [], + eventCount: Number.isInteger(options.eventCount) ? options.eventCount : events.length, + eventsCompacted: options.eventsCompacted === true, + eventWindow: options.eventWindow ?? null, + fullTraceLoaded: options.fullTraceLoaded === true, + lastEvent: options.lastEvent ?? events.at(-1), + waitingFor: "turn/completed" + } + }]; + renderConversation(); +} + +function normalizeTestTraceEvent(traceId, event, seq) { + return { + traceId, + seq, + createdAt: new Date(1779690000000 + seq * 1000).toISOString(), + ...event, + traceId: event?.traceId ?? traceId, + seq: event?.seq ?? seq, + createdAt: event?.createdAt ?? new Date(1779690000000 + seq * 1000).toISOString() + }; +} + +function appendTraceEvents(count = 1) { + const message = state.chatMessages.find((item) => item.runnerTrace?.traceId); + if (!message) return; + const trace = message.runnerTrace; + const start = Array.isArray(trace.events) ? trace.events.length : 0; + const additions = Array.from({ length: Math.max(1, Number(count)) }, (_, index) => testTraceEvent(trace.traceId, start + index + 1)); + trace.events = [...trace.events, ...additions]; + trace.eventCount = trace.events.length; + trace.lastEvent = trace.events.at(-1); + patchMessageTracePanel(message); +} + +function replaceTraceAssistantStream(stream = {}) { + const message = state.chatMessages.find((item) => item.runnerTrace?.traceId); + if (!message) return null; + const trace = message.runnerTrace; + trace.assistantStreams = [{ + itemId: "assistant-message", + chunkCount: Math.max(1, Number(stream.chunkCount ?? 1)), + createdAt: stream.createdAt ?? new Date(1779699000000).toISOString(), + updatedAt: stream.updatedAt ?? new Date(1779699001000 + Math.max(1, Number(stream.chunkCount ?? 1)) * 1000).toISOString(), + waitingFor: stream.waitingFor ?? "turn/completed", + text: String(stream.text ?? "assistant stream fixture") + }]; + patchMessageTracePanel(message); + return traceBodyDomIdentity(".message-trace-events[data-trace-ui-key] li:last-child pre"); +} + +async function reconcileTraceResultFixture(result) { + const message = state.chatMessages.find((item) => item.runnerTrace?.traceId || item.traceId); + if (!message) return null; + applyCodeAgentResultToMessage(message.id, { + ...(result && typeof result === "object" ? result : {}), + traceId: message.traceId, + conversationId: message.conversationId ?? "cnv_fixture", + sessionId: message.sessionId ?? "ses_fixture", + status: "completed", + provider: "codex-stdio", + model: "gpt-test", + backend: "hwlab-cloud-api/codex-app-server-stdio", + workspace: "/workspace/hwlab", + sandbox: "workspace-write", + capabilityLevel: "long-lived-codex-stdio-session", + runner: { + kind: "codex-app-server-stdio-runner", + codexStdio: true, + writeCapable: true, + durableSession: true, + workspace: "/workspace/hwlab", + sandbox: "workspace-write", + sessionMode: "codex-app-server-stdio-long-lived", + implementationType: "repo-owned-codex-app-server-stdio-session", + capabilityLevel: "long-lived-codex-stdio-session" + }, + session: { + sessionId: message.sessionId ?? "ses_fixture", + status: "idle", + idleTimeoutMs: 1800000, + lastTraceId: message.traceId, + longLivedSession: true, + durableSession: true, + codexStdio: true, + writeCapable: true + }, + sessionMode: "codex-app-server-stdio-long-lived", + implementationType: "repo-owned-codex-app-server-stdio-session", + sessionReuse: { reused: true, sessionId: message.sessionId ?? "ses_fixture" }, + longLivedSessionGate: { status: "pass", pass: true }, + toolCalls: [{ name: "codex-app-server.thread/resume+turn/start", status: "completed" }], + skills: [], + reply: { + content: "fixture reconciled result", + ...(result?.reply && typeof result.reply === "object" ? result.reply : {}) + }, + providerTrace: { + protocol: "codex-app-server-jsonrpc-stdio", + command: "codex app-server --listen stdio://", + terminalStatus: "completed", + ...(result?.providerTrace && typeof result.providerTrace === "object" ? result.providerTrace : {}) + }, + runnerTrace: message.runnerTrace, + updatedAt: new Date().toISOString() + }, { + baseMessage: message, + retryInput: message.retryInput, + retryOf: message.retryOf + }); + renderCodeAgentSummary(); + renderConversation(); + return latestAgentMessageText(); +} + +function latestAgentMessageText() { + const message = [...state.chatMessages].reverse().find((item) => item.role === "agent"); + return message ? { + title: message.title, + text: message.text, + status: message.status, + traceId: message.traceId, + providerTrace: message.providerTrace ?? null, + sourceKind: message.sourceKind ?? null, + capabilityLevel: message.capabilityLevel ?? null, + error: message.error ?? null + } : null; +} + +function testTraceEvent(traceId, seq) { + return { + traceId, + label: "trace:scroll-contract", + type: "trace", + status: "observed", + createdAt: new Date(1779690000000 + seq * 1000).toISOString(), + message: `trace scroll contract event ${seq}\n${"payload ".repeat(18)}`, + waitingFor: "turn/completed" + }; +} + +function traceScrollMetrics() { + const conversation = el.conversationList; + const list = conversation.querySelector(".message-trace-events[data-trace-ui-key]"); + return { + conversationTop: conversation.scrollTop, + conversationBottomGap: scrollBottomGap(conversation), + traceTop: list?.scrollTop ?? null, + traceBottomGap: list ? scrollBottomGap(list) : null, + traceScrollHeight: list?.scrollHeight ?? null, + traceClientHeight: list?.clientHeight ?? null, + traceRowCount: list?.querySelectorAll(".message-trace-row").length ?? 0 + }; +} + +function traceDomIdentity() { + const conversation = el.conversationList; + const panel = conversation.querySelector(".message-trace[data-trace-ui-key]"); + const list = conversation.querySelector(".message-trace-events[data-trace-ui-key]"); + const firstRow = list?.querySelector(".message-trace-row") ?? null; + if (panel && !panel.__hwlabTraceIdentity) panel.__hwlabTraceIdentity = `panel-${Date.now()}-${Math.random()}`; + if (list && !list.__hwlabTraceIdentity) list.__hwlabTraceIdentity = `list-${Date.now()}-${Math.random()}`; + if (firstRow && !firstRow.__hwlabTraceIdentity) firstRow.__hwlabTraceIdentity = `row-${Date.now()}-${Math.random()}`; + return { + panel: panel?.__hwlabTraceIdentity ?? null, + list: list?.__hwlabTraceIdentity ?? null, + firstRow: firstRow?.__hwlabTraceIdentity ?? null, + rowCount: list?.querySelectorAll(".message-trace-row").length ?? 0, + programmaticScrollWrites: state.traceProgrammaticScrollWrites + }; +} + +function traceBodyDomIdentity(selector = ".message-trace-events[data-trace-ui-key] li:last-child pre") { + 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()}`; + if (row && !row.__hwlabTraceIdentity) row.__hwlabTraceIdentity = `row-${Date.now()}-${Math.random()}`; + return { + selector, + body: body?.__hwlabTraceIdentity ?? null, + row: row?.__hwlabTraceIdentity ?? null, + rowId: row?.dataset.traceRowId ?? null, + top: body?.scrollTop ?? null, + left: body?.scrollLeft ?? null, + scrollHeight: body?.scrollHeight ?? null, + clientHeight: body?.clientHeight ?? null, + text: body?.textContent ?? null + }; +} + +function resetProgrammaticScrollWriteCount() { + state.traceProgrammaticScrollWrites = 0; + return state.traceProgrammaticScrollWrites; +} + +function setTraceScrollTop(top, { user = true } = {}) { + const list = el.conversationList.querySelector(".message-trace-events[data-trace-ui-key]"); + if (!list) return traceScrollMetrics(); + if (user) markTraceScrollIntent(list.dataset.traceUiKey); + list.scrollTop = top; + rememberTraceScrollPosition(list.dataset.traceUiKey, list); + return traceScrollMetrics(); +} + +function setTraceBodyScrollTop(selector, top, { user = true } = {}) { + const body = el.conversationList.querySelector(selector); + if (!body) return traceBodyDomIdentity(selector); + if (user) markTraceBodyScrollIntent(body); + body.scrollTop = top; + rememberTraceBodyScrollPosition(body); + return traceBodyDomIdentity(selector); +} + +function setConversationScrollTop(top, { user = true } = {}) { + if (user) markConversationScrollIntent(); + el.conversationList.scrollTop = top; + captureConversationScrollPosition(); + return traceScrollMetrics(); +} + +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 = []; + }; + for (const event of events) { + if (isNoisyTraceEvent(event)) { + flushToolOutputRun(); + noisyRun.push(event); + continue; + } + if (isToolOutputChunkTraceEvent(event)) { + flushNoisyRun(); + toolOutputRun.push(event); + continue; + } + flushNoisyRun(); + flushToolOutputRun(); + const row = traceDisplayRow(trace, event); + if (!row) continue; + 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 traceDisplayRow(trace, event, options = {}) { + if (!options.includeNoise && isNoisyTraceEvent(event)) return null; + if (isCommandExecutionTraceEvent(event)) return traceCommandExecutionRow(trace, event); + const clock = traceClock(event.createdAt); + const total = formatTraceDuration(traceRelativeMs(trace, event)); + const status = traceStatusToken(event); + const label = readableTraceLabel(event); + const meta = [ + status, + event.errorCode ? `error=${event.errorCode}` : null, + typeof event.timeoutMs === "number" ? `timeout=${formatTraceDuration(event.timeoutMs)}` : null, + typeof event.idleMs === "number" ? `idle=${formatTraceDuration(event.idleMs)}` : null, + event.outputSummary ? `out=${compactTraceSize(event.outputSummary)}` : null + ].filter(Boolean).join(" "); + const body = traceDisplayBody(event); + return { + rowId: `event:${event.seq ?? `${event.label ?? event.type ?? "event"}:${event.createdAt ?? "unknown"}`}`, + seq: event.seq ?? null, + tone: traceEventTone(event), + header: `${clock} total=${total}${meta ? ` ${meta}` : ""}${label ? ` ${label}` : ""}`, + body + }; +} + +function traceCommandExecutionRow(trace, event) { + const clock = traceClock(event.createdAt); + const total = formatTraceDuration(traceRelativeMs(trace, event)); + const status = traceStatusToken(event); + const command = cleanTraceOutputText(event.command ?? ""); + const meta = [ + status, + "tool cmd", + command ? `:: ${compactTraceTextTail(command, 220)}` : null, + Number.isInteger(event.exitCode) ? `exit=${event.exitCode}` : null, + typeof event.durationMs === "number" ? `s=${(event.durationMs / 1000).toFixed(1)}` : null, + typeof event.outputBytes === "number" ? `out=${formatTraceBytes(event.outputBytes)}` : null, + event.itemId ? `item=${event.itemId}` : null + ].filter(Boolean).join(" "); + return { + rowId: `event:${event.seq ?? `${event.label ?? event.type ?? "command"}:${event.createdAt ?? "unknown"}`}`, + seq: event.seq ?? null, + tone: traceEventTone(event), + header: `${clock} total=${total} ${meta}`, + body: traceCommandExecutionBody(event) + }; +} + +function traceCommandExecutionBody(event) { + const lines = [ + event.command ? `command=${compactTraceTextTail(cleanTraceOutputText(event.command), 900)}` : null, + event.stdoutSummary ? `stdout:\n${compactTraceTextTail(cleanTraceOutputText(event.stdoutSummary), 1200)}` : null, + event.stderrSummary ? `stderr:\n${compactTraceTextTail(cleanTraceOutputText(event.stderrSummary), 800)}` : null, + event.waitingFor && event.status !== "completed" ? `waiting=${event.waitingFor}` : null + ].filter(Boolean); + return lines.join("\n"); +} + +function isCommandExecutionTraceEvent(event) { + return event?.type === "tool_call" && event?.toolName === "commandExecution" && + (event?.label === "item/commandExecution:started" || event?.label === "item/commandExecution:completed"); +} + +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" || + (event?.type === "tool_call" && event?.status === "output_chunk" && !String(event?.toolName ?? "").startsWith("codex-app-server")); +} + +function readableTraceLabel(event) { + const label = String(event?.label ?? `${event?.type ?? "event"}:${event?.status ?? "observed"}`); + if (label === "request:accepted-short-connection") return "submit short-connection"; + if (label === "request:accepted") return "request accepted"; + if (label.startsWith("tool:codex-app-server.thread/start+turn/start")) return "codex turn start"; + if (label.startsWith("tool:codex-app-server.thread/resume+turn/start")) return "codex turn resume"; + if (label === "item/commandExecution:started") return "cmd started"; + if (label === "item/commandExecution:completed") return "cmd completed"; + if (label.startsWith("item/commandExecution/outputDelta")) return "cmd output"; + if (label === "turn:waiting:first_assistant_token") return "waiting first assistant token"; + if (label.startsWith("turn:completed")) return "turn completed"; + if (label.startsWith("result:completed")) return "result ready"; + if (label.startsWith("result:failed")) return "result failed"; + if (label.startsWith("timeout:")) return "timeout"; + if (label.startsWith("cancel:")) return "cancel"; + return label + .replace(/^tool:/u, "") + .replace(/^assistant:/u, "assistant ") + .replace(/:completed:completed$/u, " completed") + .replace(/:completed$/u, " completed") + .replace(/:started$/u, " started"); +} + +function traceDisplayBody(event) { + const lines = [ + event.promptSummary ? cleanTraceText(event.promptSummary) : null, + event.outputSummary ? cleanTraceOutputText(event.outputSummary) : null, + event.message ? cleanTraceText(event.message) : null, + event.chunk && !isNoisyTraceEvent(event) ? cleanTraceOutputText(event.chunk) : null, + event.waitingFor && event.status !== "completed" ? `waiting=${event.waitingFor}` : null + ].filter(Boolean); + return lines.join("\n").slice(0, 1400); +} + +function rawTraceOutputText(event) { + return String(event?.outputSummary ?? event?.chunk ?? event?.message ?? ""); +} + +function parseGatewayJsonRpcOutputs(text) { + const payloads = parseJsonObjects(text); + return payloads + .filter((payload) => payload && payload.jsonrpc === "2.0" && payload.result && typeof payload.result === "object") + .map((payload) => { + const result = payload.result; + const dispatch = result.dispatch && typeof result.dispatch === "object" ? result.dispatch : {}; + if (!("gatewaySessionId" in result) && !("capabilityId" in result) && !("shellExecuted" in dispatch)) return null; + return { payload, result, dispatch }; + }) + .filter(Boolean); +} + +function parseGatewayJsonRpcOutput(text) { + return parseGatewayJsonRpcOutputs(text)[0] ?? null; +} + +function parseJsonObjects(text) { + const source = String(text ?? ""); + const values = []; + let searchFrom = 0; + while (searchFrom < source.length) { + const start = source.indexOf("{", searchFrom); + if (start < 0) break; + const end = jsonObjectEnd(source, start); + if (end < 0) break; + try { + values.push(JSON.parse(source.slice(start, end + 1))); + searchFrom = end + 1; + } catch { + searchFrom = start + 1; + } + } + return values; +} + +function parseFirstJsonObject(text) { + const source = String(text ?? ""); + const start = source.indexOf("{"); + if (start < 0) return null; + const end = jsonObjectEnd(source, start); + if (end < 0) return null; + try { + return JSON.parse(source.slice(start, end + 1)); + } catch { + return null; + } +} + +function jsonObjectEnd(source, start) { + let depth = 0; + let inString = false; + let escaped = false; + for (let index = start; index < source.length; index += 1) { + const char = source[index]; + if (inString) { + if (escaped) { + escaped = false; + } else if (char === "\\") { + escaped = true; + } else if (char === "\"") { + inString = false; + } + continue; + } + if (char === "\"") { + inString = true; + continue; + } + if (char === "{") depth += 1; + if (char === "}") depth -= 1; + if (depth === 0) { + return index; + } + } + return -1; +} + +function gatewayJsonRpcDisplayBody({ payload, result, dispatch, raw }) { + const stdout = cleanTraceOutputText(dispatch.stdout); + const stderr = cleanTraceOutputText(dispatch.stderr); + const error = dispatch.error?.message ?? dispatch.error ?? dispatch.message ?? result.message ?? null; + return [ + `request=${payload.id ?? "unknown"}`, + `gateway=${result.gatewaySessionId ?? "unknown"} resource=${result.resourceId ?? "unknown"} capability=${result.capabilityId ?? "unknown"}`, + `dispatch=${dispatch.dispatchStatus ?? result.status ?? "unknown"} shellExecuted=${dispatch.shellExecuted === true ? "true" : "false"} timedOut=${dispatch.timedOut === true ? "true" : "false"}`, + error ? `error=${compactTraceTextTail(cleanTraceOutputText(error), 600)}` : null, + dispatch.command ? `command=${compactTraceTextTail(cleanTraceOutputText(dispatch.command), 420)}` : null, + result.auditId ? `audit=${result.auditId}` : null, + result.evidenceId ? `evidence=${result.evidenceId}` : null, + dispatch.stdoutTruncated === true ? "stdoutTruncated=true" : null, + stdout ? `stdout:\n${compactTraceTextTail(stdout, 1200)}` : null, + stderr ? `stderr:\n${compactTraceTextTail(stderr, 800)}` : null, + !stdout && !stderr && raw ? compactTraceTextTail(cleanTraceOutputText(raw), 900) : null + ].filter(Boolean).join("\n"); +} + +function traceStatusToken(event) { + const status = String(event?.status ?? ""); + if (status === "completed" || status === "succeeded") return "ok"; + if (["failed", "blocked", "error", "timeout", "canceled"].includes(status) || event?.errorCode) return "fail"; + if (["started", "running", "accepted"].includes(status)) return "run"; + return status || "event"; +} + +function traceEventTone(event) { + const status = traceStatusToken(event); + if (status === "ok") return "ok"; + if (status === "fail") return "blocked"; + if (status === "run") return "warn"; + return "source"; +} + +function traceClock(value) { + const date = new Date(value); + return Number.isNaN(date.getTime()) ? "--:--:--" : date.toISOString().slice(11, 19); +} + +function traceRelativeMs(trace, event) { + if (typeof event?.elapsedMs === "number") return event.elapsedMs; + const start = Date.parse(trace?.startedAt ?? trace?.createdAt ?? ""); + const current = Date.parse(event?.createdAt ?? ""); + return Number.isNaN(start) || Number.isNaN(current) ? 0 : Math.max(0, current - start); +} + +function formatTraceDuration(ms) { + const total = Math.max(0, Math.floor(Number(ms) || 0)); + const seconds = Math.floor(total / 1000); + const hh = String(Math.floor(seconds / 3600)).padStart(2, "0"); + const mm = String(Math.floor((seconds % 3600) / 60)).padStart(2, "0"); + const ss = String(seconds % 60).padStart(2, "0"); + return `${hh}:${mm}:${ss}`; +} + +function compactTraceSize(value) { + const length = String(value ?? "").length; + if (length >= 1000) return `${(length / 1000).toFixed(length >= 10000 ? 0 : 1)}k`; + return String(length); +} + +function formatTraceBytes(value) { + const bytes = Math.max(0, Number(value) || 0); + if (bytes >= 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)}MiB`; + if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)}KiB`; + return `${bytes}B`; +} + +function cleanTraceText(value) { + const text = String(value ?? "").trim(); + if (!text) return ""; + return text + .replace(/\s*\/\s*/gu, " / ") + .replace(/[ \t]{2,}/gu, " ") + .replace(/\n{3,}/gu, "\n\n"); +} + +function cleanTraceOutputText(value) { + const text = String(value ?? "").trim(); + if (!text) return ""; + return text + .replace(/[ \t]{2,}/gu, " ") + .replace(/\n{3,}/gu, "\n\n"); +} + +function traceActionButton(label, onClick, title) { + const button = document.createElement("button"); + button.type = "button"; + button.className = "message-trace-action"; + button.textContent = label; + button.title = title; + button.addEventListener("click", onClick); + return button; +} + +function messageTraceJson(message, trace, events) { + return JSON.stringify({ + traceId: trace?.traceId ?? message.traceId ?? null, + messageId: message.id ?? null, + eventCount: Number.isInteger(trace?.eventCount) ? trace.eventCount : events.length, + eventsCompacted: trace?.eventsCompacted === true, + eventWindow: trace?.eventWindow ?? null, + lastEvent: trace?.lastEvent ?? events.at(-1) ?? null, + assistantStreams: Array.isArray(trace?.assistantStreams) ? trace.assistantStreams : [], + events + }, null, 2); +} + +function downloadTraceJson(message, trace, events) { + const traceId = String(trace?.traceId ?? message.traceId ?? "trace") + .replace(/[^A-Za-z0-9_.-]+/gu, "-") + .replace(/^-|-$/gu, "") + .slice(0, 96) || "trace"; + const blob = new Blob([messageTraceJson(message, trace, events)], { type: "application/json" }); + const href = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = href; + anchor.download = `${traceId}.json`; + document.body.append(anchor); + anchor.click(); + anchor.remove(); + window.setTimeout(() => URL.revokeObjectURL(href), 0); +} + +function runnerTraceHeadline(message, trace) { + const count = Number.isInteger(trace?.eventCount) ? trace.eventCount : Array.isArray(trace?.events) ? trace.events.length : 0; + const last = trace?.lastEvent?.label ?? trace?.eventLabels?.at?.(-1) ?? "等待事件"; + const elapsed = typeof trace?.elapsedMs === "number" ? ` / ${trace.elapsedMs}ms` : ""; + const waiting = trace?.waitingFor ? ` / 等待 ${trace.waitingFor}` : ""; + const thread = trace?.threadId ?? message.threadId ? ` / thread=${trace?.threadId ?? message.threadId}` : ""; + return `运行 trace ${trace?.traceId ?? message.traceId ?? "pending"} / events=${count} / last=${last}${elapsed}${waiting}${thread}`; +} diff --git a/web/hwlab-cloud-web/app.mjs b/web/hwlab-cloud-web/app.mjs deleted file mode 100644 index dc6f1a9c..00000000 --- a/web/hwlab-cloud-web/app.mjs +++ /dev/null @@ -1,5268 +0,0 @@ -import { ensureWorkbenchAuth, initWorkbenchLogout } from "./auth.mjs"; -import { - codeAgentRuntimePathFromMessage, - runnerTraceSummary as compactRunnerTraceSummary, - skillsSummary as compactSkillsSummary, - toolCallsSummary as compactToolCallsSummary -} from "./code-agent-facts.mjs"; -import { classifyCodeAgentStatusSummary, codeAgentAvailabilityFromLive } from "./code-agent-status.mjs"; -import { classifyWorkbenchLiveStatus } from "./live-status.mjs"; -import { marked } from "./third_party/marked/marked.esm.js"; -import { hardenRenderedMarkdown, renderMessageMarkdown } from "./message-markdown.mjs"; - -const DEFAULT_API_TIMEOUT_MS = 4500; -const WORKBENCH_PROJECT_ID = "prj_device_pod_workbench"; -const DEFAULT_LIVE_SURFACE_TIMEOUT_MS = 12000; -const DEFAULT_CODE_AGENT_TIMEOUT_MS = 1800000; -const MAX_CODE_AGENT_TIMEOUT_MS = 2400000; -const DEFAULT_CODE_AGENT_SUBMIT_TIMEOUT_MS = 60000; -const DEFAULT_CODE_AGENT_CANCEL_TIMEOUT_MS = 30000; -const DEFAULT_GATEWAY_SHELL_TIMEOUT_MS = 120000; -const CODE_AGENT_TIMEOUT_STORAGE_KEY = "hwlab.workbench.codeAgentTimeoutMs.v1"; -const GATEWAY_SHELL_TIMEOUT_STORAGE_KEY = "hwlab.workbench.gatewayShellTimeoutMs.v1"; -const CODE_AGENT_PROVIDER_PROFILE_STORAGE_KEY = "hwlab.workbench.codeAgentProviderProfile.v1"; -const DEFAULT_CODE_AGENT_PROVIDER_PROFILE = "deepseek"; -const CODE_AGENT_PROVIDER_PROFILES = Object.freeze(["deepseek", "codex-api"]); -const CODE_AGENT_SESSION_STORAGE_KEY = "hwlab.workbench.codeAgentSession.v1"; -const CODE_AGENT_SESSION_STORAGE_VERSION = 1; -const CODE_AGENT_SESSION_STORAGE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; -const CODE_AGENT_SESSION_STORAGE_MESSAGE_LIMIT = 30; -const CODE_AGENT_SESSION_STORAGE_TRACE_EVENT_LIMIT = 80; -const CODE_AGENT_SESSION_PERSIST_DEBOUNCE_MS = 250; -const TRACE_STREAM_FALLBACK_MS = 2500; -const TRACE_POLL_INTERVAL_MS = 1000; -const FULL_TRACE_REPLAY_TIMEOUT_MS = 15000; -const API_TIMEOUT_MS = resolveTimeoutMs("apiTimeoutMs", DEFAULT_API_TIMEOUT_MS, { min: 500, max: 30000 }); -const LIVE_SURFACE_TIMEOUT_MS = resolveTimeoutMs("liveSurfaceTimeoutMs", DEFAULT_LIVE_SURFACE_TIMEOUT_MS, { min: 5000, max: 60000 }); -let CODE_AGENT_TIMEOUT_MS = resolveUserCodeAgentTimeoutMs(); -let GATEWAY_SHELL_TIMEOUT_MS = resolveUserGatewayShellTimeoutMs(); -let CODE_AGENT_PROVIDER_PROFILE = resolveUserCodeAgentProviderProfile(); -const CODE_AGENT_SUBMIT_TIMEOUT_MS = resolveTimeoutMs("codeAgentSubmitTimeoutMs", DEFAULT_CODE_AGENT_SUBMIT_TIMEOUT_MS, { min: configuredTimeoutMinMs("codeAgentSubmitTimeoutMs", 5000), max: 120000 }); -const CODE_AGENT_CANCEL_TIMEOUT_MS = resolveTimeoutMs("codeAgentCancelTimeoutMs", DEFAULT_CODE_AGENT_CANCEL_TIMEOUT_MS, { min: 5000, max: 120000 }); -const rpcReadMethods = Object.freeze([ - "system.health", - "cloud.adapter.describe" -]); - -const STATUS_LABELS = Object.freeze({ - active: "活动", - available: "可用", - blocked_after_cloud_api: "cloud-api 后阻塞", - blocked: "待处理", - canceled: "已取消", - completed: "完成", - connected: "已连接", - degraded: "降级", - disabled: "禁用", - entry: "入口", - failed: "失败", - healthy: "健康", - live: "实况", - ok: "正常", - open: "未关闭", - pass: "通过", - pending: "等待", - running: "处理中", - timeout: "超时", - error: "错误", - probing: "探测中", - ready: "就绪", - recorded: "已记录", - sent: "已发送", - requires: "依赖", - source: "来源", - unverified: "未验证", - "dry-run": "演练", - "dev-live": "实况已验证" -}); - - -const TRUSTED_CODE_AGENT_PROVIDERS = Object.freeze(["codex-stdio"]); -const CODEX_RUNNER_CAPABLE_PROVIDERS = Object.freeze(["codex-stdio"]); -const CODEX_APP_SERVER_RUNNER_KIND = "codex-app-server-stdio-runner"; -const CODEX_APP_SERVER_SESSION_MODE = "codex-app-server-stdio-long-lived"; -const CODEX_APP_SERVER_IMPLEMENTATION_TYPE = "repo-owned-codex-app-server-stdio-session"; -const CODEX_APP_SERVER_PROTOCOL = "codex-app-server-jsonrpc-stdio"; -const CODEX_STDIO_BLOCKER_MARKERS = Object.freeze([ - "codex_cli_binary_missing", - "runner_lifecycle_missing", - "stdio_protocol_not_wired", - "codex_stdio_supervisor_disabled", - "not-codex-stdio", - "not-write-capable", - "process-local-session-registry" -]); -const UNTRUSTED_CODE_AGENT_PROVIDER_PATTERN = /\b(?:echo|mock|fixture|stub|sample)\b/iu; -const LAYOUT_STORAGE_KEY = "hwlab.workbench.layout.v1"; -const LEFT_SIDEBAR_DEFAULT_WIDTH = 92; -const LEFT_SIDEBAR_MIN_WIDTH = 72; -const LEFT_SIDEBAR_MAX_WIDTH = 180; -const RIGHT_SIDEBAR_DEFAULT_WIDTH = 728; -const RIGHT_SIDEBAR_MIN_WIDTH = 560; -const RIGHT_SIDEBAR_MAX_WIDTH = 740; -const SCROLL_BOTTOM_PIN_PX = 24; -const SCROLL_USER_ACTIVITY_MS = 700; -const MESSAGE_MARKDOWN_STATUSES = Object.freeze(["completed", "source"]); - -const viewIds = new Set([...document.querySelectorAll("[data-view]")].map((view) => view.dataset.view)); -let rpcSequence = 0; - -const el = { - loginShell: byId("login-shell"), - shell: query("[data-app-shell]"), - logoutButton: byId("logout-button"), - leftSidebarToggle: byId("left-sidebar-toggle"), - leftSidebarResize: byId("left-sidebar-resize"), - rightSidebarResize: byId("right-sidebar-resize"), - routePath: byId("route-path"), - liveStatus: byId("live-status"), - liveDetail: byId("live-detail"), - liveBuildSummary: byId("live-build-summary"), - liveBuildToggle: byId("live-build-toggle"), - liveBuildLatest: byId("live-build-latest"), - liveBuildList: byId("live-build-list"), - agentChatStatus: byId("agent-chat-status"), - codeAgentSummary: byId("code-agent-summary"), - codeAgentSummaryIcon: byId("code-agent-summary-icon"), - codeAgentSummaryLabel: byId("code-agent-summary-label"), - codeAgentSummaryCapability: byId("code-agent-summary-capability"), - codeAgentSummaryTrace: byId("code-agent-summary-trace"), - codeAgentSummaryDetail: byId("code-agent-summary-detail"), - conversationList: byId("conversation-list"), - gateSourceStatus: byId("gate-source-status"), - gateStatusFilter: byId("gate-status-filter"), - gateSearch: byId("gate-search"), - gateRefresh: byId("gate-refresh"), - gateReviewBody: byId("gate-review-body"), - commandForm: byId("command-form"), - commandInput: byId("command-input"), - codeAgentProviderProfile: byId("code-agent-provider-profile"), - codeAgentTimeout: byId("code-agent-timeout"), - gatewayShellTimeout: byId("gateway-shell-timeout"), - commandSend: byId("command-send"), - commandClear: byId("command-clear"), - devicePodSelect: byId("device-pod-select"), - devicePodStatusTag: byId("device-pod-status-tag"), - devicePodSummary: byId("device-pod-summary"), - devicePodId: byId("device-pod-id"), - devicePodTarget: byId("device-pod-target"), - devicePodProfile: byId("device-pod-profile"), - devicePodFreshness: byId("device-pod-freshness"), - devicePodInterfaces: byId("device-pod-interfaces"), - deviceEventFollow: byId("device-event-follow"), - deviceEventJump: byId("device-event-jump"), - deviceEventScroll: byId("device-event-scroll"), - deviceEventText: byId("device-event-text"), - deviceDetailDialog: byId("device-detail-dialog"), - deviceDetailTitle: byId("device-detail-title"), - deviceDetailBody: byId("device-detail-body"), - helpContent: byId("help-content"), - helpStatus: byId("help-status") -}; - -let activeWorkbenchDialog = null; - -await ensureWorkbenchAuth({ - loginShell: el.loginShell, - appShell: el.shell -}); - -const state = { - conversationId: null, - sessionId: null, - threadId: null, - codeAgentAvailability: null, - chatMessages: [], - traceStreams: new Map(), - traceDetailsOpen: new Map(), - conversationRenderVersion: 0, - conversationScrollUserActiveUntil: 0, - conversationScrollPosition: { top: 0, left: 0 }, - traceScrollPositions: new Map(), - traceScrollUserActiveUntil: new Map(), - traceBodyScrollPositions: new Map(), - traceBodyScrollUserActiveUntil: new Map(), - traceProgrammaticScrollWrites: 0, - fullTraceReplayInFlight: new Set(), - resultReconciliationInFlight: new Set(), - canceledTraces: new Set(), - currentRequest: null, - sessionStatus: null, - chatPending: false, - liveSurface: null, - gateDiagnostics: { - loading: false, - payload: null, - rows: [], - error: null, - loadedAt: null - }, - devicePod: { - selectedDevicePodId: "device-pod-71-freq", - list: null, - status: null, - events: null, - chipId: null, - uart: null, - uartTail: null, - followEvents: true, - unreadEvents: 0, - lastEventLineCount: 0, - eventScrollUserActiveUntil: 0, - details: new Map() - }, - layout: { - leftSidebarCollapsed: false, - leftSidebarWidth: LEFT_SIDEBAR_DEFAULT_WIDTH, - leftDrag: null, - rightSidebarWidth: RIGHT_SIDEBAR_DEFAULT_WIDTH, - rightDrag: null - } -}; - -let codeAgentSessionPersistTimer = null; - -restoreCodeAgentSessionState(); - -initRoutes(); -initLayoutSizing(); -initLeftSidebarToggle(); -initLeftSidebarResize(); -initRightSidebarResize(); -initDevicePodPanel(); -initQuickActions(); -initCodeAgentTimeoutControl(); -initConversationScrollMemory(); -initCommandBar(); -initLiveBuildOverlay(); -initWorkbenchLogout(el.logoutButton); -el.logoutButton.addEventListener("click", clearCodeAgentSessionState); -initGateControls(); -installWorkbenchTestHooks(); -renderStaticWorkbench(); -renderProbePending(); -renderCodeAgentSummary(); -loadHelpSurface(); -loadLiveSurface().then(renderLiveSurface); -window.setTimeout(refreshRestoredCodeAgentTraces, 0); - -function byId(id) { - const element = document.getElementById(id); - if (!element) { - throw new Error(`missing required element #${id}`); - } - return element; -} - -function query(selector) { - const element = document.querySelector(selector); - if (!element) { - throw new Error(`missing required element ${selector}`); - } - return element; -} - -function resolveTimeoutMs(name, fallback, { min, max }) { - const config = globalThis.HWLAB_CLOUD_WEB_CONFIG?.timeouts ?? globalThis.HWLAB_CLOUD_WEB_TIMEOUTS ?? {}; - const configured = Number(config?.[name]); - if (!Number.isFinite(configured)) return fallback; - return Math.min(Math.max(Math.trunc(configured), min), max); -} - -function configuredTimeoutMinMs(name, fallback) { - const config = globalThis.HWLAB_CLOUD_WEB_CONFIG?.timeouts ?? globalThis.HWLAB_CLOUD_WEB_TIMEOUTS ?? {}; - const configured = Number(config?.[`${name}MinMs`]); - if (!Number.isFinite(configured)) return fallback; - return Math.max(1, Math.min(Math.trunc(configured), fallback)); -} - -function clampTimeoutMs(value, { min, max }) { - if (value === null || value === undefined) return null; - if (typeof value === "string" && value.trim() === "") return null; - const numeric = Number(value); - if (!Number.isFinite(numeric)) return null; - return Math.min(Math.max(Math.trunc(numeric), min), max); -} - -function resolveUserCodeAgentTimeoutMs() { - const min = configuredTimeoutMinMs("codeAgentTimeoutMs", 30000); - let stored = null; - try { - stored = window.localStorage?.getItem(CODE_AGENT_TIMEOUT_STORAGE_KEY) ?? null; - } catch { - stored = null; - } - return clampTimeoutMs(stored, { min, max: MAX_CODE_AGENT_TIMEOUT_MS }) - ?? resolveTimeoutMs("codeAgentTimeoutMs", DEFAULT_CODE_AGENT_TIMEOUT_MS, { min, max: MAX_CODE_AGENT_TIMEOUT_MS }); -} - -function resolveUserGatewayShellTimeoutMs() { - let stored = null; - try { - stored = window.localStorage?.getItem(GATEWAY_SHELL_TIMEOUT_STORAGE_KEY) ?? null; - } catch { - stored = null; - } - return clampTimeoutMs(stored, { min: 30000, max: 600000 }) - ?? resolveTimeoutMs("gatewayShellTimeoutMs", DEFAULT_GATEWAY_SHELL_TIMEOUT_MS, { min: 30000, max: 600000 }); -} - -function resolveUserCodeAgentProviderProfile() { - let stored = null; - try { - stored = window.localStorage?.getItem(CODE_AGENT_PROVIDER_PROFILE_STORAGE_KEY) ?? null; - } catch { - stored = null; - } - return normalizeCodeAgentProviderProfile(stored); -} - -function normalizeCodeAgentProviderProfile(value) { - const text = String(value ?? DEFAULT_CODE_AGENT_PROVIDER_PROFILE).trim().toLowerCase(); - return CODE_AGENT_PROVIDER_PROFILES.includes(text) ? text : DEFAULT_CODE_AGENT_PROVIDER_PROFILE; -} - -function initCodeAgentTimeoutControl() { - syncCodeAgentTimeoutControl(); - el.codeAgentProviderProfile.addEventListener("change", () => { - CODE_AGENT_PROVIDER_PROFILE = normalizeCodeAgentProviderProfile(el.codeAgentProviderProfile.value); - try { - window.localStorage?.setItem(CODE_AGENT_PROVIDER_PROFILE_STORAGE_KEY, CODE_AGENT_PROVIDER_PROFILE); - } catch { - // localStorage can be unavailable in hardened browser modes. - } - syncCodeAgentTimeoutControl(); - renderCodeAgentSummary(); - }); - el.codeAgentTimeout.addEventListener("change", () => { - const next = clampTimeoutMs(el.codeAgentTimeout.value, { min: 30000, max: MAX_CODE_AGENT_TIMEOUT_MS }) ?? DEFAULT_CODE_AGENT_TIMEOUT_MS; - CODE_AGENT_TIMEOUT_MS = next; - try { - window.localStorage?.setItem(CODE_AGENT_TIMEOUT_STORAGE_KEY, String(next)); - } catch { - // localStorage can be unavailable in hardened browser modes. - } - syncCodeAgentTimeoutControl(); - renderCodeAgentSummary(); - renderConversation(); - }); - el.gatewayShellTimeout.addEventListener("change", () => { - const next = clampTimeoutMs(el.gatewayShellTimeout.value, { min: 30000, max: 600000 }) ?? DEFAULT_GATEWAY_SHELL_TIMEOUT_MS; - GATEWAY_SHELL_TIMEOUT_MS = next; - try { - window.localStorage?.setItem(GATEWAY_SHELL_TIMEOUT_STORAGE_KEY, String(next)); - } catch { - // localStorage can be unavailable in hardened browser modes. - } - syncCodeAgentTimeoutControl(); - renderCodeAgentSummary(); - }); -} - -function syncCodeAgentTimeoutControl() { - el.codeAgentProviderProfile.value = CODE_AGENT_PROVIDER_PROFILE; - el.codeAgentProviderProfile.title = CODE_AGENT_PROVIDER_PROFILE === "codex-api" - ? "本次 Code Agent 请求使用原 Codex API / Responses 通道" - : "本次 Code Agent 请求使用 G14 DeepSeek Responses bridge"; - el.codeAgentTimeout.value = String(CODE_AGENT_TIMEOUT_MS); - el.codeAgentTimeout.title = `Code Agent 无新事件超过 ${formatTraceDuration(CODE_AGENT_TIMEOUT_MS)} 后才显示为超时`; - el.gatewayShellTimeout.value = String(GATEWAY_SHELL_TIMEOUT_MS); - el.gatewayShellTimeout.title = `Code Agent 调用 PC gateway wrapper 时默认使用 --timeout-ms ${GATEWAY_SHELL_TIMEOUT_MS}`; -} - -function restoreCodeAgentSessionState() { - let payload = null; - try { - const raw = window.localStorage?.getItem(CODE_AGENT_SESSION_STORAGE_KEY); - payload = raw ? JSON.parse(raw) : null; - } catch { - payload = null; - } - if (!payload || payload.version !== CODE_AGENT_SESSION_STORAGE_VERSION) return; - const updatedAtMs = Number(payload.updatedAtMs); - if (!Number.isFinite(updatedAtMs) || Date.now() - updatedAtMs > CODE_AGENT_SESSION_STORAGE_MAX_AGE_MS) { - clearCodeAgentSessionState(); - return; - } - state.conversationId = nonEmptyString(payload.conversationId); - state.sessionId = nonEmptyString(payload.sessionId); - state.threadId = nonEmptyString(payload.threadId); - state.sessionStatus = nonEmptyString(payload.sessionStatus); - state.chatMessages = Array.isArray(payload.chatMessages) - ? payload.chatMessages.map(restoreStoredChatMessage).filter(Boolean) - : []; - reconcileRestoredCodeAgentResults(); -} - -function restoreStoredChatMessage(message) { - if (!message || typeof message !== "object") return null; - const restored = { ...message }; - restored.id = nonEmptyString(restored.id) ?? nextProtocolId("msg"); - restored.role = restored.role === "user" ? "user" : restored.role === "system" ? "system" : "agent"; - restored.title = nonEmptyString(restored.title) ?? (restored.role === "user" ? "用户" : "Agent"); - restored.text = typeof restored.text === "string" ? restored.text : ""; - restored.status = nonEmptyString(restored.status) ?? "source"; - if (restored.status === "running") restored.status = "source"; - restored.conversationId = nonEmptyString(restored.conversationId) ?? state.conversationId; - restored.sessionId = nonEmptyString(restored.sessionId) ?? state.sessionId; - restored.threadId = nonEmptyString(restored.threadId) ?? state.threadId; - restored.traceId = nonEmptyString(restored.traceId); - if (restored.runnerTrace && typeof restored.runnerTrace === "object") { - restored.runnerTrace = restoreStoredRunnerTrace(restored.runnerTrace); - } - return restored; -} - -function restoreStoredRunnerTrace(trace) { - return { - ...trace, - events: Array.isArray(trace.events) ? trace.events : [], - eventCount: Number.isInteger(trace.eventCount) ? trace.eventCount : Array.isArray(trace.events) ? trace.events.length : 0, - eventsCompacted: trace.eventsCompacted === true, - fullTraceLoaded: trace.fullTraceLoaded === true - }; -} - -function scheduleCodeAgentSessionPersist() { - if (codeAgentSessionPersistTimer !== null) window.clearTimeout(codeAgentSessionPersistTimer); - codeAgentSessionPersistTimer = window.setTimeout(() => { - codeAgentSessionPersistTimer = null; - persistCodeAgentSessionState(); - }, CODE_AGENT_SESSION_PERSIST_DEBOUNCE_MS); -} - -function persistCodeAgentSessionState() { - if (!state.conversationId && !state.sessionId && !state.threadId && state.chatMessages.length === 0) { - clearCodeAgentSessionState(); - return; - } - const payload = { - version: CODE_AGENT_SESSION_STORAGE_VERSION, - updatedAtMs: Date.now(), - conversationId: state.conversationId, - sessionId: state.sessionId, - threadId: state.threadId, - sessionStatus: state.sessionStatus, - chatMessages: state.chatMessages.slice(-CODE_AGENT_SESSION_STORAGE_MESSAGE_LIMIT).map(storedChatMessage) - }; - try { - window.localStorage?.setItem(CODE_AGENT_SESSION_STORAGE_KEY, JSON.stringify(payload)); - } catch { - // Keep the in-memory conversation when localStorage is unavailable or full. - } -} - -function clearCodeAgentSessionState() { - if (codeAgentSessionPersistTimer !== null) { - window.clearTimeout(codeAgentSessionPersistTimer); - codeAgentSessionPersistTimer = null; - } - try { - window.localStorage?.removeItem(CODE_AGENT_SESSION_STORAGE_KEY); - } catch { - // Ignore storage availability errors. - } -} - -function storedChatMessage(message) { - const stored = { ...message }; - if (stored.runnerTrace && typeof stored.runnerTrace === "object") { - stored.runnerTrace = storedRunnerTrace(stored.runnerTrace); - } - delete stored.availability; - return stored; -} - -function storedRunnerTrace(trace) { - const events = Array.isArray(trace.events) ? trace.events : []; - const storedEvents = trace.fullTraceLoaded === true && events.length <= CODE_AGENT_SESSION_STORAGE_TRACE_EVENT_LIMIT - ? events - : []; - const fullTraceLoaded = storedEvents.length > 0 && trace.fullTraceLoaded === true; - return { - ...trace, - events: storedEvents, - eventsCompacted: !fullTraceLoaded, - fullTraceLoaded - }; -} - -function refreshRestoredCodeAgentTraces() { - for (const message of state.chatMessages) { - if (!message.traceId) continue; - if (message.runnerTrace?.fullTraceLoaded === true) continue; - replayFullTrace(message.id, { quiet: true }); - } -} - -function reconcileRestoredCodeAgentResults() { - for (const message of state.chatMessages) { - if (!message.traceId || message.role !== "agent") continue; - if (!shouldReconcileRestoredCodeAgentResult(message)) continue; - reconcileCodeAgentResult(message.id, { quiet: true }); - } -} - -function initLiveBuildOverlay() { - el.liveBuildSummary.open = false; - el.liveBuildToggle.addEventListener("click", (event) => { - event.preventDefault(); - el.liveBuildSummary.open = false; - openWorkbenchDialog({ - title: "构建版本明细", - body: cloneLiveBuildList(), - restoreFocus: el.liveBuildToggle - }); - }); -} - -function cloneLiveBuildList() { - const clone = el.liveBuildList.cloneNode(true); - clone.id = ""; - clone.hidden = false; - clone.removeAttribute("hidden"); - clone.tabIndex = 0; - clone.classList.remove("live-build-list-template"); - return clone; -} - -function openWorkbenchDialog({ title, body, restoreFocus = null }) { - closeWorkbenchDialog({ restoreFocus: false }); - const layer = document.createElement("div"); - layer.className = "workbench-dialog-layer"; - const backdrop = document.createElement("button"); - backdrop.type = "button"; - backdrop.className = "workbench-dialog-backdrop"; - backdrop.setAttribute("aria-label", "关闭弹窗"); - const dialog = document.createElement("section"); - dialog.className = "workbench-dialog"; - dialog.setAttribute("role", "dialog"); - dialog.setAttribute("aria-modal", "true"); - dialog.setAttribute("aria-label", title); - const head = document.createElement("div"); - head.className = "workbench-dialog-head"; - const heading = document.createElement("strong"); - heading.textContent = title; - const close = document.createElement("button"); - close.type = "button"; - close.className = "workbench-dialog-close"; - close.textContent = "关闭"; - close.setAttribute("aria-label", `关闭${title}`); - const content = document.createElement("div"); - content.className = "workbench-dialog-content"; - if (body) { - body.hidden = false; - body.removeAttribute("hidden"); - content.append(body); - } - head.append(heading, close); - dialog.append(head, content); - layer.append(backdrop, dialog); - - const closeDialog = () => closeWorkbenchDialog({ restoreFocus: true }); - const keydown = (event) => { - if (event.key !== "Escape") return; - event.preventDefault(); - closeDialog(); - }; - close.addEventListener("click", closeDialog); - backdrop.addEventListener("click", closeDialog); - document.addEventListener("keydown", keydown); - document.body.append(layer); - activeWorkbenchDialog = { layer, keydown, restoreFocus }; - close.focus(); -} - -function closeWorkbenchDialog({ restoreFocus = true } = {}) { - if (!activeWorkbenchDialog) return; - const { layer, keydown, restoreFocus: target } = activeWorkbenchDialog; - document.removeEventListener("keydown", keydown); - layer.remove(); - activeWorkbenchDialog = null; - if (restoreFocus && target && typeof target.focus === "function") { - target.focus(); - } -} - -function initRoutes() { - window.addEventListener("hashchange", () => showView(routeFromLocation())); - for (const button of document.querySelectorAll("[data-route]")) { - button.addEventListener("click", () => { - window.location.hash = `/${button.dataset.route}`; - }); - } - showView(routeFromLocation()); -} - -function initLayoutSizing() { - const persisted = readPersistedLayout(); - const leftSidebarWidth = clampLeftSidebarWidth(persisted?.leftSidebarWidth ?? LEFT_SIDEBAR_DEFAULT_WIDTH); - const rightSidebarWidth = clampRightSidebarWidth(persisted?.rightSidebarWidth ?? RIGHT_SIDEBAR_DEFAULT_WIDTH); - setLeftSidebarWidth(leftSidebarWidth, { persist: false }); - setLeftSidebarCollapsed(persisted?.leftSidebarCollapsed === true, { persist: false }); - setRightSidebarWidth(rightSidebarWidth, { persist: false }); -} - -function readPersistedLayout() { - try { - const raw = window.localStorage?.getItem(LAYOUT_STORAGE_KEY); - if (!raw) return null; - const payload = JSON.parse(raw); - if (payload?.version !== 1) return null; - return { - leftSidebarCollapsed: payload.leftSidebarCollapsed === true, - leftSidebarWidth: Number(payload.leftSidebarWidth), - rightSidebarWidth: Number(payload.rightSidebarWidth) - }; - } catch { - return null; - } -} - -function persistLayout() { - try { - window.localStorage?.setItem( - LAYOUT_STORAGE_KEY, - JSON.stringify({ - version: 1, - leftSidebarCollapsed: state.layout.leftSidebarCollapsed, - leftSidebarWidth: state.layout.leftSidebarWidth, - rightSidebarWidth: state.layout.rightSidebarWidth - }) - ); - } catch { - // Browser storage can be unavailable; layout still works for the session. - } -} - -function initLeftSidebarToggle() { - el.leftSidebarToggle.addEventListener("click", () => { - setLeftSidebarCollapsed(!state.layout.leftSidebarCollapsed); - }); -} - -function setLeftSidebarCollapsed(collapsed, options = {}) { - state.layout.leftSidebarCollapsed = collapsed === true; - el.shell.classList.toggle("is-left-sidebar-collapsed", state.layout.leftSidebarCollapsed); - el.leftSidebarToggle.setAttribute("aria-expanded", state.layout.leftSidebarCollapsed ? "false" : "true"); - el.leftSidebarToggle.setAttribute( - "aria-label", - state.layout.leftSidebarCollapsed ? "展开左侧导航" : "折叠左侧导航" - ); - el.leftSidebarToggle.title = state.layout.leftSidebarCollapsed ? "展开左侧导航" : "折叠左侧导航"; - el.leftSidebarToggle.textContent = state.layout.leftSidebarCollapsed ? "展开" : "收起"; - syncLeftSidebarResizeA11y(); - if (options.persist !== false) persistLayout(); -} - -function setLeftSidebarWidth(width, options = {}) { - const clamped = clampLeftSidebarWidth(width); - state.layout.leftSidebarWidth = clamped; - el.shell.style.setProperty("--left-sidebar-width", `${clamped}px`); - syncLeftSidebarResizeA11y(); - if (options.persist !== false) persistLayout(); - return clamped; -} - -function setRightSidebarWidth(width, options = {}) { - const clamped = clampRightSidebarWidth(width); - state.layout.rightSidebarWidth = clamped; - el.shell.style.setProperty("--right-sidebar-width", `${clamped}px`); - syncRightSidebarResizeA11y(); - if (options.persist !== false) persistLayout(); - return clamped; -} - -function clampLeftSidebarWidth(value) { - const numeric = Number(value); - const width = Number.isFinite(numeric) ? numeric : LEFT_SIDEBAR_DEFAULT_WIDTH; - const bounds = leftSidebarResizeBounds(); - return Math.min(Math.max(Math.round(width), bounds.min), bounds.max); -} - -function clampRightSidebarWidth(value) { - const numeric = Number(value); - const width = Number.isFinite(numeric) ? numeric : RIGHT_SIDEBAR_DEFAULT_WIDTH; - const bounds = rightSidebarResizeBounds(); - return Math.min(Math.max(Math.round(width), bounds.min), bounds.max); -} - -function boundedResizeRange(min, max, dynamicMax) { - const safeMin = Math.max(0, Math.round(min)); - const safeMax = Math.max(safeMin, Math.round(Math.min(max, Number.isFinite(dynamicMax) ? dynamicMax : max))); - return { min: safeMin, max: safeMax }; -} - -function leftSidebarResizeBounds() { - const styles = getComputedStyle(el.shell); - const cssMin = readCssPixel(styles.getPropertyValue("--left-sidebar-min-width"), LEFT_SIDEBAR_MIN_WIDTH); - const cssMax = readCssPixel(styles.getPropertyValue("--left-sidebar-max-width"), LEFT_SIDEBAR_MAX_WIDTH); - if (window.matchMedia("(max-width: 1240px)").matches) { - const fixedRailWidth = readCssPixel(styles.getPropertyValue("--rail-width"), LEFT_SIDEBAR_DEFAULT_WIDTH); - return { min: Math.round(fixedRailWidth), max: Math.round(fixedRailWidth) }; - } - const rightWidth = state.layout.rightSidebarWidth; - const dynamicMax = window.innerWidth - rightWidth - 420 - 4; - return boundedResizeRange(cssMin, cssMax, dynamicMax); -} - -function rightSidebarResizeBounds() { - const styles = getComputedStyle(el.shell); - const cssMin = readCssPixel(styles.getPropertyValue("--right-sidebar-min-width"), RIGHT_SIDEBAR_MIN_WIDTH); - const cssMax = readCssPixel(styles.getPropertyValue("--right-sidebar-max-width"), RIGHT_SIDEBAR_MAX_WIDTH); - if (window.matchMedia("(max-width: 1240px)").matches) { - return { min: 0, max: Math.max(0, window.innerWidth) }; - } - const railWidth = state.layout.leftSidebarCollapsed - ? readCssPixel(styles.getPropertyValue("--rail-collapsed-width"), LEFT_SIDEBAR_DEFAULT_WIDTH) - : state.layout.leftSidebarWidth; - const dynamicMax = window.innerWidth - railWidth - 420 - 4; - return boundedResizeRange(cssMin, cssMax, dynamicMax); -} - -function readCssPixel(value, fallback) { - const parsed = Number.parseFloat(String(value ?? "").trim()); - return Number.isFinite(parsed) ? parsed : fallback; -} - -function syncRightSidebarResizeA11y() { - const bounds = rightSidebarResizeBounds(); - const disabled = !canResizeRightSidebar(); - el.rightSidebarResize.tabIndex = disabled ? -1 : 0; - el.rightSidebarResize.setAttribute("aria-disabled", disabled ? "true" : "false"); - el.rightSidebarResize.setAttribute("aria-hidden", disabled ? "true" : "false"); - el.rightSidebarResize.setAttribute("aria-valuemin", String(bounds.min)); - el.rightSidebarResize.setAttribute("aria-valuemax", String(bounds.max)); - el.rightSidebarResize.setAttribute("aria-valuenow", String(state.layout.rightSidebarWidth)); - el.rightSidebarResize.setAttribute("aria-valuetext", `右侧 Device Pod 看板宽度 ${state.layout.rightSidebarWidth} 像素`); -} - -function syncLeftSidebarResizeA11y() { - const bounds = leftSidebarResizeBounds(); - const disabled = !canResizeLeftSidebar(); - el.leftSidebarResize.tabIndex = disabled ? -1 : 0; - el.leftSidebarResize.setAttribute("aria-disabled", disabled ? "true" : "false"); - el.leftSidebarResize.setAttribute("aria-hidden", disabled ? "true" : "false"); - el.leftSidebarResize.setAttribute("aria-valuemin", String(bounds.min)); - el.leftSidebarResize.setAttribute("aria-valuemax", String(bounds.max)); - el.leftSidebarResize.setAttribute("aria-valuenow", String(state.layout.leftSidebarWidth)); - el.leftSidebarResize.setAttribute("aria-valuetext", `左侧导航宽度 ${state.layout.leftSidebarWidth} 像素`); -} - -function routeFromLocation() { - const hashRoute = window.location.hash.replace(/^#\/?/, ""); - if (viewIds.has(hashRoute)) return hashRoute; - if (helpPathnames().has(window.location.pathname.replace(/\/+$/, "") || "/")) return "help"; - if (internalGatePathnames().has(window.location.pathname.replace(/\/+$/, "") || "/")) return "gate"; - return "workspace"; -} - -function showView(route) { - const activeRoute = viewIds.has(route) ? route : "workspace"; - for (const view of document.querySelectorAll("[data-view]")) { - view.hidden = view.dataset.view !== activeRoute; - } - for (const button of document.querySelectorAll("[data-route]")) { - const active = button.dataset.route === activeRoute; - button.classList.toggle("active", active); - button.setAttribute("aria-current", active ? "page" : "false"); - } - if (activeRoute === "gate" && state.gateDiagnostics.rows.length === 0 && !state.gateDiagnostics.loading) { - loadGateDiagnostics(); - } -} - -function initLeftSidebarResize() { - window.addEventListener("resize", () => { - if (canResizeLeftSidebar()) setLeftSidebarWidth(state.layout.leftSidebarWidth, { persist: false }); - syncLeftSidebarResizeA11y(); - }); - el.leftSidebarResize.addEventListener("pointerdown", (event) => { - if (!canResizeLeftSidebar()) return; - event.preventDefault(); - el.leftSidebarResize.setPointerCapture?.(event.pointerId); - state.layout.leftDrag = { - pointerId: event.pointerId, - startX: event.clientX, - startWidth: state.layout.leftSidebarWidth - }; - el.shell.classList.add("is-resizing"); - }); - el.leftSidebarResize.addEventListener("pointermove", (event) => { - const drag = state.layout.leftDrag; - if (!drag || drag.pointerId !== event.pointerId) return; - setLeftSidebarWidth(drag.startWidth + event.clientX - drag.startX, { persist: false }); - }); - el.leftSidebarResize.addEventListener("pointerup", finishLeftSidebarResize); - el.leftSidebarResize.addEventListener("pointercancel", finishLeftSidebarResize); - el.leftSidebarResize.addEventListener("keydown", (event) => { - if (!canResizeLeftSidebar()) return; - const keySteps = { - ArrowLeft: -8, - ArrowRight: 8, - PageUp: 24, - PageDown: -24 - }; - if (Object.hasOwn(keySteps, event.key)) { - event.preventDefault(); - setLeftSidebarWidth(state.layout.leftSidebarWidth + keySteps[event.key]); - } else if (event.key === "Home") { - event.preventDefault(); - setLeftSidebarWidth(leftSidebarResizeBounds().min); - } else if (event.key === "End") { - event.preventDefault(); - setLeftSidebarWidth(leftSidebarResizeBounds().max); - } - }); -} - -function finishLeftSidebarResize(event) { - const drag = state.layout.leftDrag; - if (!drag || drag.pointerId !== event.pointerId) return; - state.layout.leftDrag = null; - el.shell.classList.remove("is-resizing"); - persistLayout(); -} - -function canResizeLeftSidebar() { - return !state.layout.leftSidebarCollapsed && !window.matchMedia("(max-width: 1240px)").matches; -} - -function initRightSidebarResize() { - window.addEventListener("resize", () => { - if (canResizeRightSidebar()) setRightSidebarWidth(state.layout.rightSidebarWidth, { persist: false }); - syncRightSidebarResizeA11y(); - }); - el.rightSidebarResize.addEventListener("pointerdown", (event) => { - if (!canResizeRightSidebar()) return; - event.preventDefault(); - el.rightSidebarResize.setPointerCapture?.(event.pointerId); - state.layout.rightDrag = { - pointerId: event.pointerId, - startX: event.clientX, - startWidth: state.layout.rightSidebarWidth - }; - el.shell.classList.add("is-resizing"); - }); - el.rightSidebarResize.addEventListener("pointermove", (event) => { - const drag = state.layout.rightDrag; - if (!drag || drag.pointerId !== event.pointerId) return; - setRightSidebarWidth(drag.startWidth + drag.startX - event.clientX, { persist: false }); - }); - el.rightSidebarResize.addEventListener("pointerup", finishRightSidebarResize); - el.rightSidebarResize.addEventListener("pointercancel", finishRightSidebarResize); - el.rightSidebarResize.addEventListener("keydown", (event) => { - if (!canResizeRightSidebar()) return; - const keySteps = { - ArrowLeft: 16, - ArrowRight: -16, - PageUp: 48, - PageDown: -48 - }; - if (Object.hasOwn(keySteps, event.key)) { - event.preventDefault(); - setRightSidebarWidth(state.layout.rightSidebarWidth + keySteps[event.key]); - } else if (event.key === "Home") { - event.preventDefault(); - setRightSidebarWidth(rightSidebarResizeBounds().min); - } else if (event.key === "End") { - event.preventDefault(); - setRightSidebarWidth(rightSidebarResizeBounds().max); - } - }); -} - -function finishRightSidebarResize(event) { - const drag = state.layout.rightDrag; - if (!drag || drag.pointerId !== event.pointerId) return; - state.layout.rightDrag = null; - el.shell.classList.remove("is-resizing"); - persistLayout(); -} - -function canResizeRightSidebar() { - return !window.matchMedia("(max-width: 1240px)").matches; -} - -async function loadHelpSurface() { - el.helpStatus.textContent = "加载中"; - el.helpStatus.className = "state-tag tone-dry-run"; - el.helpContent.dataset.helpState = "loading"; - - try { - const response = await fetch("/help.md", { cache: "no-store" }); - if (!response.ok) { - throw new Error(`help.md HTTP ${response.status}`); - } - const markdown = await response.text(); - el.helpContent.innerHTML = marked.parse(markdown, { gfm: true, breaks: false }); - el.helpContent.dataset.helpState = "ready"; - el.helpStatus.textContent = "已就绪"; - el.helpStatus.className = "state-tag tone-source"; - } catch (error) { - el.helpContent.dataset.helpState = "error"; - el.helpContent.replaceChildren(); - const notice = document.createElement("div"); - notice.className = "help-error"; - notice.textContent = `帮助内容加载失败:${error.message}。默认工作台仍可继续使用。`; - el.helpContent.append(notice); - el.helpStatus.textContent = "加载失败"; - el.helpStatus.className = "state-tag tone-blocked"; - } -} - -function initDevicePodPanel() { - el.devicePodSelect.addEventListener("change", () => { - state.devicePod.selectedDevicePodId = el.devicePodSelect.value || "device-pod-71-freq"; - loadLiveSurface().then(renderLiveSurface); - }); - el.deviceEventFollow.addEventListener("click", () => { - setDeviceEventFollow(!state.devicePod.followEvents, { scroll: true }); - }); - el.deviceEventJump.addEventListener("click", () => { - state.devicePod.unreadEvents = 0; - setDeviceEventFollow(true); - renderDeviceEventUnreadHint(); - scrollDeviceEventsToBottom(); - }); - for (const eventName of ["wheel", "touchstart", "pointerdown"]) { - el.deviceEventScroll.addEventListener(eventName, markDeviceEventScrollIntent, { passive: true }); - } - el.deviceEventScroll.addEventListener("keydown", (event) => { - if (isScrollIntentKey(event.key)) markDeviceEventScrollIntent(); - }); - el.deviceEventScroll.addEventListener("scroll", () => { - if (!deviceEventNearBottom()) setDeviceEventFollow(false); - }, { passive: true }); - el.devicePodSummary.addEventListener("click", (event) => { - const tile = event.target.closest("[data-device-detail]"); - if (!tile) return; - showDeviceDetail(tile.dataset.deviceTitle || "Pod Summary", state.devicePod.details.get("pod")); - }); - el.devicePodSummary.addEventListener("keydown", (event) => { - if (event.key !== "Enter" && event.key !== " ") return; - const tile = event.target.closest("[data-device-detail]"); - if (!tile) return; - event.preventDefault(); - showDeviceDetail(tile.dataset.deviceTitle || "Pod Summary", state.devicePod.details.get("pod")); - }); - el.devicePodInterfaces.addEventListener("click", (event) => { - const tile = event.target.closest("[data-device-detail]"); - if (!tile) return; - showDeviceDetail(tile.dataset.deviceTitle || tile.dataset.deviceDetail || "Device Pod", state.devicePod.details.get(tile.dataset.deviceDetail)); - }); -} - -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(); - const value = el.commandInput.value.trim(); - await submitAgentMessage(value); - }); - - el.commandClear.addEventListener("click", () => { - for (const close of state.traceStreams.values()) close(); - state.traceStreams.clear(); - state.traceDetailsOpen.clear(); - state.conversationRenderVersion += 1; - state.conversationScrollUserActiveUntil = 0; - state.conversationScrollPosition = { top: 0, left: 0 }; - state.traceScrollPositions.clear(); - state.traceScrollUserActiveUntil.clear(); - state.traceProgrammaticScrollWrites = 0; - state.fullTraceReplayInFlight.clear(); - state.chatMessages = []; - state.conversationId = null; - state.sessionId = null; - state.threadId = null; - state.sessionStatus = null; - state.currentRequest = null; - state.canceledTraces.clear(); - state.chatPending = false; - clearCodeAgentSessionState(); - el.commandInput.value = ""; - renderAgentChatStatus("idle"); - renderCodeAgentSummary(); - renderConversation(); - renderDrafts(); - renderDevicePodPanel(state.liveSurface); - }); -} - -async function submitAgentMessage(value, options = {}) { - if (!value || state.chatPending) return; - const traceId = options.traceId ?? nextProtocolId("trc"); - const activeConversationId = options.conversationId ?? state.conversationId ?? nextProtocolId("cnv"); - const requestedSessionId = options.sessionId === undefined ? sessionIdForNextRequest() : options.sessionId; - const requestedThreadId = options.threadId === undefined ? threadIdForNextRequest() : options.threadId; - const pendingContinuity = pendingSessionContinuity({ - conversationId: activeConversationId, - sessionId: requestedSessionId, - threadId: requestedThreadId, - retryOf: options.retryOf - }); - state.conversationId = activeConversationId; - const userMessage = { - id: nextProtocolId("msg"), - role: "user", - title: `${options.retryOf ? "重试" : "用户"} ${shortTime(new Date().toISOString())}`, - text: value, - status: "sent", - traceId, - conversationId: activeConversationId, - sessionId: requestedSessionId, - threadId: requestedThreadId, - retryOf: options.retryOf ?? null, - createdAt: new Date().toISOString() - }; - const pendingMessage = { - id: nextProtocolId("msg"), - role: "agent", - title: options.retryOf ? "Code Agent 重试中" : "Code Agent 处理中", - text: `正在处理这次 Code Agent 请求;复杂问题可能需要几分钟。页面会保留 trace/session 并等待后端返回成功、结构化 blocker 或真实超时;不会把旧 4500ms 轻量探测窗口当作最终失败。`, - status: "running", - traceId, - conversationId: activeConversationId, - sessionId: requestedSessionId, - threadId: requestedThreadId, - sessionContinuity: pendingContinuity, - retryInput: value, - retryOf: options.retryOf ?? null, - sourceKind: "PENDING", - createdAt: new Date().toISOString() - }; - state.chatMessages.push(userMessage, pendingMessage); - state.chatPending = true; - state.currentRequest = { - traceId, - conversationId: activeConversationId, - sessionId: requestedSessionId, - threadId: requestedThreadId, - messageId: pendingMessage.id, - input: value, - lastActivityAt: Date.now(), - lastActivityIso: new Date().toISOString(), - traceEventCount: 0, - waitingFor: "agent/chat", - lastEventLabel: "request:submitted" - }; - el.commandInput.value = ""; - renderAgentChatStatus("running"); - renderCodeAgentSummary(); - renderConversation(); - renderDrafts(); - renderDevicePodPanel(state.liveSurface); - - const stopTraceStream = subscribeRunnerTrace(traceId, pendingMessage.id); - - try { - const result = await sendAgentMessage(value, activeConversationId, traceId, requestedSessionId, requestedThreadId); - stopTraceStream(); - if (state.canceledTraces.has(traceId)) return; - const index = state.chatMessages.findIndex((message) => message.id === pendingMessage.id); - const updatedMessage = applyCodeAgentResultToMessage(pendingMessage.id, result, { - baseMessage: pendingMessage, - pendingContinuity, - requestedThreadId, - retryInput: value, - retryOf: options.retryOf ?? null - }); - if (result.availability) { - state.codeAgentAvailability = result.availability; - } - if (!["completed", "source"].includes(updatedMessage?.status)) { - el.commandInput.value = value; - } - renderAgentChatStatus(updatedMessage?.status, state.chatMessages[index]); - renderCodeAgentSummary(); - } catch (error) { - stopTraceStream(); - if (state.canceledTraces.has(traceId)) return; - const index = state.chatMessages.findIndex((message) => message.id === pendingMessage.id); - const presentation = agentFailurePresentation(error, { traceId }); - const failedStatus = errorStatusFromPresentation(presentation, error); - state.chatMessages[index] = { - ...pendingMessage, - title: presentation.title, - text: presentation.text, - status: failedStatus, - traceId: error.traceId || traceId, - threadId: requestedThreadId, - sessionContinuity: failedSessionContinuity(pendingContinuity, error), - updatedAt: new Date().toISOString(), - retryInput: value, - error: { - code: error.code || "request_failed", - category: presentation.category, - layer: error.layer, - blocker: error.blocker, - retryable: error.retryable, - userMessage: error.userMessage, - message: error.message, - timeoutMs: error.timeoutMs, - hardTimeoutMs: error.hardTimeoutMs, - idleMs: error.idleMs, - lastActivityAt: error.lastActivityAt, - waitingFor: error.waitingFor, - providerStatus: error.providerStatus, - missingConfig: error.missingConfig, - route: error.route, - toolName: error.toolName - }, - runnerTrace: latestTraceSnapshot(traceId) - }; - state.sessionStatus = state.chatMessages[index].runnerTrace?.sessionStatus ?? failedStatus; - el.commandInput.value = value; - renderAgentChatStatus(failedStatus, state.chatMessages[index]); - renderCodeAgentSummary(); - } finally { - if (state.currentRequest?.traceId === traceId) { - state.currentRequest = null; - state.chatPending = false; - } else if (!state.currentRequest) { - state.chatPending = false; - } - stopTraceStream(); - renderCodeAgentSummary(); - renderConversation(); - renderDrafts(); - renderDevicePodPanel(state.liveSurface); - } -} - -function sessionIdForNextRequest() { - return isTerminalSessionStatus(state.sessionStatus) ? undefined : state.sessionId; -} - -function threadIdForNextRequest() { - return isTerminalSessionStatus(state.sessionStatus) ? undefined : state.threadId; -} - -function isTerminalSessionStatus(status) { - return ["failed", "interrupted", "timeout", "error", "canceled", "expired"].includes(String(status ?? "").toLowerCase()); -} - -function applyCodeAgentResultToMessage(messageId, result, options = {}) { - const index = state.chatMessages.findIndex((message) => message.id === messageId); - if (index < 0 || !result || typeof result !== "object") return null; - const current = options.baseMessage ?? state.chatMessages[index]; - state.conversationId = result.conversationId || result.sessionId || state.conversationId; - state.sessionId = result.sessionId || result.session?.sessionId || state.sessionId || state.conversationId; - const resultThreadId = threadIdFrom(result); - if (resultThreadId) state.threadId = resultThreadId; - state.sessionStatus = result.sessionLifecycleStatus ?? result.sessionSummary?.status ?? result.session?.lifecycleStatus ?? result.session?.status ?? result.runnerTrace?.sessionLifecycleStatus ?? result.runnerTrace?.sessionStatus ?? result.status ?? state.sessionStatus; - const pendingContinuity = options.pendingContinuity ?? current.sessionContinuity ?? pendingSessionContinuity({ - conversationId: current.conversationId || options.fallbackConversationId || state.conversationId, - sessionId: current.sessionId || state.sessionId, - threadId: current.threadId || options.fallbackThreadId || state.threadId, - retryOf: current.retryOf - }); - const sessionContinuity = completedSessionContinuity(result, pendingContinuity); - const resultBlockedError = structuredBlockedErrorFromResult(result); - const continuityBlocker = codeAgentContinuityBlocker(result, sessionContinuity); - const blockedError = resultBlockedError ?? continuityBlocker; - const completion = classifyCodeAgentCompletion(result, { blockedError }); - const status = completion.status; - state.chatMessages[index] = { - ...current, - title: completion.title, - text: completion.replied - ? result.reply?.content || "Code Agent 没有返回文本。" - : blockedError - ? failureMessage({ ...result, error: blockedError }) - : result.status === "completed" - ? untrustedCompletionMessage(result) - : failureMessage(result), - status, - traceId: result.traceId || current.traceId, - conversationId: result.conversationId || result.sessionId || state.conversationId, - sessionId: result.sessionId || result.session?.sessionId || result.sessionReuse?.sessionId || state.sessionId, - threadId: resultThreadId || options.requestedThreadId || current.threadId || state.threadId, - sessionContinuity, - messageId: result.messageId, - provider: result.provider, - model: result.model, - backend: result.backend, - workspace: result.workspace, - sandbox: result.sandbox, - session: result.session, - sessionLifecycleStatus: result.sessionLifecycleStatus, - sessionLifecycle: result.sessionLifecycle, - sessionSummary: result.sessionSummary, - sessionMode: result.sessionMode, - sessionReuse: result.sessionReuse, - implementationType: result.implementationType, - runnerLimitations: result.runnerLimitations, - codexStdioFeasibility: result.codexStdioFeasibility, - longLivedSessionGate: result.longLivedSessionGate, - toolCalls: result.toolCalls, - skills: result.skills, - runner: result.runner, - runnerTrace: result.runnerTrace ?? current.runnerTrace, - conversationFacts: result.conversationFacts, - responseType: result.responseType, - capabilityLevel: result.capabilityLevel, - sourceKind: completion.sourceKind, - providerTrace: result.providerTrace, - blocker: result.blocker, - blockers: result.blockers, - updatedAt: result.updatedAt ?? new Date().toISOString(), - retryInput: options.retryInput ?? current.retryInput, - retryOf: options.retryOf ?? current.retryOf ?? null, - error: blockedError ?? result.error ?? (result.status === "completed" && !completion.replied - ? { - code: "untrusted_completion", - message: "completed 回复缺少真实 provider/model/trace/conversation 证据,或 provider 属于 echo/mock/stub。" - } - : undefined), - availability: result.availability, - traceReplayStatus: current.traceReplayStatus - }; - maybeReplayFullTraceForMessage(state.chatMessages[index]); - return state.chatMessages[index]; -} - -function errorStatusFromPresentation(presentation, error) { - if (presentation.category === "timeout") return "timeout"; - if (presentation.category === "canceled" || error?.code === "codex_stdio_canceled") return "canceled"; - return "failed"; -} - -function pendingSessionContinuity({ conversationId, sessionId, threadId, retryOf = null } = {}) { - const hasReusableSession = Boolean(nonEmptyString(sessionId)); - const hasReusableThread = Boolean(nonEmptyString(threadId)); - const status = hasReusableSession || hasReusableThread - ? "continued" - : retryOf ? "degraded" : "new"; - return { - status, - tone: status === "continued" ? "ok" : status === "degraded" ? "warn" : "source", - label: status === "continued" ? "继续当前会话" : status === "degraded" ? "重试降级" : "新会话待建立", - summary: status === "continued" - ? "本次请求会带上已保存的 conversation/session/thread 上下文。" - : status === "degraded" - ? "上一轮未留下可复用 session/thread;重试会保留输入和 conversation,并等待后端建立新会话。" - : "这是当前浏览器会话中的首轮请求,等待后端分配 session/thread。", - requested: { - conversationId: nonEmptyString(conversationId), - sessionId: nonEmptyString(sessionId), - threadId: nonEmptyString(threadId), - retryOf: nonEmptyString(retryOf) - }, - returned: { - conversationId: null, - sessionId: null, - threadId: null - }, - missing: [], - changed: [] - }; -} - -function completedSessionContinuity(result, pending = null) { - const lifecycle = result?.sessionSummary ?? result?.sessionLifecycle ?? null; - if (lifecycle?.degraded === true || lifecycle?.fallback === true) { - const requested = pending?.requested ?? {}; - return { - status: "degraded", - tone: "warn", - label: lifecycle.label ?? "会话降级", - summary: lifecycle.userMessage ?? "当前响应为会话降级;输入已保留,可重新发送建立新会话。", - requested, - returned: { - conversationId: nonEmptyString(result?.conversationId), - sessionId: sessionIdFrom(result), - threadId: threadIdFrom(result) - }, - missing: [], - changed: [], - reused: lifecycle.reused ?? null - }; - } - const requested = pending?.requested ?? {}; - const returned = { - conversationId: nonEmptyString(result?.conversationId), - sessionId: sessionIdFrom(result), - threadId: threadIdFrom(result) - }; - const missing = []; - const changed = []; - if (!returned.conversationId) missing.push("conversationId"); - if (requiresCodexSessionEvidence(result) && !returned.sessionId) missing.push("sessionId"); - if (requiresCodexThreadEvidence(result) && !returned.threadId) missing.push("threadId"); - if (requiresCodexProviderTrace(result) && !hasProviderTrace(result)) missing.push("providerTrace"); - if (requested.conversationId && returned.conversationId && requested.conversationId !== returned.conversationId) changed.push("conversationId"); - if (requested.sessionId && returned.sessionId && requested.sessionId !== returned.sessionId) changed.push("sessionId"); - if (requested.threadId && returned.threadId && requested.threadId !== returned.threadId) changed.push("threadId"); - const reused = result?.sessionReuse?.reused ?? result?.session?.reused ?? result?.runnerTrace?.sessionReused ?? null; - const requestedReusable = Boolean(requested.sessionId || requested.threadId); - const status = missing.length > 0 || changed.length > 0 || (requestedReusable && reused === false) - ? "degraded" - : requestedReusable ? "continued" : "new"; - const label = status === "continued" ? "继续当前会话" : status === "new" ? "新会话已建立" : "新会话/会话降级"; - const summary = status === "continued" - ? "后端确认复用当前 conversation/session/thread。" - : status === "new" - ? "后端已为当前 conversation 建立新的 session/thread。" - : `后端未完整确认原 session/thread 复用;${continuityIssueText({ missing, changed, reused })}`; - return { - status, - tone: status === "continued" ? "ok" : status === "new" ? "source" : "warn", - label, - summary, - requested, - returned, - missing, - changed, - reused - }; -} - -function failedSessionContinuity(pending = null, error = null) { - const base = pending ?? pendingSessionContinuity(); - const missing = [...(base.missing ?? [])]; - if (!base.requested?.sessionId) missing.push("sessionId"); - if (!base.requested?.threadId) missing.push("threadId"); - const code = normalizeErrorCode(error?.code); - const label = code === "session_busy" - ? "会话忙/请等待" - : code === "session_expired" - ? "会话已过期" - : ["session_failed", "session_error", "session_timeout"].includes(code) - ? "需新建会话" - : "会话受阻"; - const summary = code === "session_busy" - ? "上一轮仍在处理;输入已保留,请等待当前请求完成或取消后重试。" - : code === "session_expired" - ? "当前 session 已过期;输入已保留,可重新发送建立新的 Code Agent session。" - : `本次请求没有完成可复用会话确认;输入已保留。${error?.code ? ` code=${error.code}` : ""}`; - return { - ...base, - status: "blocked", - tone: "blocked", - label, - summary, - missing: [...new Set(missing)] - }; -} - -function continuityIssueText({ missing = [], changed = [], reused = null } = {}) { - const parts = [ - missing.length ? `缺失 ${missing.join("、")}` : null, - changed.length ? `${changed.join("、")} 已变化` : null, - reused === false ? "sessionReuse=false" : null - ].filter(Boolean); - return parts.length ? parts.join(";") : "会话复用证据不足"; -} - -function codeAgentContinuityBlocker(result, continuity) { - if ( - !result || - result.status !== "completed" || - isSourceFixtureCompletion(result) || - isTextFallbackChatResult(result) || - !isTrustedCodeAgentProvider(result.provider) - ) { - return null; - } - const missing = [ - ...(!hasProviderTrace(result) ? ["providerTrace"] : []), - ...(!sessionIdFrom(result) ? ["sessionId"] : []), - ...(requiresCodexThreadEvidence(result) && !threadIdFrom(result) ? ["threadId"] : []) - ]; - if (missing.length === 0) return null; - return { - code: "code_agent_session_evidence_missing", - category: "session_blocked", - layer: "cloud-web", - retryable: true, - missingFields: missing, - traceId: result.traceId, - message: `completed response missing Code Agent session evidence: ${missing.join(", ")}`, - userMessage: `Code Agent completed 但会话证据缺失:${missing.join("、")};本次按会话降级处理,输入已保留,可重试建立新会话。`, - blocker: { - code: "code_agent_session_evidence_missing", - category: "session_blocked", - layer: "cloud-web", - retryable: true, - missingFields: missing, - traceId: result.traceId, - userMessage: `Code Agent completed 但会话证据缺失:${missing.join("、")};本次按会话降级处理,输入已保留,可重试建立新会话。`, - sessionContinuity: continuity - } - }; -} - -function hasProviderTrace(value) { - return Boolean(value?.providerTrace && typeof value.providerTrace === "object" && Object.keys(value.providerTrace).length > 0); -} - -function requiresCodexProviderTrace(value) { - return isTrustedCodeAgentProvider(value?.provider) && !isSourceFixtureCompletion(value); -} - -function requiresCodexSessionEvidence(value) { - return isTrustedCodeAgentProvider(value?.provider); -} - -function requiresCodexThreadEvidence(value) { - return isCodexStdioLongLivedShape(value); -} - -function isCodexStdioLongLivedShape(value) { - return ( - isTrustedCodeAgentProvider(value?.provider) || - String(value?.runner?.kind ?? value?.runnerTrace?.runnerKind ?? "").trim() === CODEX_APP_SERVER_RUNNER_KIND || - value?.runner?.codexStdio === true || - value?.session?.codexStdio === true - ) && ( - String(value?.sessionMode ?? value?.session?.sessionMode ?? value?.runnerTrace?.sessionMode ?? "").trim() === CODEX_APP_SERVER_SESSION_MODE || - String(value?.implementationType ?? value?.session?.implementationType ?? "").trim() === CODEX_APP_SERVER_IMPLEMENTATION_TYPE - ); -} - -function sessionIdFrom(value) { - return firstNonEmptyString(value?.sessionId, value?.session?.sessionId, value?.sessionReuse?.sessionId, value?.runner?.sessionId, value?.runnerTrace?.sessionId); -} - -function threadIdFrom(value) { - return firstNonEmptyString(value?.threadId, value?.session?.threadId, value?.sessionReuse?.threadId, value?.providerTrace?.threadId, value?.runnerTrace?.threadId); -} - -function firstNonEmptyString(...values) { - for (const value of values) { - const text = nonEmptyString(value); - if (text) return text; - } - return null; -} - -function nonEmptyString(value) { - const text = String(value ?? "").trim(); - return text ? text : null; -} - -function subscribeRunnerTrace(traceId, messageId) { - if (typeof EventSource !== "function") return pollRunnerTrace(traceId, messageId); - const existing = state.traceStreams.get(traceId); - if (existing) existing(); - let stopped = false; - let fallbackStop = null; - let firstEventSeen = false; - let fallbackTimer = null; - const source = new EventSource(`/v1/agent/chat/trace/${encodeURIComponent(traceId)}/stream`); - const close = () => { - stopped = true; - if (fallbackTimer) window.clearTimeout(fallbackTimer); - source.close(); - if (fallbackStop) fallbackStop(); - if (state.traceStreams.get(traceId) === close) state.traceStreams.delete(traceId); - }; - const markEventSeen = () => { - firstEventSeen = true; - if (fallbackTimer) { - window.clearTimeout(fallbackTimer); - fallbackTimer = null; - } - }; - const startPolling = () => { - if (stopped || fallbackStop) return; - source.close(); - fallbackStop = pollRunnerTrace(traceId, messageId); - state.traceStreams.set(traceId, close); - }; - fallbackTimer = window.setTimeout(() => { - if (!firstEventSeen) startPolling(); - }, TRACE_STREAM_FALLBACK_MS); - source.addEventListener("snapshot", (event) => { - markEventSeen(); - updateMessageTrace(messageId, parseTraceEventData(event.data)); - }); - source.addEventListener("runnerTrace", (event) => { - markEventSeen(); - const payload = parseTraceEventData(event.data); - updateMessageTrace(messageId, payload?.snapshot ?? payload); - }); - source.addEventListener("heartbeat", (event) => { - markEventSeen(); - updateMessageTrace(messageId, parseTraceEventData(event.data), { quiet: true }); - }); - source.onerror = () => { - startPolling(); - }; - state.traceStreams.set(traceId, close); - return close; -} - -function pollRunnerTrace(traceId, messageId) { - let stopped = false; - let timer = null; - const tick = async () => { - if (stopped) return; - try { - const response = await fetchJson(`/v1/agent/chat/trace/${encodeURIComponent(traceId)}`, { - timeoutMs: Math.min(API_TIMEOUT_MS, 3000), - timeoutName: "Code Agent trace" - }); - if (response.ok) updateMessageTrace(messageId, response.data); - } catch { - // Keep polling; the main /v1/agent/chat request owns terminal error handling. - } - if (!stopped) timer = window.setTimeout(tick, TRACE_POLL_INTERVAL_MS); - }; - tick(); - const stop = () => { - stopped = true; - if (timer) window.clearTimeout(timer); - if (state.traceStreams.get(traceId) === stop) state.traceStreams.delete(traceId); - }; - state.traceStreams.set(traceId, stop); - return stop; -} - -function parseTraceEventData(data) { - try { - return data ? JSON.parse(data) : null; - } catch { - return null; - } -} - -function updateMessageTrace(messageId, snapshot, options = {}) { - if (!snapshot || typeof snapshot !== "object" || !Array.isArray(snapshot.events)) return; - const index = state.chatMessages.findIndex((message) => message.id === messageId); - if (index < 0) return; - const current = state.chatMessages[index]; - const runnerTrace = runnerTraceFromSnapshot(snapshot, current.runnerTrace); - const sessionId = snapshot.sessionId ?? runnerTrace.sessionId ?? current.sessionId; - const threadId = snapshot.threadId ?? runnerTrace.threadId ?? current.threadId; - noteCurrentRequestTraceActivity(snapshot); - const nextMessage = { - ...current, - runnerTrace, - traceId: snapshot.traceId ?? current.traceId, - sessionId, - threadId, - updatedAt: snapshot.updatedAt ?? current.updatedAt - }; - state.chatMessages[index] = nextMessage; - if (sessionId) state.sessionId = sessionId; - if (threadId) state.threadId = threadId; - if (runnerTrace.sessionStatus) state.sessionStatus = runnerTrace.sessionStatus; - if (state.currentRequest?.traceId === (snapshot.traceId ?? current.traceId) && sessionId) { - state.currentRequest.sessionId = sessionId; - } - if (state.currentRequest?.traceId === (snapshot.traceId ?? current.traceId) && threadId) { - state.currentRequest.threadId = threadId; - } - if (runnerTrace.eventsCompacted === true && runnerTrace.fullTraceLoaded !== true) { - maybeReplayFullTraceForMessage(nextMessage); - } - if (shouldReconcileCodeAgentResult(nextMessage, runnerTrace)) { - reconcileCodeAgentResult(messageId, { quiet: options.quiet === true }); - } - if (options.quiet !== true) { - renderCodeAgentSummary(); - patchMessageTracePanel(nextMessage); - renderDevicePodPanel(state.liveSurface); - } -} - -function noteCurrentRequestTraceActivity(snapshot) { - const request = state.currentRequest; - if (!request || request.traceId !== snapshot.traceId) return; - const eventCount = Number.isInteger(snapshot.eventCount) ? snapshot.eventCount : snapshot.events.length; - const assistantChunkCount = traceAssistantStreamChunkCount(snapshot); - const traceUpdatedAt = Date.parse(snapshot.updatedAt ?? ""); - const previousUpdatedAt = Date.parse(request.lastTraceUpdatedAt ?? ""); - const hasAssistantActivity = assistantChunkCount > (request.assistantChunkCount ?? 0); - const hasTimestampActivity = Number.isFinite(traceUpdatedAt) && (!Number.isFinite(previousUpdatedAt) || traceUpdatedAt > previousUpdatedAt); - if (eventCount <= (request.traceEventCount ?? 0) && !hasAssistantActivity && !hasTimestampActivity) { - if (snapshot.waitingFor) request.waitingFor = snapshot.waitingFor; - return; - } - request.traceEventCount = eventCount; - request.assistantChunkCount = assistantChunkCount; - request.lastActivityAt = Date.now(); - request.lastActivityIso = new Date(request.lastActivityAt).toISOString(); - request.lastTraceUpdatedAt = snapshot.updatedAt ?? request.lastTraceUpdatedAt; - request.waitingFor = snapshot.waitingFor ?? request.waitingFor; - request.lastEventLabel = snapshot.lastEvent?.label ?? snapshot.events.at(-1)?.label ?? request.lastEventLabel; -} - -function traceAssistantStreamChunkCount(trace) { - return Array.isArray(trace?.assistantStreams) - ? trace.assistantStreams.reduce((total, stream) => total + Math.max(0, Number(stream?.chunkCount ?? 0)), 0) - : 0; -} - -function latestTraceSnapshot(traceId) { - for (const message of [...state.chatMessages].reverse()) { - if (message.traceId === traceId && message.runnerTrace) return message.runnerTrace; - } - return null; -} - -function runnerTraceFromSnapshot(snapshot, previous = null) { - return { - ...(previous && typeof previous === "object" ? previous : {}), - traceId: snapshot.traceId, - runnerKind: snapshot.runnerKind ?? previous?.runnerKind, - workspace: snapshot.workspace ?? previous?.workspace, - sandbox: snapshot.sandbox ?? previous?.sandbox, - sessionMode: snapshot.sessionMode ?? previous?.sessionMode, - sessionId: snapshot.sessionId ?? previous?.sessionId, - threadId: snapshot.threadId ?? previous?.threadId, - turnId: snapshot.turnId ?? previous?.turnId, - sessionStatus: snapshot.sessionStatus ?? previous?.sessionStatus, - implementationType: snapshot.implementationType ?? previous?.implementationType, - startedAt: snapshot.startedAt ?? previous?.startedAt, - finishedAt: snapshot.finishedAt ?? previous?.finishedAt, - updatedAt: snapshot.updatedAt ?? previous?.updatedAt, - eventCount: Number.isInteger(snapshot.eventCount) ? snapshot.eventCount : snapshot.events.length, - eventsCompacted: snapshot.eventsCompacted === true, - eventWindow: snapshot.eventWindow ?? previous?.eventWindow, - fullTraceLoaded: snapshot.fullTraceLoaded === true || (snapshot.eventsCompacted !== true && Number.isInteger(snapshot.eventCount) && snapshot.eventCount === snapshot.events.length), - elapsedMs: snapshot.elapsedMs ?? previous?.elapsedMs, - waitingFor: snapshot.waitingFor ?? previous?.waitingFor, - events: snapshot.events, - assistantStreams: Array.isArray(snapshot.assistantStreams) ? snapshot.assistantStreams : previous?.assistantStreams ?? [], - eventLabels: snapshot.eventLabels ?? snapshot.events.map((item) => item.label).filter(Boolean), - lastEvent: snapshot.lastEvent ?? snapshot.events.at(-1) ?? null, - outputTruncated: snapshot.outputTruncated === true, - valuesPrinted: false - }; -} - - -function initGateControls() { - el.gateStatusFilter.addEventListener("change", renderGateTable); - el.gateSearch.addEventListener("input", renderGateTable); - el.gateRefresh.addEventListener("click", () => { - loadGateDiagnostics(); - }); -} - -function renderStaticWorkbench() { - el.routePath.textContent = "当前浏览器会话只保存任务草稿,Device Pod 动作需通过受控后端流程。"; - - renderConversation(); - renderDrafts(); - renderDevicePodPanel(null); - renderGateTable(); - renderCodeAgentSummary(); -} - -function renderDrafts() { - el.routePath.textContent = state.chatPending - ? "Code Agent 请求进行中;输入和 trace 会保留,Device Pod 仍只显示 summary。" - : "当前浏览器会话只保存任务草稿,Device Pod 动作需通过受控后端流程。"; -} - -function renderProbePending() { - el.liveStatus.textContent = "等待验证"; - el.liveStatus.className = "tone-pending"; - el.liveDetail.textContent = "正在验证 hwlab-cloud-api /health/live、/v1、/v1/agent/chat 与 /v1/device-pods;未完成前不标为通过。"; - renderLiveBuilds(null); - renderCodeAgentSummary(); -} - -async function sendAgentMessage(message, conversationId, traceId = nextProtocolId("trc"), requestedSessionId = sessionIdForNextRequest(), requestedThreadId = threadIdForNextRequest()) { - const sessionId = requestedSessionId || undefined; - const threadId = requestedThreadId || undefined; - const response = await fetchJson("/v1/agent/chat", { - method: "POST", - headers: { - "Content-Type": "application/json", - "X-Trace-Id": traceId, - "Prefer": "respond-async", - "X-HWLAB-Short-Connection": "1" - }, - timeoutMs: CODE_AGENT_SUBMIT_TIMEOUT_MS, - timeoutName: "Code Agent", - body: JSON.stringify({ - message, - conversationId, - sessionId, - threadId, - traceId, - providerProfile: CODE_AGENT_PROVIDER_PROFILE, - gatewayShellTimeoutMs: GATEWAY_SHELL_TIMEOUT_MS, - shortConnection: true, - projectId: WORKBENCH_PROJECT_ID - }) - }); - - if (!response.ok) { - if (isBlockedAgentResponse(response.data, response.status)) { - return normalizeBlockedAgentResult(response.data, response.status, traceId, response.error); - } - const error = agentErrorFromHttpResponse(response, traceId); - throw error; - } - if (response.status === 202 || response.data?.shortConnection === true) { - return waitForAgentMessageResult(traceId, response.data); - } - return { - ...response.data, - traceId: response.data?.traceId || traceId - }; -} - -async function waitForAgentMessageResult(traceId, accepted = {}) { - const startedAt = Date.now(); - const resultPath = accepted?.resultUrl || `/v1/agent/chat/result/${encodeURIComponent(traceId)}`; - let transientPollErrors = 0; - while (true) { - const activity = readActivityRef(() => state.currentRequest?.traceId === traceId ? state.currentRequest : null, startedAt); - const idleMs = Math.max(0, Date.now() - activity.lastActivityAt); - if (idleMs >= CODE_AGENT_TIMEOUT_MS) { - const error = new Error(`Code Agent 超过 ${CODE_AGENT_TIMEOUT_MS}ms 无新事件`); - error.code = "client_trace_idle_timeout"; - error.timeoutMs = CODE_AGENT_TIMEOUT_MS; - error.idleMs = idleMs; - error.lastActivityAt = activity.lastActivityIso; - error.waitingFor = activity.waitingFor; - error.traceId = traceId; - throw error; - } - const response = await fetchJson(resultPath, { - timeoutMs: Math.min(API_TIMEOUT_MS, 3000), - timeoutName: "Code Agent result" - }); - if (response.ok && response.status === 200 && response.data?.status && response.data.status !== "running") { - return { - ...response.data, - traceId: response.data.traceId || traceId - }; - } - if (response.ok) { - transientPollErrors = 0; - } - if (!response.ok && response.status && response.status !== 404) { - if (isTransientCodeAgentResultPollError(response)) { - transientPollErrors += 1; - await refreshTraceAfterResultPollError(traceId); - await wait(Math.min(TRACE_POLL_INTERVAL_MS * Math.min(transientPollErrors, 5), 5000)); - continue; - } - const error = agentErrorFromHttpResponse(response, traceId); - throw error; - } - await wait(Math.min(TRACE_POLL_INTERVAL_MS, 1000)); - } -} - -function isTransientCodeAgentResultPollError(response) { - if (response?.timeout === true) return true; - const status = Number(response?.status ?? 0); - if ([408, 425, 429, 500, 502, 503, 504].includes(status)) return true; - return /响应不是 JSON|非 JSON|空响应|请求失败/u.test(String(response?.error ?? "")); -} - -async function refreshTraceAfterResultPollError(traceId) { - const messageId = state.currentRequest?.traceId === traceId ? state.currentRequest.messageId : null; - if (!messageId) return; - try { - const response = await fetchJson(`/v1/agent/chat/trace/${encodeURIComponent(traceId)}`, { - timeoutMs: Math.min(API_TIMEOUT_MS, 3000), - timeoutName: "Code Agent trace" - }); - if (response.ok) updateMessageTrace(messageId, response.data, { quiet: true }); - } catch { - // Result polling owns the terminal decision; trace refresh is best-effort. - } -} - -function shouldReconcileCodeAgentResult(message, runnerTrace = message?.runnerTrace) { - if (!message?.traceId || message.role !== "agent") return false; - if (state.resultReconciliationInFlight.has(message.traceId)) return false; - const status = String(message.status ?? "").toLowerCase(); - if (["completed", "canceled"].includes(status)) return false; - if (["failed", "timeout", "error"].includes(status)) return traceHasTerminalResultEvent(runnerTrace) || isRecoverableCodeAgentTerminalMessage(message); - return traceHasTerminalResultEvent(runnerTrace); -} - -function shouldReconcileRestoredCodeAgentResult(message) { - if (!message?.traceId || message.role !== "agent") return false; - if (state.resultReconciliationInFlight.has(message.traceId)) return false; - const status = String(message.status ?? "").toLowerCase(); - if (["completed", "canceled"].includes(status)) return false; - return traceHasTerminalResultEvent(message.runnerTrace); -} - -function isRecoverableCodeAgentTerminalMessage(message) { - const code = normalizeErrorCode(message?.error?.code); - return [ - "client_trace_idle_timeout", - "client_timeout", - "proxy_timeout", - "cloud_api_proxy_timeout", - "code_agent_background_failed", - "code_agent_session_evidence_missing" - ].includes(code) || isCodeAgentTimeoutError(message?.error, message?.text); -} - -function traceHasTerminalResultEvent(runnerTrace) { - const events = Array.isArray(runnerTrace?.events) ? runnerTrace.events : []; - const terminalLabel = runnerTrace?.lastEvent?.label ?? events.at(-1)?.label; - if (/^(?:result|session):(completed|failed|canceled|session_busy)/u.test(String(terminalLabel ?? ""))) return true; - return events.some((event) => /^(?:result|session):(completed|failed|canceled|session_busy)/u.test(String(event?.label ?? ""))); -} - -async function reconcileCodeAgentResult(messageId, options = {}) { - const index = state.chatMessages.findIndex((message) => message.id === messageId); - if (index < 0) return false; - const message = state.chatMessages[index]; - if (!message.traceId || state.resultReconciliationInFlight.has(message.traceId)) return false; - state.resultReconciliationInFlight.add(message.traceId); - try { - const response = await fetchJson(`/v1/agent/chat/result/${encodeURIComponent(message.traceId)}`, { - timeoutMs: Math.min(API_TIMEOUT_MS, 3000), - timeoutName: "Code Agent result reconcile" - }); - if (!response.ok || !response.data || response.data.status === "running") return false; - const updated = applyCodeAgentResultToMessage(messageId, response.data, { - retryInput: message.retryInput, - retryOf: message.retryOf, - fallbackConversationId: message.conversationId, - fallbackThreadId: message.threadId - }); - if (!updated) return false; - renderAgentChatStatus(updated.status, updated); - renderCodeAgentSummary(); - renderConversation(); - renderDrafts(); - renderDevicePodPanel(state.liveSurface); - return true; - } finally { - state.resultReconciliationInFlight.delete(message.traceId); - } -} - -function wait(ms) { - return new Promise((resolve) => window.setTimeout(resolve, ms)); -} - -async function loadLiveSurface() { - const selectedDevicePodId = state.devicePod.selectedDevicePodId || "device-pod-71-freq"; - const encodedPodId = encodeURIComponent(selectedDevicePodId); - const [healthLive, liveBuilds, restIndex, devicePods, devicePodStatus, devicePodEvents, devicePodChipId, devicePodUart, devicePodUartTail, health, adapter] = await Promise.all([ - liveSurfaceFetch("/health/live"), - liveSurfaceFetch("/v1/live-builds"), - liveSurfaceFetch("/v1"), - liveSurfaceFetch("/v1/device-pods"), - liveSurfaceFetch(`/v1/device-pods/${encodedPodId}/status`), - liveSurfaceFetch(`/v1/device-pods/${encodedPodId}/events?limit=120`), - liveSurfaceFetch(`/v1/device-pods/${encodedPodId}/debug-probe/chip-id`), - liveSurfaceFetch(`/v1/device-pods/${encodedPodId}/io-probe/uart/1`), - liveSurfaceFetch(`/v1/device-pods/${encodedPodId}/io-probe/uart/1/tail?maxBytes=12000`), - liveSurfaceRpc("system.health"), - liveSurfaceRpc("cloud.adapter.describe") - ]); - - return { - observedAt: new Date().toISOString(), - healthLive, - liveBuilds, - restIndex, - devicePods, - devicePodStatus, - devicePodEvents, - devicePodChipId, - devicePodUart, - devicePodUartTail, - health, - adapter - }; -} - -function liveSurfaceFetch(path) { - return fetchJson(path, { - timeoutMs: LIVE_SURFACE_TIMEOUT_MS, - timeoutName: `工作台实况 ${path}` - }); -} - -function liveSurfaceRpc(method, params = {}) { - return callRpc(method, params, { - timeoutMs: LIVE_SURFACE_TIMEOUT_MS, - timeoutName: `工作台实况 /json-rpc ${method}` - }); -} - -async function loadGateDiagnostics() { - if (state.gateDiagnostics.loading) return; - state.gateDiagnostics.loading = true; - state.gateDiagnostics.error = null; - renderGateTable(); - const response = await fetchJson("/v1/diagnostics/gate", { - timeoutMs: LIVE_SURFACE_TIMEOUT_MS, - timeoutName: "内部复核 live 聚合" - }); - state.gateDiagnostics.loading = false; - if (response.ok && Array.isArray(response.data?.rows)) { - state.gateDiagnostics.payload = response.data; - state.gateDiagnostics.rows = response.data.rows.map(normalizeGateRow); - state.gateDiagnostics.loadedAt = new Date().toISOString(); - } else { - state.gateDiagnostics.payload = null; - state.gateDiagnostics.rows = [gateLiveBlockerRow(response)]; - state.gateDiagnostics.error = response.error ?? "内部复核 live 后端聚合不可用"; - state.gateDiagnostics.loadedAt = new Date().toISOString(); - } - renderGateTable(); -} - -async function fetchJson(path, options = {}) { - const { - timeoutMs = API_TIMEOUT_MS, - timeoutName = path, - activityRef = null, - ...fetchOptions - } = options; - const controller = new AbortController(); - let timer = null; - let timeoutDetail = null; - const startedAt = Date.now(); - const activityTimeout = Boolean(activityRef); - const scheduleTimeout = () => { - const activity = readActivityRef(activityRef, startedAt); - const idleMs = Math.max(0, Date.now() - activity.lastActivityAt); - const remainingMs = timeoutMs - idleMs; - if (remainingMs <= 0) { - timeoutDetail = { ...activity, idleMs }; - controller.abort(); - return; - } - timer = window.setTimeout(scheduleTimeout, Math.max(1, remainingMs)); - }; - scheduleTimeout(); - try { - const response = await fetch(path, { - ...fetchOptions, - headers: { - Accept: "application/json", - ...(fetchOptions.headers ?? {}) - }, - signal: controller.signal - }); - const text = await response.text(); - let data = null; - try { - data = text ? JSON.parse(text) : null; - } catch (error) { - if (response.ok) throw error; - return { - ok: false, - path, - status: response.status, - data: null, - error: `${path} HTTP ${response.status}:响应不是 JSON` - }; - } - if (!response.ok) { - return { - ok: false, - path, - status: response.status, - data, - error: `${path} HTTP ${response.status}:${messageFromPayload(data)}` - }; - } - return { ok: true, path, status: response.status, data }; - } catch (error) { - const timedOut = error.name === "AbortError"; - const timeoutState = timeoutDetail ?? readActivityRef(activityRef, startedAt); - const idleMs = timedOut ? Math.max(0, Date.now() - timeoutState.lastActivityAt) : undefined; - return { - ok: false, - path, - timeout: timedOut, - timeoutMs, - idleMs, - lastActivityAt: timedOut ? timeoutState.lastActivityIso : undefined, - waitingFor: timedOut ? timeoutState.waitingFor : undefined, - error: timedOut - ? activityTimeout - ? `${timeoutName} 超过 ${timeoutMs}ms 无新事件` - : `${timeoutName} 请求超时 ${timeoutMs}ms` - : `请求失败:${error.message}` - }; - } finally { - if (timer) window.clearTimeout(timer); - } -} - -function readActivityRef(activityRef, fallbackMs = Date.now()) { - const ref = typeof activityRef === "function" ? activityRef() : activityRef; - const lastActivityAt = Number(ref?.lastActivityAt); - const safeLastActivityAt = Number.isFinite(lastActivityAt) && lastActivityAt > 0 ? lastActivityAt : fallbackMs; - return { - lastActivityAt: safeLastActivityAt, - lastActivityIso: typeof ref?.lastActivityIso === "string" ? ref.lastActivityIso : new Date(safeLastActivityAt).toISOString(), - waitingFor: typeof ref?.waitingFor === "string" ? ref.waitingFor : null, - lastEventLabel: typeof ref?.lastEventLabel === "string" ? ref.lastEventLabel : null - }; -} - -async function callRpc(method, params = {}, options = {}) { - const id = nextProtocolId("req"); - const traceId = nextProtocolId("trc"); - const response = await fetchJson("/json-rpc", { - method: "POST", - headers: { - "Content-Type": "application/json" - }, - body: JSON.stringify({ - jsonrpc: "2.0", - id, - method, - params, - meta: { - traceId, - serviceId: "hwlab-cloud-web", - environment: "dev" - } - }), - timeoutMs: options?.timeoutMs ?? API_TIMEOUT_MS, - timeoutName: options?.timeoutName ?? `/json-rpc ${method}` - }); - - if (!response.ok) { - return { ...response, method }; - } - if (response.data?.error) { - return { - ok: false, - path: response.path, - status: response.status, - method, - error: `RPC 错误 ${method}:${response.data.error.message}`, - envelope: response.data - }; - } - return { - ok: true, - path: response.path, - status: response.status, - method, - data: response.data?.result, - envelope: response.data - }; -} - - -function nextProtocolId(prefix) { - rpcSequence += 1; - return `${prefix}_cloud-web-workbench-${Date.now()}-${rpcSequence}`; -} - -function renderLiveSurface(live) { - state.liveSurface = live; - const coreProbes = [live.healthLive, live.restIndex, live.health, live.adapter]; - const surfaceStatus = workbenchApiSurfaceStatus(live, coreProbes); - const runtimeSummary = runtimeSummaryFrom(live); - - el.liveStatus.textContent = surfaceStatus.label; - el.liveStatus.className = `tone-${toneClass(surfaceStatus.tone)}`; - state.codeAgentAvailability = codeAgentAvailabilityFrom(live); - el.liveDetail.textContent = surfaceStatus.detail ?? codeAgentAvailabilitySummary(); - - renderLiveBuilds(live.liveBuilds); - renderAgentChatStatus(deriveAgentChatStatus()); - renderCodeAgentSummary(); - renderDevicePodPanel(live); - renderConversation(); - renderDrafts(); -} - -function renderLiveBuilds(response) { - const payload = response?.ok ? response.data : null; - const services = Array.isArray(payload?.services) ? payload.services : []; - const latest = payload?.latest ?? null; - - if (latest?.build?.createdAt) { - el.liveBuildLatest.textContent = [ - `最新镜像构建时间:${formatBeijingTime(latest.build.createdAt)}`, - latest.name || latest.serviceId || "未知服务", - `tag ${latest.image?.tag || "unknown"}`, - `commit ${shortToken(latest.commit?.id)}`, - `revision ${shortToken(latest.revision)}`, - `来源 ${liveBuildSourceLabel(latest.build?.metadataSource)}` - ].join(" · "); - } else { - el.liveBuildLatest.textContent = "最新镜像构建时间:构建时间不可用"; - } - - if (!response) { - replaceChildren( - el.liveBuildList, - liveBuildUnavailableRow("等待实时元数据聚合返回。") - ); - return; - } - - if (!response.ok) { - replaceChildren( - el.liveBuildList, - liveBuildUnavailableRow(`构建时间不可用:${response.error || "实时元数据聚合不可用"}`) - ); - return; - } - - if (!services.length) { - replaceChildren( - el.liveBuildList, - liveBuildUnavailableRow("构建时间不可用:当前实时元数据未返回微服务明细。") - ); - return; - } - - replaceChildren(el.liveBuildList, ...services.map(liveBuildRow)); -} - -function liveBuildRow(service) { - const row = document.createElement("div"); - row.className = `live-build-row live-build-row-${toneClass(service.status)}`; - - const header = document.createElement("div"); - header.className = "live-build-row-head"; - header.append( - textSpan(service.name || service.serviceId || "未知服务", "live-build-service"), - textSpan(liveBuildTimeText(service), "live-build-time") - ); - - const meta = document.createElement("div"); - meta.className = "live-build-meta"; - meta.append( - textSpan(`tag ${service.image?.tag || "unknown"}`), - textSpan(`commit ${shortToken(service.commit?.id)}`), - textSpan(`revision ${shortToken(service.revision)}`), - textSpan(`来源 ${liveBuildSourceLabel(service.build?.metadataSource)}`) - ); - - const desired = liveBuildDesiredStateText(service); - if (desired) meta.append(textSpan(desired)); - - const reasonText = liveBuildReasonText(service); - if (reasonText) { - row.append(header, meta, textSpan(reasonText, "live-build-reason")); - } else { - row.append(header, meta); - } - return row; -} - -function liveBuildUnavailableRow(reason) { - return liveBuildRow({ - serviceId: "live-build-metadata", - name: "实时元数据", - kind: "hwlab", - status: "unavailable", - build: { - createdAt: null, - unavailableReason: reason - }, - image: { - tag: "unknown" - }, - commit: { - id: "unknown" - }, - revision: "unknown" - }); -} - -function liveBuildTimeText(service) { - if (service.kind === "external") return "外部镜像"; - const createdAt = service.build?.createdAt; - return createdAt ? formatBeijingTime(createdAt) : "构建时间不可用"; -} - -function liveBuildReasonText(service) { - if (service.kind === "external") return "外部镜像或非 HWLAB 构建产物,不参与最新 HWLAB 构建时间计算。"; - if (service.build?.liveHealthMissingReason) return service.build.liveHealthMissingReason; - if (service.build?.unavailableReason) return service.build.unavailableReason; - if (service.error?.message) return `构建时间不可用:${service.error.message}`; - return ""; -} - -function liveBuildSourceLabel(source) { - const text = String(source ?? "").trim(); - if (text.includes("artifact-catalog")) return "artifact catalog"; - if (text.includes("deploy")) return "deploy metadata"; - if (text.includes("runtime-env") || text.includes("health")) return "live health"; - if (text.includes("external")) return "external image"; - if (!text || text === "unavailable") return "不可用"; - return text; -} - -function liveBuildDesiredStateText(service) { - if (!service.desiredState || service.kind === "external") return ""; - const parts = []; - if (Number.isFinite(Number(service.desiredState.replicas))) { - parts.push(`replicas ${Number(service.desiredState.replicas)}`); - } - if (service.desiredState.healthPath) parts.push(service.desiredState.healthPath); - return parts.length ? `desired ${parts.join(" / ")}` : ""; -} - -function renderDevicePodPanel(live) { - state.devicePod.list = live?.devicePods ?? null; - state.devicePod.status = live?.devicePodStatus ?? null; - state.devicePod.events = live?.devicePodEvents ?? null; - state.devicePod.chipId = live?.devicePodChipId ?? null; - state.devicePod.uart = live?.devicePodUart ?? null; - state.devicePod.uartTail = live?.devicePodUartTail ?? null; - renderDevicePodSelector(); - renderDevicePodSummary(); - renderDevicePodInterfaces(); - renderDeviceEventStream(); -} - -function renderDevicePodSelector() { - const payload = state.devicePod.list?.ok ? state.devicePod.list.data : null; - const pods = Array.isArray(payload?.devicePods) && payload.devicePods.length > 0 - ? payload.devicePods - : [{ devicePodId: state.devicePod.selectedDevicePodId || "device-pod-71-freq" }]; - const current = state.devicePod.selectedDevicePodId || pods[0]?.devicePodId || "device-pod-71-freq"; - const existing = [...el.devicePodSelect.options].map((option) => option.value).join("\n"); - const next = pods.map((pod) => pod.devicePodId).join("\n"); - if (existing !== next) { - replaceChildren(el.devicePodSelect, ...pods.map((pod) => { - const option = document.createElement("option"); - option.value = pod.devicePodId; - option.textContent = pod.devicePodId; - return option; - })); - } - el.devicePodSelect.value = current; -} - -function renderDevicePodSummary() { - const response = state.devicePod.status; - const payload = response?.ok ? response.data : null; - const pod = payload?.devicePod ?? null; - const summary = payload?.summary ?? null; - const status = pod?.status ?? summary?.status ?? (response?.ok ? "ok" : "blocked"); - const tone = response?.ok ? toneForDeviceStatus(status, summary?.blocker ?? pod?.blocker) : "blocked"; - el.devicePodStatusTag.textContent = response?.ok ? statusLabel(status) : "未接入"; - el.devicePodStatusTag.className = `state-tag tone-${toneClass(tone)}`; - el.devicePodId.textContent = pod?.devicePodId ?? state.devicePod.selectedDevicePodId ?? "device-pod-71-freq"; - el.devicePodTarget.textContent = pod?.target?.targetId ?? summary?.targetId ?? "未观测"; - el.devicePodProfile.textContent = shortHash(pod?.profile?.profileHash ?? summary?.profileHash ?? "未观测"); - el.devicePodFreshness.textContent = freshnessLabel(pod?.freshness ?? summary?.freshness); - state.devicePod.details.set("pod", payload ?? response ?? { status: "waiting" }); -} - -function renderDevicePodInterfaces() { - const pod = state.devicePod.status?.ok ? state.devicePod.status.data?.devicePod : null; - const chip = state.devicePod.chipId?.ok ? state.devicePod.chipId.data : null; - const uart = state.devicePod.uart?.ok ? state.devicePod.uart.data : null; - const tail = state.devicePod.uartTail?.ok ? state.devicePod.uartTail.data : null; - const tiles = [ - { key: "target", title: "Target", value: pod?.target?.targetId ?? "未观测", detail: pod?.target?.lock?.status ? `lock=${pod.target.lock.status}` : "等待 Device Pod status", tone: pod ? "ok" : "pending", raw: pod?.target ?? null }, - { key: "workspace", title: "Workspace", value: pod?.projectWorkspace?.latestBuild?.artifact ?? "未观测", detail: pod?.projectWorkspace?.workspaceRoot ?? "等待 workspace 摘要", tone: pod?.projectWorkspace?.status ?? "pending", raw: pod?.projectWorkspace ?? null }, - { key: "debug", title: "Debug", value: chip?.chipId ?? pod?.debugInterface?.chipId ?? "未观测", detail: chip?.probe ?? pod?.debugInterface?.probe ?? "等待 probe 摘要", tone: chip?.status ?? pod?.debugInterface?.status ?? "pending", raw: chip ?? pod?.debugInterface ?? null }, - { key: "io", title: "IO", value: uart?.uart?.status ?? pod?.ioInterface?.uart1?.status ?? "未观测", detail: tail?.text ? compactOneLine(tail.text, 92) : "等待 UART1 tail", tone: uart?.status ?? pod?.ioInterface?.status ?? "pending", raw: { uart, tail, ioInterface: pod?.ioInterface ?? null } } - ]; - state.devicePod.details.clear(); - state.devicePod.details.set("pod", state.devicePod.status?.data ?? state.devicePod.status ?? null); - replaceChildren(el.devicePodInterfaces, ...tiles.map(deviceSummaryTile)); -} - -function deviceSummaryTile(tile) { - state.devicePod.details.set(tile.key, tile.raw ?? tile); - const article = document.createElement("article"); - article.className = `summary-tile tone-border-${toneClass(tile.tone)}`; - article.tabIndex = 0; - article.dataset.deviceDetail = tile.key; - article.dataset.deviceTitle = tile.title; - article.setAttribute("role", "button"); - article.setAttribute("aria-label", `查看 ${tile.title} 详情`); - article.addEventListener("keydown", (event) => { - if (event.key !== "Enter" && event.key !== " ") return; - event.preventDefault(); - showDeviceDetail(tile.title, state.devicePod.details.get(tile.key)); - }); - article.append(textSpan(tile.title, "summary-title")); - article.append(textSpan(tile.value, "summary-value mono wrap")); - article.append(textSpan(tile.detail, "summary-detail")); - return article; -} - -function renderDeviceEventStream() { - const payload = state.devicePod.events?.ok ? state.devicePod.events.data : null; - const lines = Array.isArray(payload?.lines) ? payload.lines : []; - const nextText = lines.length > 0 ? lines.join("\n") : "等待 /v1/device-pods 事件流。"; - const wasNearBottom = deviceEventNearBottom(); - const previousLineCount = state.devicePod.lastEventLineCount; - const nextLineCount = lines.length; - if (el.deviceEventText.textContent !== nextText) { - el.deviceEventText.textContent = nextText; - if (state.devicePod.followEvents && wasNearBottom && !deviceEventUserActive()) { - scrollDeviceEventsToBottom(); - } else if (nextLineCount > previousLineCount) { - state.devicePod.unreadEvents += nextLineCount - previousLineCount; - } - } - state.devicePod.lastEventLineCount = nextLineCount; - renderDeviceEventUnreadHint(); -} - -function markDeviceEventScrollIntent() { - state.devicePod.eventScrollUserActiveUntil = Date.now() + SCROLL_USER_ACTIVITY_MS; -} - -function deviceEventUserActive() { - return Date.now() < state.devicePod.eventScrollUserActiveUntil; -} - -function setDeviceEventFollow(enabled, options = {}) { - state.devicePod.followEvents = enabled === true; - el.deviceEventFollow.setAttribute("aria-pressed", state.devicePod.followEvents ? "true" : "false"); - el.deviceEventFollow.textContent = state.devicePod.followEvents ? "跟随" : "暂停跟随"; - if (state.devicePod.followEvents && options.scroll === true) scrollDeviceEventsToBottom(); -} - -function deviceEventNearBottom() { - return scrollBottomGap(el.deviceEventScroll) < 24; -} - -function scrollDeviceEventsToBottom() { - el.deviceEventScroll.scrollTop = el.deviceEventScroll.scrollHeight; -} - -function renderDeviceEventUnreadHint() { - const count = state.devicePod.unreadEvents; - el.deviceEventJump.hidden = count <= 0; - el.deviceEventJump.textContent = `有 ${count} 条新事件`; -} - -function showDeviceDetail(title, payload) { - el.deviceDetailTitle.textContent = title || "Device Pod 详情"; - el.deviceDetailBody.textContent = JSON.stringify(payload ?? { status: "empty" }, null, 2); - if (typeof el.deviceDetailDialog.showModal === "function") { - el.deviceDetailDialog.showModal(); - } else { - el.deviceDetailDialog.setAttribute("open", ""); - } -} - -function toneForDeviceStatus(status, blocker = null) { - if (blocker) return "blocked"; - const value = String(status ?? "").toLowerCase(); - if (["ok", "ready", "live", "pass", "healthy"].includes(value)) return "ok"; - if (["blocked", "failed", "error"].includes(value)) return "blocked"; - return "pending"; -} - -function freshnessLabel(freshness) { - if (!freshness) return "未观测"; - const ageMs = Number(freshness.ageMs); - const age = Number.isFinite(ageMs) ? `${Math.round(ageMs / 1000)}s` : "未知"; - return `${freshness.stale ? "过期" : "新鲜"} / age=${age}`; -} - -function shortHash(value) { - const text = String(value ?? "").trim(); - if (!text) return "未观测"; - return text.length > 26 ? `${text.slice(0, 23)}...` : text; -} - -function compactOneLine(value, maxLength = 120) { - const text = String(value ?? "").replace(/\s+/gu, " ").trim(); - return text.length > maxLength ? `${text.slice(0, maxLength - 3)}...` : text; -} - -function formatBeijingTime(value) { - const date = new Date(value); - if (Number.isNaN(date.getTime())) return "构建时间不可用"; - const parts = Object.fromEntries(new Intl.DateTimeFormat("zh-CN", { - timeZone: "Asia/Shanghai", - year: "numeric", - month: "2-digit", - day: "2-digit", - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - hour12: false - }).formatToParts(date).map((part) => [part.type, part.value])); - return `${parts.year}-${parts.month}-${parts.day} ${parts.hour}:${parts.minute}:${parts.second} 北京时间`; -} - -function shortToken(value) { - const text = String(value ?? "unknown").trim() || "unknown"; - return text.length > 12 ? text.slice(0, 12) : text; -} - -function workbenchApiSurfaceStatus(live, coreProbes = [live.healthLive, live.restIndex, live.health, live.adapter]) { - const status = classifyWorkbenchLiveStatus({ - ...live, - healthLive: live.healthLive ?? coreProbes[0], - restIndex: live.restIndex ?? coreProbes[1], - health: live.health ?? coreProbes[2], - adapter: live.adapter ?? coreProbes[3] - }); - return { - ...status, - methodTone: status.kind === "pass" ? "live" : status.kind - }; -} - -function renderConversation() { - const renderVersion = ++state.conversationRenderVersion; - captureConversationScrollPosition(); - captureTraceScrollPositions(); - captureTraceBodyScrollPositions(); - const introMessages = [ - { - role: "system", - title: "界面模式", - text: "这里是用户工作台:可以整理任务、查看 Device Pod summary 和纯文本事件流。对话会发送到受控 Code Agent 后端;页面不会直接发送硬件变更。", - status: "source" - }, - codeAgentStatusMessage(state.codeAgentAvailability) - ]; - replaceChildren(el.conversationList, ...[...introMessages, ...state.chatMessages].map(messageCard)); - restoreConversationScrollPosition(); - restoreTraceScrollPositions(); - restoreTraceBodyScrollPositions(); - scheduleCodeAgentSessionPersist(); - window.requestAnimationFrame(() => { - if (renderVersion !== state.conversationRenderVersion) return; - restoreConversationScrollPosition({ deferred: true }); - restoreTraceScrollPositions(el.conversationList, { deferred: true }); - restoreTraceBodyScrollPositions(el.conversationList, { deferred: true }); - }); -} - -function initConversationScrollMemory() { - for (const eventName of ["wheel", "touchstart", "pointerdown"]) { - el.conversationList.addEventListener(eventName, markConversationScrollIntent, { passive: true }); - } - el.conversationList.addEventListener("keydown", (event) => { - if (isScrollIntentKey(event.key)) markConversationScrollIntent(); - }); - el.conversationList.addEventListener("scroll", () => { - captureConversationScrollPosition(); - }, { passive: true }); -} - -function markConversationScrollIntent() { - state.conversationScrollUserActiveUntil = Date.now() + SCROLL_USER_ACTIVITY_MS; -} - -function isScrollIntentKey(key) { - return ["ArrowDown", "ArrowUp", "PageDown", "PageUp", "Home", "End", " "].includes(key); -} - -function captureConversationScrollPosition() { - state.conversationScrollPosition = { - top: el.conversationList.scrollTop, - left: el.conversationList.scrollLeft, - bottomGap: scrollBottomGap(el.conversationList) - }; -} - -function restoreConversationScrollPosition(options = {}) { - if (options.deferred === true && Date.now() < state.conversationScrollUserActiveUntil) return; - const position = state.conversationScrollPosition; - if (!position) return; - writeConversationScrollTop(scrollTopForPosition(el.conversationList, position)); - el.conversationList.scrollLeft = Math.min(position.left, Math.max(0, el.conversationList.scrollWidth - el.conversationList.clientWidth)); -} - -function captureTraceScrollPositions(root = el.conversationList) { - for (const list of root.querySelectorAll(".message-trace-events[data-trace-ui-key]")) { - rememberTraceScrollPosition(list.dataset.traceUiKey, list); - } -} - -function rememberTraceScrollPosition(traceUiKey, list) { - if (!traceUiKey || !list) return; - state.traceScrollPositions.set(traceUiKey, { - top: list.scrollTop, - left: list.scrollLeft, - bottomGap: scrollBottomGap(list) - }); -} - -function restoreTraceScrollPositions(root = el.conversationList, options = {}) { - for (const list of root.querySelectorAll(".message-trace-events[data-trace-ui-key]")) { - if (options.deferred === true && isTraceScrollUserActive(list.dataset.traceUiKey)) continue; - const position = state.traceScrollPositions.get(list.dataset.traceUiKey); - if (!position) continue; - writeTraceScrollTop(list, scrollTopForPosition(list, position)); - list.scrollLeft = Math.min(position.left, Math.max(0, list.scrollWidth - list.clientWidth)); - } -} - -function captureTraceBodyScrollPositions(root = el.conversationList) { - for (const body of root.querySelectorAll(".message-trace-body")) { - rememberTraceBodyScrollPosition(body); - } -} - -function traceBodyScrollKey(body) { - const list = body?.closest?.(".message-trace-events[data-trace-ui-key]"); - const row = body?.closest?.(".message-trace-row[data-trace-row-id]"); - const traceUiKey = list?.dataset?.traceUiKey; - const rowId = row?.dataset?.traceRowId; - return traceUiKey && rowId ? `${traceUiKey}:${rowId}` : null; -} - -function rememberTraceBodyScrollPosition(body) { - const key = traceBodyScrollKey(body); - if (!key || !body) return; - state.traceBodyScrollPositions.set(key, elementScrollPosition(body)); -} - -function restoreTraceBodyScrollPositions(root = el.conversationList, options = {}) { - for (const body of root.querySelectorAll(".message-trace-body")) { - const key = traceBodyScrollKey(body); - if (!key) continue; - if (options.deferred === true && isTraceBodyScrollUserActive(key)) continue; - const position = state.traceBodyScrollPositions.get(key); - if (!position) continue; - restoreElementScrollPosition(body, position); - } -} - -function markTraceBodyScrollIntent(body) { - const key = traceBodyScrollKey(body); - if (!key) return; - state.traceBodyScrollUserActiveUntil.set(key, Date.now() + SCROLL_USER_ACTIVITY_MS); -} - -function isTraceBodyScrollUserActive(key) { - if (!key) return false; - return Date.now() < Number(state.traceBodyScrollUserActiveUntil.get(key) ?? 0); -} - -function elementScrollPosition(element) { - return { - top: element.scrollTop, - left: element.scrollLeft, - bottomGap: scrollBottomGap(element) - }; -} - -function restoreElementScrollPosition(element, position) { - if (!element || !position) return; - element.scrollTop = scrollTopForPosition(element, position); - element.scrollLeft = Math.min(position.left, Math.max(0, element.scrollWidth - element.clientWidth)); -} - -function scrollBottomGap(element) { - return Math.max(0, element.scrollHeight - element.clientHeight - element.scrollTop); -} - -function scrollTopForPosition(element, position) { - const maxTop = Math.max(0, element.scrollHeight - element.clientHeight); - if (Number(position.bottomGap) <= SCROLL_BOTTOM_PIN_PX) return maxTop; - return Math.min(position.top, maxTop); -} - -function markTraceScrollIntent(traceUiKey) { - if (!traceUiKey) return; - state.traceScrollUserActiveUntil.set(traceUiKey, Date.now() + SCROLL_USER_ACTIVITY_MS); -} - -function isTraceScrollUserActive(traceUiKey) { - if (!traceUiKey) return false; - return Date.now() < Number(state.traceScrollUserActiveUntil.get(traceUiKey) ?? 0); -} - -function writeConversationScrollTop(top) { - if (el.conversationList.scrollTop === top) return; - state.traceProgrammaticScrollWrites += 1; - el.conversationList.scrollTop = top; -} - -function writeTraceScrollTop(list, top) { - if (!list || list.scrollTop === top) return; - state.traceProgrammaticScrollWrites += 1; - list.scrollTop = top; -} - -function patchMessageTracePanel(message) { - const traceUiKey = messageTraceUiKey(message); - if (!traceUiKey) return false; - const panel = el.conversationList.querySelector(`.message-trace[data-trace-ui-key="${cssEscape(traceUiKey)}"]`); - if (!panel) return false; - const replacement = messageTracePanel(message); - if (!replacement) { - panel.remove(); - return true; - } - patchTracePanelElement(panel, replacement); - return true; -} - -function patchTracePanelElement(panel, replacement) { - panel.open = replacement.open; - const currentSummary = panel.querySelector("summary"); - const nextSummary = replacement.querySelector("summary"); - if (currentSummary && nextSummary) { - currentSummary.className = nextSummary.className; - currentSummary.textContent = nextSummary.textContent; - } - - const currentToolbar = panel.querySelector(".message-trace-toolbar"); - const nextToolbar = replacement.querySelector(".message-trace-toolbar"); - if (currentToolbar && nextToolbar) { - currentToolbar.replaceChildren(...nextToolbar.childNodes); - } else if (!currentToolbar && nextToolbar) { - const list = panel.querySelector(".message-trace-events"); - panel.insertBefore(nextToolbar, list); - } else if (currentToolbar && !nextToolbar) { - currentToolbar.remove(); - } - - const currentList = panel.querySelector(".message-trace-events[data-trace-ui-key]"); - const nextList = replacement.querySelector(".message-trace-events[data-trace-ui-key]"); - if (currentList && nextList) { - captureTraceBodyScrollPositions(currentList); - currentList.dataset.traceMode = nextList.dataset.traceMode ?? ""; - patchTraceEventList(currentList, [...nextList.children]); - restoreTraceBodyScrollPositions(currentList); - } -} - -function cssEscape(value) { - if (window.CSS && typeof window.CSS.escape === "function") return window.CSS.escape(value); - return String(value).replace(/["\\]/gu, "\\$&"); -} - - -function renderGateTable() { - const rows = filteredGateRows(); - const loading = state.gateDiagnostics.loading; - const payload = state.gateDiagnostics.payload; - const statusTone = gatePayloadTone(); - const loadedAt = state.gateDiagnostics.loadedAt; - el.gateSourceStatus.textContent = loading - ? "读取 live 后端中" - : payload - ? `live 后端 ${rows.length}/${state.gateDiagnostics.rows.length} 行` - : state.gateDiagnostics.error - ? "live 后端阻塞" - : "等待 live 后端"; - el.gateSourceStatus.className = `state-tag tone-${toneClass(statusTone)}`; - el.gateSourceStatus.title = payload - ? `route=${payload.route}; observedAt=${payload.observedAt ?? loadedAt ?? "unknown"}` - : state.gateDiagnostics.error ?? "等待 /v1/diagnostics/gate"; - - if (loading && state.gateDiagnostics.rows.length === 0) { - const tr = document.createElement("tr"); - tr.append(tableCell("正在读取 live 后端聚合。", "wrap", 8)); - replaceChildren(el.gateReviewBody, tr); - return; - } - - if (rows.length === 0) { - const tr = document.createElement("tr"); - tr.append(tableCell("没有匹配当前过滤条件的复核行。", "wrap", 8)); - replaceChildren(el.gateReviewBody, tr); - return; - } - - replaceChildren(el.gateReviewBody, ...rows.map(gateTableRow)); -} - -function filteredGateRows() { - const filter = el.gateStatusFilter.value; - const query = el.gateSearch.value.trim().toLowerCase(); - return state.gateDiagnostics.rows.filter((row) => { - if (filter !== "all" && row.status !== filter) return false; - if (!query) return true; - return [ - row.category, - row.check, - row.status, - row.owner, - row.detail, - ...(row.evidence ?? []), - row.updatedAt, - row.next - ].join(" ").toLowerCase().includes(query); - }); -} - -function gateTableRow(row) { - const tr = document.createElement("tr"); - tr.dataset.status = row.status; - tr.dataset.statusKey = row.statusKey; - tr.append(tableCell(row.category, "wrap")); - tr.append(tableCell(row.check, "wrap")); - const status = document.createElement("td"); - status.append(badge(row.status, row.statusKey)); - tr.append(status); - tr.append(tableCell(row.owner, "mono wrap")); - tr.append(tableCell(row.detail, "wrap")); - tr.append(tableCell((row.evidence ?? []).join(" / "), "mono wrap")); - tr.append(tableCell(formatTimestamp(row.updatedAt), "mono wrap")); - tr.append(tableCell(row.next, "wrap")); - return tr; -} - -function normalizeGateRow(row) { - const statusKey = normalizeGateStatusKey(row.statusKey ?? row.status); - return { - category: oneLine(row.category || "内部复核"), - check: oneLine(row.check || "未命名检查项"), - status: gateStatusLabel(row.status, statusKey), - statusKey, - owner: oneLine(row.owner || "未标明"), - detail: oneLine(row.detail || "live 后端未返回关键细节"), - evidence: normalizeEvidenceList(row.evidence), - updatedAt: row.updatedAt || new Date().toISOString(), - next: oneLine(row.next || "等待维护者补充下一步。"), - sourceKind: "LIVE-BACKEND" - }; -} - -function gateLiveBlockerRow(response) { - const now = new Date().toISOString(); - return normalizeGateRow({ - category: "健康检查", - check: "内部复核 live 后端聚合", - status: "阻塞", - statusKey: response.timeout ? "failed" : "blocked", - owner: "hwlab-cloud-api /v1/diagnostics/gate", - detail: response.error || "live 后端聚合不可用;页面不会回退到 SOURCE 静态拓扑。", - evidence: [ - "GET /v1/diagnostics/gate", - response.status ? `HTTP ${response.status}` : null, - response.timeout ? `timeoutMs=${response.timeoutMs}` : null - ].filter(Boolean), - updatedAt: now, - next: "先恢复后端聚合接口,再刷新本表。" - }); -} - -function gatePayloadTone() { - if (state.gateDiagnostics.loading) return "pending"; - if (!state.gateDiagnostics.payload) return state.gateDiagnostics.error ? "blocked" : "source"; - const rows = state.gateDiagnostics.rows; - if (rows.some((row) => row.statusKey === "failed")) return "failed"; - if (rows.some((row) => row.statusKey === "blocked")) return "blocked"; - if (rows.some((row) => row.statusKey === "pending")) return "pending"; - return "pass"; -} - -function renderAgentChatStatus(status, result = null) { - const labels = { - idle: "等待输入", - running: "处理中", - completed: "DEV-LIVE 回复", - source: "SOURCE 回复", - failed: "后端失败", - timeout: "等待超时", - canceled: "已取消", - error: "请求错误", - blocked: "服务受阻" - }; - el.agentChatStatus.textContent = agentStatusLabel(status, result, labels); - el.agentChatStatus.className = `state-tag tone-${toneClass(agentStatusTone(status, result))}`; - el.commandInput.disabled = status === "running"; - el.commandSend.disabled = status === "running"; - el.commandSend.textContent = status === "running" ? "发送中" : "发送"; - if (result?.conversationId) { - el.agentChatStatus.title = `${result.provider ?? "provider"} / ${result.model ?? "model"} / ${result.backend ?? "backend"}`; - } else { - el.agentChatStatus.removeAttribute("title"); - } -} - -function renderCodeAgentSummary() { - const summary = currentCodeAgentStatusSummary(); - el.codeAgentSummary.className = `code-agent-summary tone-border-${toneClass(summary.tone)}`; - el.codeAgentSummary.dataset.codeAgentStatusKind = summary.kind; - el.codeAgentSummaryIcon.textContent = summary.icon; - el.codeAgentSummaryIcon.className = `code-agent-summary-icon tone-${toneClass(summary.tone)}`; - el.codeAgentSummaryLabel.textContent = summary.label; - el.codeAgentSummaryLabel.className = `code-agent-summary-label tone-${toneClass(summary.tone)}`; - el.codeAgentSummaryCapability.textContent = summary.capabilityLevel; - el.codeAgentSummaryTrace.textContent = `trace ${summary.lastTraceId}`; - replaceChildren(el.codeAgentSummaryDetail, ...codeAgentSummaryRows(summary)); -} - -function currentCodeAgentStatusSummary() { - return classifyCodeAgentStatusSummary({ - availability: state.codeAgentAvailability, - latestMessage: latestChatResult(), - live: state.liveSurface - }); -} - -function codeAgentSummaryRows(summary) { - return [ - codeAgentSummaryRow("codeAgent.status", summary.codeAgentStatus, summary.tone), - codeAgentSummaryRow("provider/mode/backend", summary.providerModeBackend, "source"), - codeAgentSummaryRow("capabilityLevel", summary.capabilityLevel, summary.tone), - codeAgentSummaryRow("session", summary.sessionSummary, sessionSummaryTone(summary)), - codeAgentSummaryRow("sessionId/status", summary.sessionIdStatus, sessionSummaryTone(summary)), - codeAgentSummaryRow("会话提示", summary.sessionLifecycleHint, sessionSummaryTone(summary)), - codeAgentSummaryRow("workspace", summary.workspace, "source"), - codeAgentSummaryRow("sandbox", summary.sandbox, "source"), - codeAgentSummaryRow("runnerKind", summary.runnerKind, summary.kind === "long-lived-session" ? "ok" : "source"), - codeAgentSummaryRow("protocol", summary.protocol, summary.runtimePathTone), - codeAgentSummaryRow("implementationType", summary.implementationType, summary.runtimePathTone), - codeAgentSummaryRow("providerTrace.command", summary.providerTraceCommand, summary.runtimePathTone), - codeAgentSummaryRow("providerTrace.terminalStatus", summary.providerTraceTerminalStatus, summary.runtimePathTone), - codeAgentSummaryRow("运行路径语义", summary.runtimePathLabel, summary.runtimePathTone), - codeAgentSummaryRow("toolCalls", summary.toolCalls, summary.toolCalls === "none" ? "source" : summary.tone), - codeAgentSummaryRow("skills", summary.skills, summary.skills === "none" ? "source" : summary.tone), - codeAgentSummaryRow("conversation facts", conversationFactsSummary(summary.latestMessage?.conversationFacts), summary.latestMessage?.conversationFacts ? "source" : "blocked"), - codeAgentSummaryRow("fact traces", conversationFactTracesSummary(summary.latestMessage?.conversationFacts), summary.latestMessage?.conversationFacts ? "source" : "blocked"), - codeAgentSummaryRow("runnerTrace", summary.runnerTrace, "source"), - codeAgentSummaryRow("readiness blockers", summary.blockersLabel, summary.readinessBlockers.length > 0 ? "blocked" : "source"), - codeAgentSummaryRow("last traceId", summary.lastTraceId, "source"), - codeAgentSummaryRow("当前部署 revision", summary.deploymentRevision, "source") - ]; -} - -function codeAgentSummaryRow(label, value, tone) { - const row = document.createElement("div"); - row.className = "code-agent-summary-row"; - row.append(textSpan(label, "code-agent-summary-key"), textSpan(value, `code-agent-summary-value tone-${toneClass(tone)}`)); - return row; -} - -function sessionSummaryTone(summary) { - if (summary.kind === "long-lived-session") return "ok"; - if (summary.kind === "blocked" || summary.kind === "skill-cli-api-blocked") return "blocked"; - if (["read-only-session-tools", "stateless-one-shot", "fallback-text-chat-only", "degraded"].includes(summary.kind)) return "warn"; - return "source"; -} - -function agentStatusLabel(status, result, labels) { - if (status !== "failed") return labels[status] ?? statusLabel(status); - const presentation = agentFailurePresentation(result?.error, { result }); - return ( - { - timeout: "等待超时", - provider: "Provider 不可用", - runner_busy: "Runner 忙碌", - session_blocked: "Session 受阻", - canceled: "已取消", - runner_blocked: "Runner 受阻", - api_error: "API 错误", - needs_config: "需要配置", - capability_unavailable: "能力未开放", - security_blocked: "安全阻断", - fallback: "仍是 fallback", - retryable: "可重试" - }[presentation.category] ?? labels.failed - ); -} - -function agentStatusTone(status, result) { - if (status === "completed") return "dev-live"; - if (["failed", "blocked", "timeout", "canceled", "error"].includes(status)) return "blocked"; - if (status === "running") return "pending"; - return "source"; -} - -function sourceTitle(result) { - return `Code Agent 回复 ${shortTime(result.updatedAt ?? new Date().toISOString())}`; -} - -function sourceFixtureTitle(result) { - return `Code Agent SOURCE 回复 ${shortTime(result.updatedAt ?? new Date().toISOString())}`; -} - -function textFallbackTitle(result) { - return `Code Agent 文本 fallback 回复 ${shortTime(result.updatedAt ?? new Date().toISOString())}`; -} - -function classifyCodeAgentCompletion(result, { blockedError = null } = {}) { - if (result?.status === "canceled") { - return { - status: "canceled", - replied: false, - sourceKind: "BLOCKED", - title: "Code Agent 已取消" - }; - } - if (result?.status === "timeout") { - return { - status: "timeout", - replied: false, - sourceKind: "BLOCKED", - title: "Code Agent 无新事件超时" - }; - } - if (result?.status === "error") { - return { - status: "failed", - replied: false, - sourceKind: "BLOCKED", - title: "Code Agent 返回错误" - }; - } - if (isStructuredBlockedChatResult(result)) { - return { - status: "failed", - replied: false, - sourceKind: "BLOCKED", - title: agentFailurePresentation(structuredBlockedErrorFromResult(result), { result }).title - }; - } - if (blockedError) { - return { - status: "failed", - replied: false, - sourceKind: "BLOCKED", - title: agentFailurePresentation(blockedError, { result }).title - }; - } - if (isRealCompletedChatResult(result)) { - return { - status: "completed", - replied: true, - sourceKind: "DEV-LIVE", - title: sourceTitle(result) - }; - } - if (isSourceFixtureChatResult(result)) { - return { - status: "source", - replied: true, - sourceKind: "SOURCE", - title: sourceFixtureTitle(result) - }; - } - if (isTextFallbackChatResult(result)) { - return { - status: "source", - replied: true, - sourceKind: "TEXT-FALLBACK", - title: textFallbackTitle(result) - }; - } - return { - status: "failed", - replied: false, - sourceKind: "BLOCKED", - title: result?.status === "completed" && !result?.blocker ? "Code Agent 完成证据不足" : agentFailurePresentation(result?.error, { result }).title - }; -} - -function isStructuredBlockedChatResult(result) { - const error = result?.error && typeof result.error === "object" ? result.error : {}; - return Boolean( - result && - typeof result === "object" && - ( - result.blocker?.code || - error.blocker?.code || - result.capabilityLevel === "hwlab-api-control-blocked" - ) - ); -} - -function structuredBlockedErrorFromResult(result) { - if (!isStructuredBlockedChatResult(result)) return null; - const error = result?.error && typeof result.error === "object" ? result.error : {}; - const blocker = error.blocker ?? result?.blocker ?? {}; - const code = normalizeErrorCode(error.code ?? blocker.code ?? "code_agent_blocked"); - const selectedBlocker = blocker; - return { - ...error, - code, - layer: error.layer ?? selectedBlocker.layer, - category: error.category ?? selectedBlocker.category, - blocker: selectedBlocker, - retryable: error.retryable ?? selectedBlocker.retryable, - userMessage: error.userMessage ?? selectedBlocker.userMessage, - message: error.message ?? selectedBlocker.summary ?? selectedBlocker.zh ?? selectedBlocker.message ?? code, - traceId: error.traceId ?? result?.traceId, - route: error.route ?? selectedBlocker.route, - toolName: error.toolName ?? selectedBlocker.toolName - }; -} - - -function failureMessage(result) { - const presentation = agentFailurePresentation(structuredBlockedErrorFromResult(result) ?? result?.error, { result }); - if (presentation.category !== "unknown") { - return presentation.text; - } - if (result?.error?.code === "provider_unavailable" || result?.availability?.status === "blocked") { - const availability = result.error?.providerStatus || result.availability?.status !== "blocked" - ? codeAgentAvailabilityFromResult(result) - : result.availability ?? codeAgentAvailabilityFromResult(result); - return codeAgentBlockedSummary(availability); - } - const missing = [ - ...(result.error?.missingCommands ?? []), - ...(result.error?.missingEnv ?? []) - ].filter(Boolean); - const suffix = missing.length ? ` 缺口:${missing.join("、")}。` : ""; - return `Code Agent 调用失败:${result.error?.message ?? "未知错误"}。${suffix}请稍后重试或联系维护者补齐后端 provider。`; -} - -function agentErrorFromHttpResponse(response, traceId) { - const payloadError = response.data?.error; - const code = response.timeout - ? "client_timeout" - : normalizeErrorCode( - (payloadError && typeof payloadError === "object" ? payloadError.code : null) ?? - (typeof payloadError === "string" ? payloadError : null) ?? - response.data?.code ?? - "request_failed" - ); - const error = new Error(response.timeout - ? `Code Agent 超过 ${response.timeoutMs}ms 无新事件;已保留输入,可稍后重试。` - : payloadError?.userMessage || response.error || "Code Agent 请求失败"); - error.traceId = response.data?.traceId || traceId; - error.code = code; - error.layer = payloadError && typeof payloadError === "object" ? payloadError.layer : undefined; - error.blocker = payloadError && typeof payloadError === "object" ? payloadError.blocker : undefined; - error.retryable = payloadError && typeof payloadError === "object" ? payloadError.retryable : response.timeout ? true : undefined; - error.userMessage = payloadError && typeof payloadError === "object" ? payloadError.userMessage : undefined; - error.missingConfig = payloadError && typeof payloadError === "object" ? payloadError.missingConfig : undefined; - error.route = payloadError && typeof payloadError === "object" ? payloadError.route : undefined; - error.toolName = payloadError && typeof payloadError === "object" ? payloadError.toolName : undefined; - error.timeoutMs = response.timeoutMs ?? (payloadError && typeof payloadError === "object" ? payloadError.timeoutMs : undefined); - error.hardTimeoutMs = payloadError && typeof payloadError === "object" ? payloadError.hardTimeoutMs : undefined; - error.idleMs = response.idleMs ?? (payloadError && typeof payloadError === "object" ? payloadError.idleMs : undefined); - error.lastActivityAt = response.lastActivityAt ?? (payloadError && typeof payloadError === "object" ? payloadError.lastActivityAt : undefined); - error.waitingFor = response.waitingFor ?? (payloadError && typeof payloadError === "object" ? payloadError.waitingFor : undefined); - error.providerStatus = payloadError && typeof payloadError === "object" ? payloadError.providerStatus : response.status; - error.category = payloadError && typeof payloadError === "object" ? payloadError.category : undefined; - return error; -} - -function agentFailurePresentation(error, { result = null, traceId = null } = {}) { - const code = normalizeErrorCode(error?.code); - const message = String(error?.message ?? result?.error?.message ?? "").trim(); - const structuredBlocker = error?.blocker ?? result?.error?.blocker ?? result?.blocker ?? null; - const userMessage = String(error?.userMessage ?? result?.error?.userMessage ?? structuredBlocker?.userMessage ?? "").trim(); - const observedTraceId = result?.traceId || error?.traceId || traceId || null; - const traceSuffix = observedTraceId ? ` trace=${observedTraceId}` : ""; - const timeoutMs = error?.timeoutMs ?? result?.error?.timeoutMs ?? timeoutMsFromText(message); - const hardTimeoutMs = error?.hardTimeoutMs ?? result?.error?.hardTimeoutMs; - const idleMs = error?.idleMs ?? result?.error?.idleMs; - const lastActivityAt = error?.lastActivityAt ?? result?.error?.lastActivityAt; - const waitingFor = error?.waitingFor ?? result?.error?.waitingFor; - const providerStatus = error?.providerStatus ?? result?.error?.providerStatus; - - if (code === "codex_stdio_hard_timeout") { - return { - category: "timeout", - title: "Code Agent 达到硬性上限", - text: `Code Agent 达到硬性总时长上限${hardTimeoutMs ? ` ${hardTimeoutMs}ms` : ""};输入已保留,可稍后重试或缩小问题范围。${traceSuffix}` - }; - } - - if (isTimeoutFailure(code, message)) { - const detail = [ - typeof idleMs === "number" ? `idleMs=${idleMs}` : null, - lastActivityAt ? `lastActivityAt=${lastActivityAt}` : null, - waitingFor ? `waitingFor=${waitingFor}` : null - ].filter(Boolean).join(";"); - return { - category: "timeout", - title: "Code Agent 无新事件超时", - text: `Code Agent ${timeoutMs ? `超过 ${timeoutMs}ms ` : ""}无新事件;输入已保留,可稍后重试或缩小问题范围。${detail ? ` ${detail}。` : ""}${traceSuffix}` - }; - } - - if (code === "codex_stdio_canceled" || code === "session_canceled") { - return { - category: "canceled", - title: "Code Agent 已取消", - text: `本次 Codex stdio 请求已取消;输入、sessionId 和 traceId 已保留,可重试上一条消息。${traceSuffix}` - }; - } - - if (userMessage) { - const category = error?.category ?? result?.error?.category ?? structuredBlocker?.category ?? "runner_blocked"; - return { - category, - title: titleForBlockerCategory(category, code), - text: `${userMessage}${traceSuffix}` - }; - } - - if (code === "session_busy") { - return { - category: "runner_busy", - title: "会话忙/请等待", - text: `Codex stdio runner 正在处理上一轮请求;输入已保留,请等待当前请求完成或取消后重试。${traceSuffix}` - }; - } - - if (code === "session_expired") { - return { - category: "session_blocked", - title: "会话已过期", - text: `当前 session 已过期;输入已保留,可重新发送建立新的 Codex stdio session。${traceSuffix}` - }; - } - - if (["session_reuse_conflict", "session_failed", "session_interrupted", "session_canceled", "session_timeout", "session_error"].includes(code)) { - return { - category: "session_blocked", - title: "Code Agent Session 受阻", - text: `当前 session 已过期、失败、中断或与 conversation 绑定不一致;输入已保留,可重新发送建立新的 Codex stdio session。${traceSuffix}` - }; - } - - if ([ - "runner_unavailable", - "tool_unavailable", - "skills_unavailable", - "security_blocked", - "codex_stdio_blocked", - "codex_stdio_failed", - "codex_stdio_protocol_blocked", - "codex_stdio_empty_response", - "codex_stdio_command_probe_failed" - ].includes(code)) { - return { - category: "runner_blocked", - title: "Code Agent Runner 受阻", - text: `Codex stdio runner 返回 ${code};本次不会 fallback 冒充 Codex session。${message ? `原因:${message}。` : ""}${traceSuffix}` - }; - } - - if (["invalid_params", "parse_error", "request_failed", "cloud_api_proxy_failed", "upstream_unavailable"].includes(code)) { - return { - category: "api_error", - title: "Code Agent API 错误", - text: `Code Agent API 请求失败;输入已保留,可稍后重试。${message ? `原因:${message}。` : ""}${traceSuffix}` - }; - } - - if (code === "provider_unavailable" || providerStatus) { - return { - category: "provider", - title: "Code Agent Provider 不可用", - text: providerStatus - ? `Code Agent provider 返回 HTTP ${providerStatus};本次未生成回复,输入已保留,可稍后重试。${traceSuffix}` - : `Code Agent provider 暂不可用;本次未生成回复,输入已保留,可稍后重试。${traceSuffix}` - }; - } - - return { - category: "unknown", - title: "Code Agent 返回失败", - text: `Code Agent 调用失败:${message || "未知错误"}。请稍后重试或联系维护者补齐后端 provider。${traceSuffix}` - }; -} - -function titleForBlockerCategory(category, code) { - const value = String(category ?? "").trim(); - if (value === "needs_config") return "Code Agent 需要配置"; - if (value === "security_blocked" || code === "security_blocked") return "Code Agent 安全阻断"; - if (value === "fallback" || code === "text_chat_only_fallback") return "Code Agent 仍是 fallback"; - if (value === "timeout") return "Code Agent 无新事件超时"; - if (value === "canceled" || code === "codex_stdio_canceled") return "Code Agent 已取消"; - if (value === "runner_busy") return "Code Agent Runner 忙碌"; - if (value === "session_blocked") return "Code Agent Session 受阻"; - if (value === "runner_blocked") return "Code Agent Runner 受阻"; - if (value === "api_error") return "Code Agent API 错误"; - if (value === "capability_unavailable") return "Code Agent 能力未开放"; - if (value === "retryable") return "Code Agent 可重试"; - return "Code Agent 服务受阻"; -} - -function normalizeErrorCode(code) { - const normalized = String(code ?? "").trim(); - return normalized || "code_agent_failed"; -} - -function isTimeoutFailure(code, message) { - return ["client_timeout", "proxy_timeout", "cloud_api_proxy_timeout", "codex_stdio_timeout", "codex_stdio_idle_timeout", "codex_stdio_hard_timeout"].includes(code) || - /\b(?:timeout|timed out|abort|aborted|超时|请求超过|无新事件)\b/iu.test(message); -} - -function isCodeAgentTimeoutError(error, fallbackMessage = "") { - const code = normalizeErrorCode(error?.code); - const message = [error?.message, error?.userMessage, fallbackMessage] - .filter(Boolean) - .join(" "); - return isTimeoutFailure(code, message); -} - -function timeoutMsFromText(message) { - const match = String(message ?? "").match(/(?:timed out after|timeout after|请求超时|请求超过|超过)\s*(\d+)\s*ms/iu); - if (!match) return null; - const value = Number.parseInt(match[1], 10); - return Number.isInteger(value) && value > 0 ? value : null; -} - -function messageCard(message) { - const article = document.createElement("article"); - article.className = `message-card message-${toneClass(message.role)} status-${toneClass(message.status)}`; - article.append(textSpan(roleLabel(message.role), "message-role")); - article.append(textSpan(message.title, "message-title")); - article.append(messageCopyNode(message)); - const pendingContext = messagePendingContextPanel(message); - if (pendingContext) article.append(pendingContext); - const sessionContext = messageSessionContinuityPanel(message); - if (sessionContext) article.append(sessionContext); - const runtimePath = messageRuntimePathPanel(message); - if (runtimePath) article.append(runtimePath); - const tracePanel = messageTracePanel(message); - if (tracePanel) article.append(tracePanel); - const actions = messageActionsPanel(message); - if (actions) article.append(actions); - return article; -} - -function messageCopyNode(message) { - if (!shouldRenderMessageMarkdown(message)) { - return textSpan(message.text, "message-copy"); - } - const node = document.createElement("div"); - node.className = "message-copy message-copy-markdown"; - const markdown = String(message.text ?? ""); - try { - node.innerHTML = renderMessageMarkdown(markdown); - } catch (error) { - node.textContent = markdown || `Markdown 渲染失败:${error.message}`; - } - hardenRenderedMarkdown(node); - return node; -} - -function shouldRenderMessageMarkdown(message) { - return message?.role === "agent" && MESSAGE_MARKDOWN_STATUSES.includes(String(message.status ?? "").toLowerCase()); -} - -function messageActionsPanel(message) { - if (message.role !== "agent") return null; - const actions = []; - if (message.status === "running") { - actions.push(actionButton("取消当前请求", "cancel", () => cancelAgentMessage(message.id), "取消当前 in-flight Codex stdio 请求")); - if (message.retryInput) { - actions.push(actionButton("重试上一条", "retry", () => restartRunningAgentMessage(message.id), "先取消当前 trace,再用同一输入重新发送")); - } - } - if (canRetryTerminalAgentMessage(message)) { - actions.push(actionButton("重试上一条", "retry", () => retryAgentMessage(message.id), "保留 conversation/trace 记录并重新发送上一条输入")); - } - if (message.traceId) { - actions.push(actionButton("回放 trace", "trace", () => replayAgentTrace(message.id), "从 runnerTrace store 重新读取真实事件")); - } - if (actions.length === 0 && !message.traceReplayStatus) return null; - const panel = document.createElement("div"); - panel.className = "message-actions"; - panel.append(...actions); - if (message.traceReplayStatus) { - panel.append(textSpan(message.traceReplayStatus, "message-action-status")); - } - return panel; -} - -function actionButton(label, action, onClick, title) { - const button = document.createElement("button"); - button.type = "button"; - button.className = `message-action message-action-${action}`; - button.textContent = label; - button.title = title; - button.addEventListener("click", onClick); - return button; -} - -function canRetryTerminalAgentMessage(message) { - return Boolean( - message?.retryInput && - ["failed", "timeout", "error", "canceled"].includes(String(message.status ?? "").toLowerCase()) - ); -} - -async function restartRunningAgentMessage(messageId) { - const message = state.chatMessages.find((item) => item.id === messageId); - if (!message?.retryInput || message.status !== "running") return; - await cancelAgentMessage(messageId); - const canceled = state.chatMessages.find((item) => item.id === messageId); - if (canceled?.status !== "canceled" || state.chatPending) { - setAgentActionStatus(messageId, "取消未确认,当前 trace 仍在等待后端;已保留输入,可等待真实超时或后端结果后再重试。"); - return; - } - await submitAgentMessage(message.retryInput, { - conversationId: canceled.conversationId || state.conversationId || undefined, - sessionId: isTerminalSessionStatus(canceled.session?.status ?? canceled.runnerTrace?.sessionStatus ?? canceled.status) - ? undefined - : canceled.sessionId, - threadId: isTerminalSessionStatus(canceled.session?.status ?? canceled.runnerTrace?.sessionStatus ?? canceled.status) - ? undefined - : canceled.threadId ?? threadIdFrom(canceled), - retryOf: canceled.traceId - }); -} - -async function retryAgentMessage(messageId) { - const message = state.chatMessages.find((item) => item.id === messageId); - if (!message?.retryInput) return; - if (state.chatPending) { - setAgentActionStatus(messageId, "当前仍有请求处理中;请先取消当前请求,或等待真实超时/后端结果后再重试。"); - return; - } - await submitAgentMessage(message.retryInput, { - conversationId: message.conversationId || state.conversationId || undefined, - sessionId: isTerminalSessionStatus(message.session?.status ?? message.runnerTrace?.sessionStatus ?? message.status) - ? undefined - : message.sessionId, - threadId: isTerminalSessionStatus(message.session?.status ?? message.runnerTrace?.sessionStatus ?? message.status) - ? undefined - : message.threadId ?? threadIdFrom(message), - retryOf: message.traceId - }); -} - -function setAgentActionStatus(messageId, text) { - const index = state.chatMessages.findIndex((message) => message.id === messageId); - if (index < 0) return; - state.chatMessages[index] = { - ...state.chatMessages[index], - traceReplayStatus: text, - updatedAt: new Date().toISOString() - }; - renderConversation(); -} - -async function cancelAgentMessage(messageId) { - const index = state.chatMessages.findIndex((message) => message.id === messageId); - if (index < 0) return; - const message = state.chatMessages[index]; - const traceId = message.traceId; - const sessionId = message.sessionId || message.runnerTrace?.sessionId || state.currentRequest?.sessionId || state.sessionId; - const threadId = message.threadId || message.runnerTrace?.threadId || state.currentRequest?.threadId || state.threadId; - const response = await cancelAgentRequest({ - traceId, - conversationId: message.conversationId || state.conversationId, - sessionId, - threadId - }); - const payload = response.data ?? {}; - const runnerTrace = payload.runnerTrace ?? latestTraceSnapshot(traceId); - if (response.ok && payload.canceled === true) { - state.canceledTraces.add(traceId); - stopRunningTraceStream(traceId); - state.chatPending = false; - state.currentRequest = null; - state.sessionId = payload.sessionId || sessionId || state.sessionId; - state.sessionStatus = payload.session?.status ?? runnerTrace?.sessionStatus ?? "canceled"; - state.chatMessages[index] = { - ...message, - title: "Code Agent 已取消", - text: payload.userMessage || "当前 Codex stdio 请求已取消;输入、sessionId、traceId 和最后 trace event 已保留,可重试上一条消息。", - status: "canceled", - sessionId: payload.sessionId || sessionId || message.sessionId, - threadId: payload.threadId || threadId || message.threadId, - sessionContinuity: failedSessionContinuity(message.sessionContinuity, { code: "codex_stdio_canceled" }), - session: payload.session ?? message.session, - runnerTrace, - traceReplayStatus: lastTraceEventLabel(runnerTrace), - updatedAt: new Date().toISOString(), - error: payload.error ?? { - code: "codex_stdio_canceled", - category: "canceled", - retryable: true, - message: "user canceled current Code Agent request" - } - }; - if (message.retryInput) el.commandInput.value = message.retryInput; - } else { - state.chatMessages[index] = { - ...message, - title: "Code Agent 取消受阻", - text: payload.error?.userMessage || response.error || "当前请求没有可取消的 in-flight Codex stdio session;输入和 trace 已保留。", - status: "failed", - threadId, - sessionContinuity: failedSessionContinuity(message.sessionContinuity, { code: "cancel_blocked" }), - runnerTrace, - traceReplayStatus: lastTraceEventLabel(runnerTrace), - updatedAt: new Date().toISOString(), - error: payload.error ?? { - code: "cancel_blocked", - category: "cancel_blocked", - retryable: true, - message: response.error || "cancel blocked" - } - }; - if (message.retryInput) el.commandInput.value = message.retryInput; - } - renderAgentChatStatus(state.chatMessages[index].status, state.chatMessages[index]); - renderCodeAgentSummary(); - renderConversation(); - renderDrafts(); - renderDevicePodPanel(state.liveSurface); -} - -function stopRunningTraceStream(traceId) { - const id = nonEmptyString(traceId); - if (!id) return; - const close = state.traceStreams.get(id); - if (typeof close === "function") close(); - state.traceStreams.delete(id); -} - -async function cancelAgentRequest({ traceId, conversationId, sessionId, threadId }) { - return fetchJson("/v1/agent/chat/cancel", { - method: "POST", - headers: { - "Content-Type": "application/json", - "X-Trace-Id": traceId, - "Prefer": "respond-async" - }, - timeoutMs: CODE_AGENT_CANCEL_TIMEOUT_MS, - timeoutName: "Code Agent cancel", - body: JSON.stringify({ - traceId, - conversationId, - sessionId, - threadId - }) - }); -} - -async function replayAgentTrace(messageId) { - const index = state.chatMessages.findIndex((message) => message.id === messageId); - if (index < 0) return; - const message = state.chatMessages[index]; - if (!message.traceId) return; - const response = await fetchJson(`/v1/agent/chat/trace/${encodeURIComponent(message.traceId)}`, { - timeoutMs: Math.min(Math.max(API_TIMEOUT_MS, 5000), FULL_TRACE_REPLAY_TIMEOUT_MS), - timeoutName: "Code Agent trace replay" - }); - if (response.ok) { - const runnerTrace = runnerTraceFromSnapshot({ ...response.data, fullTraceLoaded: true }, message.runnerTrace); - state.chatMessages[index] = { - ...message, - runnerTrace, - threadId: runnerTrace.threadId ?? message.threadId, - traceReplayStatus: `完整 trace 已回放:${runnerTrace.events?.length ?? 0} 个原始 event;${lastTraceEventLabel(runnerTrace)}`, - updatedAt: response.data?.updatedAt ?? new Date().toISOString() - }; - if (shouldReconcileCodeAgentResult(state.chatMessages[index], runnerTrace)) { - reconcileCodeAgentResult(message.id, { quiet: true }); - } - } else { - state.chatMessages[index] = { - ...message, - traceReplayStatus: response.error || "trace 回放失败", - updatedAt: new Date().toISOString() - }; - } - renderCodeAgentSummary(); - renderConversation(); - renderDevicePodPanel(state.liveSurface); -} - -function maybeReplayFullTraceForMessage(message) { - if (!message || !message.traceId || message.runnerTrace?.eventsCompacted !== true) return; - if (state.fullTraceReplayInFlight.has(message.traceId)) return; - replayFullTrace(message.id, { quiet: true }); -} - -async function replayFullTrace(messageId, options = {}) { - const index = state.chatMessages.findIndex((message) => message.id === messageId); - if (index < 0) return false; - const message = state.chatMessages[index]; - if (!message.traceId) return false; - if (state.fullTraceReplayInFlight.has(message.traceId)) return false; - state.fullTraceReplayInFlight.add(message.traceId); - try { - const response = await fetchJson(`/v1/agent/chat/trace/${encodeURIComponent(message.traceId)}`, { - timeoutMs: Math.min(Math.max(API_TIMEOUT_MS, 5000), FULL_TRACE_REPLAY_TIMEOUT_MS), - timeoutName: "Code Agent full trace replay" - }); - if (!response.ok) return false; - const currentIndex = state.chatMessages.findIndex((item) => item.id === messageId); - if (currentIndex < 0) return false; - const current = state.chatMessages[currentIndex]; - const runnerTrace = runnerTraceFromSnapshot({ ...response.data, fullTraceLoaded: true }, current.runnerTrace); - state.chatMessages[currentIndex] = { - ...current, - runnerTrace, - threadId: runnerTrace.threadId ?? current.threadId, - traceReplayStatus: `完整 trace 已回放:${runnerTrace.events?.length ?? 0} 个原始 event;${lastTraceEventLabel(runnerTrace)}`, - updatedAt: response.data?.updatedAt ?? new Date().toISOString() - }; - if (shouldReconcileCodeAgentResult(state.chatMessages[currentIndex], runnerTrace)) { - reconcileCodeAgentResult(messageId, { quiet: true }); - } - if (options.quiet !== true) { - renderCodeAgentSummary(); - renderConversation(); - renderDevicePodPanel(state.liveSurface); - } else { - renderConversation(); - } - return true; - } finally { - state.fullTraceReplayInFlight.delete(message.traceId); - } -} - -function lastTraceEventLabel(runnerTrace) { - const event = runnerTrace?.lastEvent ?? (Array.isArray(runnerTrace?.events) ? runnerTrace.events.at(-1) : null); - if (!event) return "lastEvent=none"; - return `lastEvent=${event.label ?? `${event.type ?? "event"}:${event.status ?? "observed"}`}`; -} - -function messageSessionContinuityPanel(message) { - if (message.role !== "agent" || message.status === "running") return null; - const continuity = message.sessionContinuity ?? completedSessionContinuity(message, pendingSessionContinuity({ - conversationId: message.conversationId, - sessionId: message.sessionId, - threadId: message.threadId, - retryOf: message.retryOf - })); - if (!continuity) return null; - const fields = [ - ["状态", continuity.label], - ["conversation", continuity.returned?.conversationId ?? message.conversationId], - ["session", continuity.returned?.sessionId ?? message.sessionId ?? "未确认"], - ["thread", continuity.returned?.threadId ?? message.threadId ?? "未确认"], - ["lifecycle", message.sessionLifecycleStatus ?? message.sessionSummary?.status ?? message.session?.lifecycleStatus], - ["提示", message.sessionSummary?.userMessage ?? message.sessionLifecycle?.userMessage], - ["requestedSession", continuity.requested?.sessionId], - ["requestedThread", continuity.requested?.threadId], - ["retryOf", continuity.requested?.retryOf ?? message.retryOf], - ["missing", continuity.missing?.join("、")], - ["changed", continuity.changed?.join("、")] - ]; - const list = document.createElement("div"); - list.className = "message-session-grid"; - for (const [label, value] of fields) { - if (value === undefined || value === null || value === "") continue; - const item = document.createElement("div"); - item.className = "message-session-row"; - item.append(textSpan(label, "message-session-key"), textSpan(value, "message-session-value mono")); - if (label === "conversation" || label === "session" || label === "thread") { - item.append(copyButton(value, label)); - } - list.append(item); - } - return messageCompactDetails({ - className: "message-session-context", - tone: continuity.tone, - badgeLabel: continuity.label, - badgeTone: continuity.tone, - summary: continuity.summary, - dialogTitle: "会话明细", - body: list - }); -} - -function messagePendingContextPanel(message) { - if (message.status !== "running") return null; - const continuity = message.sessionContinuity ?? pendingSessionContinuity({ - conversationId: message.conversationId, - sessionId: message.sessionId, - threadId: message.threadId, - retryOf: message.retryOf - }); - const fields = [ - ["状态", continuity.label], - ["traceId", message.traceId], - ["conversation", message.conversationId], - ["session", message.sessionId ?? message.runnerTrace?.sessionId ?? "等待后端分配"], - ["thread", message.threadId ?? message.runnerTrace?.threadId ?? "等待后端分配"], - ["sessionStatus", message.runnerTrace?.sessionStatus], - ["lifecycle", message.runnerTrace?.sessionLifecycleStatus], - ["activityTimeout", `${formatTraceDuration(CODE_AGENT_TIMEOUT_MS)} 无新事件`] - ]; - const section = document.createElement("section"); - section.className = "message-pending-context tone-border-pending"; - const header = document.createElement("div"); - header.className = "message-pending-head"; - header.append(badge("处理中", "pending"), textSpan(continuity.summary, "message-pending-summary")); - const list = document.createElement("div"); - list.className = "message-pending-grid"; - for (const [label, value] of fields) { - if (value === undefined || value === null || value === "") continue; - const item = document.createElement("div"); - item.className = "message-pending-row"; - item.append(textSpan(label, "message-pending-key"), textSpan(value, "message-pending-value mono")); - if (label === "traceId" || label === "conversation" || label === "session") { - item.append(copyButton(value, label)); - } - list.append(item); - } - section.append(header, list); - return section; -} - - -function messageRuntimePathPanel(message) { - if (message.role !== "agent") return null; - const runtimePath = codeAgentRuntimePathFromMessage(message); - if (!runtimePath) return null; - const rows = document.createElement("div"); - rows.className = "message-runtime-grid"; - for (const row of runtimePath.rows) { - const item = document.createElement("div"); - item.className = `message-runtime-row${row.missing ? " message-runtime-row-missing" : ""}`; - item.append(textSpan(row.label, "message-runtime-key"), textSpan(row.value, "message-runtime-value mono")); - if (!row.missing && (row.label === "providerTrace.command" || row.label === "providerTrace.terminalStatus" || row.label === "protocol")) { - item.append(copyButton(row.value, row.label)); - } - rows.append(item); - } - if (runtimePath.missingFields.length > 0) { - const item = document.createElement("div"); - item.className = "message-runtime-row message-runtime-row-missing message-runtime-row-wide"; - item.append( - textSpan("缺失字段", "message-runtime-key"), - textSpan(runtimePath.missingFields.join(", "), "message-runtime-value mono") - ); - rows.append(item); - } - const details = messageCompactDetails({ - className: "message-runtime-path", - tone: runtimePath.tone, - badgeLabel: runtimePath.label, - badgeTone: runtimePath.tone, - summary: runtimePath.summary, - dialogTitle: "运行路径明细", - body: rows - }); - details.dataset.runtimePathKind = runtimePath.kind; - return details; -} - -function messageCompactDetails({ className, tone, badgeLabel, badgeTone, summary, dialogTitle, body }) { - const section = document.createElement("section"); - section.className = `${className} message-compact-details tone-border-${toneClass(tone)}`; - const trigger = document.createElement("button"); - trigger.type = "button"; - trigger.className = "message-compact-summary"; - trigger.setAttribute("aria-haspopup", "dialog"); - trigger.append( - badge(badgeLabel, badgeTone), - textSpan(summary, "message-compact-summary-text") - ); - trigger.addEventListener("click", () => { - openWorkbenchDialog({ - title: dialogTitle, - body, - restoreFocus: trigger - }); - }); - section.append(trigger); - return section; -} - -function boundedJson(value, maxLength = 6000) { - let text = ""; - try { - text = JSON.stringify(value, null, 2); - } catch { - text = String(value ?? ""); - } - return text.length > maxLength ? `${text.slice(0, maxLength)}\n...字段已截断` : text; -} - -function messageTracePanel(message) { - const trace = message.runnerTrace && typeof message.runnerTrace === "object" ? message.runnerTrace : null; - if (!trace && !message.traceId) return null; - const details = document.createElement("details"); - details.className = "message-trace"; - const traceUiKey = messageTraceUiKey(message); - if (traceUiKey) details.dataset.traceUiKey = traceUiKey; - const storedOpen = traceUiKey ? state.traceDetailsOpen.get(traceUiKey) : undefined; - details.open = typeof storedOpen === "boolean" ? storedOpen : defaultTraceDetailsOpen(message); - if (traceUiKey) { - details.addEventListener("toggle", () => { - state.traceDetailsOpen.set(traceUiKey, details.open); - }); - } - const summary = document.createElement("summary"); - summary.className = "message-meta"; - summary.textContent = runnerTraceHeadline(message, trace); - const list = document.createElement("ol"); - list.className = "message-trace-events"; - if (traceUiKey) { - list.dataset.traceUiKey = traceUiKey; - for (const eventName of ["wheel", "touchstart", "pointerdown"]) { - list.addEventListener(eventName, () => markTraceScrollIntent(traceUiKey), { passive: true }); - } - list.addEventListener("keydown", (event) => { - if (isScrollIntentKey(event.key)) markTraceScrollIntent(traceUiKey); - }); - list.addEventListener("scroll", () => rememberTraceScrollPosition(traceUiKey, list), { passive: true }); - } - const events = Array.isArray(trace?.events) ? trace.events : []; - const rows = traceDisplayRows(trace, events); - if (events.length === 0) { - const item = document.createElement("li"); - item.textContent = `trace=${message.traceId ?? "pending"};等待后端事件。`; - list.append(item); - } else { - const toolbar = messageTraceToolbar(message, trace, events, rows); - list.dataset.traceMode = "all"; - renderTraceEventList(list, rows); - details.append(summary, toolbar, list); - return details; - } - details.append(summary, list); - return details; -} - -function messageTraceUiKey(message) { - const traceId = nonEmptyString(message.traceId); - const messageId = nonEmptyString(message.id ?? message.messageId); - if (!traceId && !messageId) return null; - return `${messageId ?? "message"}:${traceId ?? "trace"}`; -} - -function defaultTraceDetailsOpen(message) { - return ["running", "completed", "source", "failed", "timeout", "canceled", "error"].includes(message.status); -} - -function messageTraceToolbar(message, trace, events, rows) { - const toolbar = document.createElement("div"); - toolbar.className = "message-trace-toolbar"; - const rawTotal = Number.isInteger(trace?.eventCount) ? trace.eventCount : events.length; - const count = textSpan(messageTraceCountText(trace, rawTotal, events.length, rows.length), "message-trace-count"); - toolbar.append(count); - toolbar.append(traceActionButton("复制 JSON", () => copyTextToClipboard(messageTraceJson(message, trace, events)), "复制完整 trace JSON")); - toolbar.append(traceActionButton("下载 trace", () => downloadTraceJson(message, trace, events), "下载完整 trace JSON 文件")); - return toolbar; -} - -function messageTraceCountText(trace, rawTotal, loadedTotal, readableTotal) { - if (trace?.eventsCompacted === true && trace?.fullTraceLoaded !== true) { - return `完整 trace 回放中 / 当前可读事件 ${readableTotal} / 已载入原始 ${loadedTotal} / 后端原始 ${rawTotal}`; - } - return `显示全部可读事件 ${readableTotal} / 已载入原始 ${loadedTotal} / 后端原始 ${rawTotal}`; -} - -function renderTraceEventList(list, rows) { - patchTraceEventList(list, rows.map((row, index) => traceEventRowNode(row, index))); -} - -function patchTraceEventList(list, nextItems) { - const currentById = new Map(); - for (const current of [...list.children]) { - const rowId = current.dataset.traceRowId; - if (rowId && !currentById.has(rowId)) currentById.set(rowId, current); - } - const retained = new Set(); - for (let index = 0; index < nextItems.length; index += 1) { - const next = nextItems[index]; - const rowId = next.dataset.traceRowId; - const current = rowId ? currentById.get(rowId) : null; - const item = current && !retained.has(current) ? current : next; - if (item === current) patchTraceRowElement(current, next); - const before = list.children[index] ?? null; - if (before !== item) list.insertBefore(item, before); - retained.add(item); - } - for (const current of [...list.children]) { - if (!retained.has(current)) current.remove(); - } -} - -function patchTraceRowElement(current, next) { - current.className = next.className; - current.dataset.traceRowKey = next.dataset.traceRowKey ?? ""; - current.dataset.traceRowId = next.dataset.traceRowId ?? ""; - const currentHeader = current.querySelector(".message-trace-line"); - const nextHeader = next.querySelector(".message-trace-line"); - if (currentHeader && nextHeader) { - currentHeader.className = nextHeader.className; - if (currentHeader.textContent !== nextHeader.textContent) currentHeader.textContent = nextHeader.textContent; - } else if (!currentHeader && nextHeader) { - current.prepend(nextHeader); - } else if (currentHeader && !nextHeader) { - currentHeader.remove(); - } - const currentBody = current.querySelector(".message-trace-body"); - const nextBody = next.querySelector(".message-trace-body"); - if (currentBody && nextBody) { - patchTraceBodyElement(currentBody, nextBody); - } else if (!currentBody && nextBody) { - current.append(nextBody); - } else if (currentBody && !nextBody) { - currentBody.remove(); - } -} - -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; - } - restoreElementScrollPosition(currentBody, position); - rememberTraceBodyScrollPosition(currentBody); -} - -function traceEventRowNode(row, index = 0) { - const item = document.createElement("li"); - item.className = `message-trace-row tone-border-${toneClass(row.tone)}`; - const rowId = traceRowId(row, index); - item.dataset.traceRowId = rowId; - item.dataset.traceRowKey = traceRowKey(row); - const header = document.createElement("div"); - header.className = "message-trace-line"; - header.textContent = row.header; - item.append(header); - if (row.body) { - const body = document.createElement("pre"); - body.className = "message-trace-body"; - body.dataset.traceBodyId = rowId; - installTraceBodyScrollListeners(body); - body.textContent = row.body; - item.append(body); - } - return item; -} - -function installTraceBodyScrollListeners(body) { - for (const eventName of ["wheel", "touchstart", "pointerdown"]) { - body.addEventListener(eventName, () => markTraceBodyScrollIntent(body), { passive: true }); - } - body.addEventListener("keydown", (event) => { - if (isScrollIntentKey(event.key)) markTraceBodyScrollIntent(body); - }); - body.addEventListener("scroll", () => rememberTraceBodyScrollPosition(body), { passive: true }); -} - -function traceRowId(row, index = 0) { - if (row?.rowId) return String(row.rowId); - if (row?.seq !== null && row?.seq !== undefined) return `event:${row.seq}`; - return `row:${index}:${traceRowKey(row)}`; -} - -function traceRowKey(row) { - return `${row.header}\n${row.body ?? ""}\n${row.tone ?? ""}`; -} - -function installWorkbenchTestHooks() { - if (!isLocalWorkbenchTestHost() || !new URLSearchParams(window.location.search).has("hwlab-test-hooks")) return; - window.__hwlabWorkbenchTestHooks = { - seedCompletedAgentMessage, - seedTraceMessage, - appendTraceEvents, - reconcileTraceResultFixture, - latestAgentMessageText, - traceScrollMetrics, - traceDomIdentity, - traceBodyDomIdentity, - setTraceBodyScrollTop, - replaceTraceAssistantStream, - setTraceScrollTop, - setConversationScrollTop, - resetProgrammaticScrollWriteCount - }; -} - -function isLocalWorkbenchTestHost() { - return ["127.0.0.1", "localhost", "::1", "[::1]"].includes(window.location.hostname); -} - -function seedCompletedAgentMessage(options = {}) { - state.chatMessages = [{ - id: options.messageId ?? "msg_markdown_contract", - role: "agent", - title: options.title ?? "Code Agent 回复", - text: String(options.text ?? ""), - status: options.status ?? "completed", - traceId: options.traceId ?? "trc_markdown_contract", - conversationId: options.conversationId ?? "cnv_markdown_contract", - sessionId: options.sessionId ?? "ses_markdown_contract" - }]; - renderConversation(); -} - -function seedTraceMessage(options = {}) { - const traceId = options.traceId ?? "trc_scroll_contract"; - const messageId = options.messageId ?? "msg_scroll_contract"; - const count = Math.max(1, Number(options.count ?? 80)); - const events = Array.isArray(options.events) - ? options.events.map((event, index) => normalizeTestTraceEvent(traceId, event, index + 1)) - : Array.from({ length: count }, (_, index) => testTraceEvent(traceId, index + 1)); - state.chatMessages = [{ - id: messageId, - role: "agent", - title: "Code Agent 处理中", - text: "Trace scroll contract fixture", - status: "running", - traceId, - runnerTrace: { - traceId, - events, - assistantStreams: Array.isArray(options.assistantStreams) ? options.assistantStreams : [], - eventCount: Number.isInteger(options.eventCount) ? options.eventCount : events.length, - eventsCompacted: options.eventsCompacted === true, - eventWindow: options.eventWindow ?? null, - fullTraceLoaded: options.fullTraceLoaded === true, - lastEvent: options.lastEvent ?? events.at(-1), - waitingFor: "turn/completed" - } - }]; - renderConversation(); -} - -function normalizeTestTraceEvent(traceId, event, seq) { - return { - traceId, - seq, - createdAt: new Date(1779690000000 + seq * 1000).toISOString(), - ...event, - traceId: event?.traceId ?? traceId, - seq: event?.seq ?? seq, - createdAt: event?.createdAt ?? new Date(1779690000000 + seq * 1000).toISOString() - }; -} - -function appendTraceEvents(count = 1) { - const message = state.chatMessages.find((item) => item.runnerTrace?.traceId); - if (!message) return; - const trace = message.runnerTrace; - const start = Array.isArray(trace.events) ? trace.events.length : 0; - const additions = Array.from({ length: Math.max(1, Number(count)) }, (_, index) => testTraceEvent(trace.traceId, start + index + 1)); - trace.events = [...trace.events, ...additions]; - trace.eventCount = trace.events.length; - trace.lastEvent = trace.events.at(-1); - patchMessageTracePanel(message); -} - -function replaceTraceAssistantStream(stream = {}) { - const message = state.chatMessages.find((item) => item.runnerTrace?.traceId); - if (!message) return null; - const trace = message.runnerTrace; - trace.assistantStreams = [{ - itemId: "assistant-message", - chunkCount: Math.max(1, Number(stream.chunkCount ?? 1)), - createdAt: stream.createdAt ?? new Date(1779699000000).toISOString(), - updatedAt: stream.updatedAt ?? new Date(1779699001000 + Math.max(1, Number(stream.chunkCount ?? 1)) * 1000).toISOString(), - waitingFor: stream.waitingFor ?? "turn/completed", - text: String(stream.text ?? "assistant stream fixture") - }]; - patchMessageTracePanel(message); - return traceBodyDomIdentity(".message-trace-events[data-trace-ui-key] li:last-child pre"); -} - -async function reconcileTraceResultFixture(result) { - const message = state.chatMessages.find((item) => item.runnerTrace?.traceId || item.traceId); - if (!message) return null; - applyCodeAgentResultToMessage(message.id, { - ...(result && typeof result === "object" ? result : {}), - traceId: message.traceId, - conversationId: message.conversationId ?? "cnv_fixture", - sessionId: message.sessionId ?? "ses_fixture", - status: "completed", - provider: "codex-stdio", - model: "gpt-test", - backend: "hwlab-cloud-api/codex-app-server-stdio", - workspace: "/workspace/hwlab", - sandbox: "workspace-write", - capabilityLevel: "long-lived-codex-stdio-session", - runner: { - kind: "codex-app-server-stdio-runner", - codexStdio: true, - writeCapable: true, - durableSession: true, - workspace: "/workspace/hwlab", - sandbox: "workspace-write", - sessionMode: "codex-app-server-stdio-long-lived", - implementationType: "repo-owned-codex-app-server-stdio-session", - capabilityLevel: "long-lived-codex-stdio-session" - }, - session: { - sessionId: message.sessionId ?? "ses_fixture", - status: "idle", - idleTimeoutMs: 1800000, - lastTraceId: message.traceId, - longLivedSession: true, - durableSession: true, - codexStdio: true, - writeCapable: true - }, - sessionMode: "codex-app-server-stdio-long-lived", - implementationType: "repo-owned-codex-app-server-stdio-session", - sessionReuse: { reused: true, sessionId: message.sessionId ?? "ses_fixture" }, - longLivedSessionGate: { status: "pass", pass: true }, - toolCalls: [{ name: "codex-app-server.thread/resume+turn/start", status: "completed" }], - skills: [], - reply: { - content: "fixture reconciled result", - ...(result?.reply && typeof result.reply === "object" ? result.reply : {}) - }, - providerTrace: { - protocol: "codex-app-server-jsonrpc-stdio", - command: "codex app-server --listen stdio://", - terminalStatus: "completed", - ...(result?.providerTrace && typeof result.providerTrace === "object" ? result.providerTrace : {}) - }, - runnerTrace: message.runnerTrace, - updatedAt: new Date().toISOString() - }, { - baseMessage: message, - retryInput: message.retryInput, - retryOf: message.retryOf - }); - renderCodeAgentSummary(); - renderConversation(); - return latestAgentMessageText(); -} - -function latestAgentMessageText() { - const message = [...state.chatMessages].reverse().find((item) => item.role === "agent"); - return message ? { - title: message.title, - text: message.text, - status: message.status, - traceId: message.traceId, - providerTrace: message.providerTrace ?? null, - sourceKind: message.sourceKind ?? null, - capabilityLevel: message.capabilityLevel ?? null, - error: message.error ?? null - } : null; -} - -function testTraceEvent(traceId, seq) { - return { - traceId, - label: "trace:scroll-contract", - type: "trace", - status: "observed", - createdAt: new Date(1779690000000 + seq * 1000).toISOString(), - message: `trace scroll contract event ${seq}\n${"payload ".repeat(18)}`, - waitingFor: "turn/completed" - }; -} - -function traceScrollMetrics() { - const conversation = el.conversationList; - const list = conversation.querySelector(".message-trace-events[data-trace-ui-key]"); - return { - conversationTop: conversation.scrollTop, - conversationBottomGap: scrollBottomGap(conversation), - traceTop: list?.scrollTop ?? null, - traceBottomGap: list ? scrollBottomGap(list) : null, - traceScrollHeight: list?.scrollHeight ?? null, - traceClientHeight: list?.clientHeight ?? null, - traceRowCount: list?.querySelectorAll(".message-trace-row").length ?? 0 - }; -} - -function traceDomIdentity() { - const conversation = el.conversationList; - const panel = conversation.querySelector(".message-trace[data-trace-ui-key]"); - const list = conversation.querySelector(".message-trace-events[data-trace-ui-key]"); - const firstRow = list?.querySelector(".message-trace-row") ?? null; - if (panel && !panel.__hwlabTraceIdentity) panel.__hwlabTraceIdentity = `panel-${Date.now()}-${Math.random()}`; - if (list && !list.__hwlabTraceIdentity) list.__hwlabTraceIdentity = `list-${Date.now()}-${Math.random()}`; - if (firstRow && !firstRow.__hwlabTraceIdentity) firstRow.__hwlabTraceIdentity = `row-${Date.now()}-${Math.random()}`; - return { - panel: panel?.__hwlabTraceIdentity ?? null, - list: list?.__hwlabTraceIdentity ?? null, - firstRow: firstRow?.__hwlabTraceIdentity ?? null, - rowCount: list?.querySelectorAll(".message-trace-row").length ?? 0, - programmaticScrollWrites: state.traceProgrammaticScrollWrites - }; -} - -function traceBodyDomIdentity(selector = ".message-trace-events[data-trace-ui-key] li:last-child pre") { - 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()}`; - if (row && !row.__hwlabTraceIdentity) row.__hwlabTraceIdentity = `row-${Date.now()}-${Math.random()}`; - return { - selector, - body: body?.__hwlabTraceIdentity ?? null, - row: row?.__hwlabTraceIdentity ?? null, - rowId: row?.dataset.traceRowId ?? null, - top: body?.scrollTop ?? null, - left: body?.scrollLeft ?? null, - scrollHeight: body?.scrollHeight ?? null, - clientHeight: body?.clientHeight ?? null, - text: body?.textContent ?? null - }; -} - -function resetProgrammaticScrollWriteCount() { - state.traceProgrammaticScrollWrites = 0; - return state.traceProgrammaticScrollWrites; -} - -function setTraceScrollTop(top, { user = true } = {}) { - const list = el.conversationList.querySelector(".message-trace-events[data-trace-ui-key]"); - if (!list) return traceScrollMetrics(); - if (user) markTraceScrollIntent(list.dataset.traceUiKey); - list.scrollTop = top; - rememberTraceScrollPosition(list.dataset.traceUiKey, list); - return traceScrollMetrics(); -} - -function setTraceBodyScrollTop(selector, top, { user = true } = {}) { - const body = el.conversationList.querySelector(selector); - if (!body) return traceBodyDomIdentity(selector); - if (user) markTraceBodyScrollIntent(body); - body.scrollTop = top; - rememberTraceBodyScrollPosition(body); - return traceBodyDomIdentity(selector); -} - -function setConversationScrollTop(top, { user = true } = {}) { - if (user) markConversationScrollIntent(); - el.conversationList.scrollTop = top; - captureConversationScrollPosition(); - return traceScrollMetrics(); -} - -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 = []; - }; - for (const event of events) { - if (isNoisyTraceEvent(event)) { - flushToolOutputRun(); - noisyRun.push(event); - continue; - } - if (isToolOutputChunkTraceEvent(event)) { - flushNoisyRun(); - toolOutputRun.push(event); - continue; - } - flushNoisyRun(); - flushToolOutputRun(); - const row = traceDisplayRow(trace, event); - if (!row) continue; - 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 traceDisplayRow(trace, event, options = {}) { - if (!options.includeNoise && isNoisyTraceEvent(event)) return null; - if (isCommandExecutionTraceEvent(event)) return traceCommandExecutionRow(trace, event); - const clock = traceClock(event.createdAt); - const total = formatTraceDuration(traceRelativeMs(trace, event)); - const status = traceStatusToken(event); - const label = readableTraceLabel(event); - const meta = [ - status, - event.errorCode ? `error=${event.errorCode}` : null, - typeof event.timeoutMs === "number" ? `timeout=${formatTraceDuration(event.timeoutMs)}` : null, - typeof event.idleMs === "number" ? `idle=${formatTraceDuration(event.idleMs)}` : null, - event.outputSummary ? `out=${compactTraceSize(event.outputSummary)}` : null - ].filter(Boolean).join(" "); - const body = traceDisplayBody(event); - return { - rowId: `event:${event.seq ?? `${event.label ?? event.type ?? "event"}:${event.createdAt ?? "unknown"}`}`, - seq: event.seq ?? null, - tone: traceEventTone(event), - header: `${clock} total=${total}${meta ? ` ${meta}` : ""}${label ? ` ${label}` : ""}`, - body - }; -} - -function traceCommandExecutionRow(trace, event) { - const clock = traceClock(event.createdAt); - const total = formatTraceDuration(traceRelativeMs(trace, event)); - const status = traceStatusToken(event); - const command = cleanTraceOutputText(event.command ?? ""); - const meta = [ - status, - "tool cmd", - command ? `:: ${compactTraceTextTail(command, 220)}` : null, - Number.isInteger(event.exitCode) ? `exit=${event.exitCode}` : null, - typeof event.durationMs === "number" ? `s=${(event.durationMs / 1000).toFixed(1)}` : null, - typeof event.outputBytes === "number" ? `out=${formatTraceBytes(event.outputBytes)}` : null, - event.itemId ? `item=${event.itemId}` : null - ].filter(Boolean).join(" "); - return { - rowId: `event:${event.seq ?? `${event.label ?? event.type ?? "command"}:${event.createdAt ?? "unknown"}`}`, - seq: event.seq ?? null, - tone: traceEventTone(event), - header: `${clock} total=${total} ${meta}`, - body: traceCommandExecutionBody(event) - }; -} - -function traceCommandExecutionBody(event) { - const lines = [ - event.command ? `command=${compactTraceTextTail(cleanTraceOutputText(event.command), 900)}` : null, - event.stdoutSummary ? `stdout:\n${compactTraceTextTail(cleanTraceOutputText(event.stdoutSummary), 1200)}` : null, - event.stderrSummary ? `stderr:\n${compactTraceTextTail(cleanTraceOutputText(event.stderrSummary), 800)}` : null, - event.waitingFor && event.status !== "completed" ? `waiting=${event.waitingFor}` : null - ].filter(Boolean); - return lines.join("\n"); -} - -function isCommandExecutionTraceEvent(event) { - return event?.type === "tool_call" && event?.toolName === "commandExecution" && - (event?.label === "item/commandExecution:started" || event?.label === "item/commandExecution:completed"); -} - -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" || - (event?.type === "tool_call" && event?.status === "output_chunk" && !String(event?.toolName ?? "").startsWith("codex-app-server")); -} - -function readableTraceLabel(event) { - const label = String(event?.label ?? `${event?.type ?? "event"}:${event?.status ?? "observed"}`); - if (label === "request:accepted-short-connection") return "submit short-connection"; - if (label === "request:accepted") return "request accepted"; - if (label.startsWith("tool:codex-app-server.thread/start+turn/start")) return "codex turn start"; - if (label.startsWith("tool:codex-app-server.thread/resume+turn/start")) return "codex turn resume"; - if (label === "item/commandExecution:started") return "cmd started"; - if (label === "item/commandExecution:completed") return "cmd completed"; - if (label.startsWith("item/commandExecution/outputDelta")) return "cmd output"; - if (label === "turn:waiting:first_assistant_token") return "waiting first assistant token"; - if (label.startsWith("turn:completed")) return "turn completed"; - if (label.startsWith("result:completed")) return "result ready"; - if (label.startsWith("result:failed")) return "result failed"; - if (label.startsWith("timeout:")) return "timeout"; - if (label.startsWith("cancel:")) return "cancel"; - return label - .replace(/^tool:/u, "") - .replace(/^assistant:/u, "assistant ") - .replace(/:completed:completed$/u, " completed") - .replace(/:completed$/u, " completed") - .replace(/:started$/u, " started"); -} - -function traceDisplayBody(event) { - const lines = [ - event.promptSummary ? cleanTraceText(event.promptSummary) : null, - event.outputSummary ? cleanTraceOutputText(event.outputSummary) : null, - event.message ? cleanTraceText(event.message) : null, - event.chunk && !isNoisyTraceEvent(event) ? cleanTraceOutputText(event.chunk) : null, - event.waitingFor && event.status !== "completed" ? `waiting=${event.waitingFor}` : null - ].filter(Boolean); - return lines.join("\n").slice(0, 1400); -} - -function rawTraceOutputText(event) { - return String(event?.outputSummary ?? event?.chunk ?? event?.message ?? ""); -} - -function parseGatewayJsonRpcOutputs(text) { - const payloads = parseJsonObjects(text); - return payloads - .filter((payload) => payload && payload.jsonrpc === "2.0" && payload.result && typeof payload.result === "object") - .map((payload) => { - const result = payload.result; - const dispatch = result.dispatch && typeof result.dispatch === "object" ? result.dispatch : {}; - if (!("gatewaySessionId" in result) && !("capabilityId" in result) && !("shellExecuted" in dispatch)) return null; - return { payload, result, dispatch }; - }) - .filter(Boolean); -} - -function parseGatewayJsonRpcOutput(text) { - return parseGatewayJsonRpcOutputs(text)[0] ?? null; -} - -function parseJsonObjects(text) { - const source = String(text ?? ""); - const values = []; - let searchFrom = 0; - while (searchFrom < source.length) { - const start = source.indexOf("{", searchFrom); - if (start < 0) break; - const end = jsonObjectEnd(source, start); - if (end < 0) break; - try { - values.push(JSON.parse(source.slice(start, end + 1))); - searchFrom = end + 1; - } catch { - searchFrom = start + 1; - } - } - return values; -} - -function parseFirstJsonObject(text) { - const source = String(text ?? ""); - const start = source.indexOf("{"); - if (start < 0) return null; - const end = jsonObjectEnd(source, start); - if (end < 0) return null; - try { - return JSON.parse(source.slice(start, end + 1)); - } catch { - return null; - } -} - -function jsonObjectEnd(source, start) { - let depth = 0; - let inString = false; - let escaped = false; - for (let index = start; index < source.length; index += 1) { - const char = source[index]; - if (inString) { - if (escaped) { - escaped = false; - } else if (char === "\\") { - escaped = true; - } else if (char === "\"") { - inString = false; - } - continue; - } - if (char === "\"") { - inString = true; - continue; - } - if (char === "{") depth += 1; - if (char === "}") depth -= 1; - if (depth === 0) { - return index; - } - } - return -1; -} - -function gatewayJsonRpcDisplayBody({ payload, result, dispatch, raw }) { - const stdout = cleanTraceOutputText(dispatch.stdout); - const stderr = cleanTraceOutputText(dispatch.stderr); - const error = dispatch.error?.message ?? dispatch.error ?? dispatch.message ?? result.message ?? null; - return [ - `request=${payload.id ?? "unknown"}`, - `gateway=${result.gatewaySessionId ?? "unknown"} resource=${result.resourceId ?? "unknown"} capability=${result.capabilityId ?? "unknown"}`, - `dispatch=${dispatch.dispatchStatus ?? result.status ?? "unknown"} shellExecuted=${dispatch.shellExecuted === true ? "true" : "false"} timedOut=${dispatch.timedOut === true ? "true" : "false"}`, - error ? `error=${compactTraceTextTail(cleanTraceOutputText(error), 600)}` : null, - dispatch.command ? `command=${compactTraceTextTail(cleanTraceOutputText(dispatch.command), 420)}` : null, - result.auditId ? `audit=${result.auditId}` : null, - result.evidenceId ? `evidence=${result.evidenceId}` : null, - dispatch.stdoutTruncated === true ? "stdoutTruncated=true" : null, - stdout ? `stdout:\n${compactTraceTextTail(stdout, 1200)}` : null, - stderr ? `stderr:\n${compactTraceTextTail(stderr, 800)}` : null, - !stdout && !stderr && raw ? compactTraceTextTail(cleanTraceOutputText(raw), 900) : null - ].filter(Boolean).join("\n"); -} - -function traceStatusToken(event) { - const status = String(event?.status ?? ""); - if (status === "completed" || status === "succeeded") return "ok"; - if (["failed", "blocked", "error", "timeout", "canceled"].includes(status) || event?.errorCode) return "fail"; - if (["started", "running", "accepted"].includes(status)) return "run"; - return status || "event"; -} - -function traceEventTone(event) { - const status = traceStatusToken(event); - if (status === "ok") return "ok"; - if (status === "fail") return "blocked"; - if (status === "run") return "warn"; - return "source"; -} - -function traceClock(value) { - const date = new Date(value); - return Number.isNaN(date.getTime()) ? "--:--:--" : date.toISOString().slice(11, 19); -} - -function traceRelativeMs(trace, event) { - if (typeof event?.elapsedMs === "number") return event.elapsedMs; - const start = Date.parse(trace?.startedAt ?? trace?.createdAt ?? ""); - const current = Date.parse(event?.createdAt ?? ""); - return Number.isNaN(start) || Number.isNaN(current) ? 0 : Math.max(0, current - start); -} - -function formatTraceDuration(ms) { - const total = Math.max(0, Math.floor(Number(ms) || 0)); - const seconds = Math.floor(total / 1000); - const hh = String(Math.floor(seconds / 3600)).padStart(2, "0"); - const mm = String(Math.floor((seconds % 3600) / 60)).padStart(2, "0"); - const ss = String(seconds % 60).padStart(2, "0"); - return `${hh}:${mm}:${ss}`; -} - -function compactTraceSize(value) { - const length = String(value ?? "").length; - if (length >= 1000) return `${(length / 1000).toFixed(length >= 10000 ? 0 : 1)}k`; - return String(length); -} - -function formatTraceBytes(value) { - const bytes = Math.max(0, Number(value) || 0); - if (bytes >= 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)}MiB`; - if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)}KiB`; - return `${bytes}B`; -} - -function cleanTraceText(value) { - const text = String(value ?? "").trim(); - if (!text) return ""; - return text - .replace(/\s*\/\s*/gu, " / ") - .replace(/[ \t]{2,}/gu, " ") - .replace(/\n{3,}/gu, "\n\n"); -} - -function cleanTraceOutputText(value) { - const text = String(value ?? "").trim(); - if (!text) return ""; - return text - .replace(/[ \t]{2,}/gu, " ") - .replace(/\n{3,}/gu, "\n\n"); -} - -function traceActionButton(label, onClick, title) { - const button = document.createElement("button"); - button.type = "button"; - button.className = "message-trace-action"; - button.textContent = label; - button.title = title; - button.addEventListener("click", onClick); - return button; -} - -function messageTraceJson(message, trace, events) { - return JSON.stringify({ - traceId: trace?.traceId ?? message.traceId ?? null, - messageId: message.id ?? null, - eventCount: Number.isInteger(trace?.eventCount) ? trace.eventCount : events.length, - eventsCompacted: trace?.eventsCompacted === true, - eventWindow: trace?.eventWindow ?? null, - lastEvent: trace?.lastEvent ?? events.at(-1) ?? null, - assistantStreams: Array.isArray(trace?.assistantStreams) ? trace.assistantStreams : [], - events - }, null, 2); -} - -function downloadTraceJson(message, trace, events) { - const traceId = String(trace?.traceId ?? message.traceId ?? "trace") - .replace(/[^A-Za-z0-9_.-]+/gu, "-") - .replace(/^-|-$/gu, "") - .slice(0, 96) || "trace"; - const blob = new Blob([messageTraceJson(message, trace, events)], { type: "application/json" }); - const href = URL.createObjectURL(blob); - const anchor = document.createElement("a"); - anchor.href = href; - anchor.download = `${traceId}.json`; - document.body.append(anchor); - anchor.click(); - anchor.remove(); - window.setTimeout(() => URL.revokeObjectURL(href), 0); -} - -function runnerTraceHeadline(message, trace) { - const count = Number.isInteger(trace?.eventCount) ? trace.eventCount : Array.isArray(trace?.events) ? trace.events.length : 0; - const last = trace?.lastEvent?.label ?? trace?.eventLabels?.at?.(-1) ?? "等待事件"; - const elapsed = typeof trace?.elapsedMs === "number" ? ` / ${trace.elapsedMs}ms` : ""; - const waiting = trace?.waitingFor ? ` / 等待 ${trace.waitingFor}` : ""; - const thread = trace?.threadId ?? message.threadId ? ` / thread=${trace?.threadId ?? message.threadId}` : ""; - return `运行 trace ${trace?.traceId ?? message.traceId ?? "pending"} / events=${count} / last=${last}${elapsed}${waiting}${thread}`; -} - -function traceEventMeta(event) { - return [ - event.toolName ? `tool=${event.toolName}` : null, - event.waitingFor ? `waiting=${event.waitingFor}` : null, - typeof event.timeoutMs === "number" ? `timeoutMs=${event.timeoutMs}` : null, - typeof event.hardTimeoutMs === "number" ? `hardTimeoutMs=${event.hardTimeoutMs}` : null, - typeof event.idleMs === "number" ? `idleMs=${event.idleMs}` : null, - event.lastActivityAt ? `lastActivityAt=${event.lastActivityAt}` : null, - typeof event.elapsedMs === "number" ? `${event.elapsedMs}ms` : null, - event.errorCode ? `error=${event.errorCode}` : null, - event.outputSummary, - event.message - ].filter(Boolean).join(" / "); -} - -function toolCallsSummary(toolCalls) { - const summary = compactToolCallsSummary(toolCalls); - return summary === "none" ? null : summary; -} - -function skillsSummary(skills) { - const summary = compactSkillsSummary(skills); - return summary === "none" ? null : summary; -} - -function runnerTraceSummary(runnerTrace) { - const summary = compactRunnerTraceSummary(runnerTrace); - return summary === "none" ? null : summary; -} - -function conversationFactsSummary(conversationFacts) { - if (!conversationFacts || typeof conversationFacts !== "object") return "none"; - return [ - recordField("turns", conversationFacts.turnCount), - recordField("sessionId", conversationFacts.sessionId), - recordField("status", conversationFacts.sessionStatus), - recordField("workspace", conversationFacts.workspace), - recordField("mode", conversationFacts.sessionMode), - conversationFacts.valuesRedacted === true ? "valuesRedacted=true" : null - ].filter(Boolean).join(" / ") || "none"; -} - -function conversationFactTracesSummary(conversationFacts) { - if (!conversationFacts || typeof conversationFacts !== "object") return "none"; - const traces = Array.isArray(conversationFacts.traceIds) ? conversationFacts.traceIds.filter(Boolean) : []; - return traces.length ? traces.slice(-4).join(",") : conversationFacts.latestTraceId ?? "none"; -} - -function conversationFactSkillsSummary(conversationFacts) { - const skills = conversationFacts?.latestSkills; - if (!skills || typeof skills !== "object") return "none"; - const names = Array.isArray(skills.names) ? skills.names.filter(Boolean).slice(0, 6).join(",") : ""; - return [ - skills.status ?? "unknown", - recordField("count", skills.totalCount ?? skills.count), - names ? `items=${names}` : null - ].filter(Boolean).join(":") || "none"; -} - -function conversationFactToolsSummary(conversationFacts) { - const toolCalls = Array.isArray(conversationFacts?.recentToolCalls) ? conversationFacts.recentToolCalls : []; - if (toolCalls.length === 0) return "none"; - return toolCalls - .map((toolCall) => [toolCall.name ?? "tool", toolCall.status ? `(${toolCall.status})` : null].filter(Boolean).join("")) - .slice(-5) - .join(","); -} - -function sessionSummary(session) { - if (!session || typeof session !== "object") return null; - const parts = [ - session.sessionId, - session.status, - typeof session.turn === "number" ? `turn${session.turn}` : null, - session.threadId ? `thread=${session.threadId}` : null, - session.lastTraceId ? `lastTrace=${session.lastTraceId}` : null - ].filter(Boolean); - return parts.join(":"); -} - -function statusCard(item) { - const article = document.createElement("article"); - article.className = "status-card"; - article.append(textSpan(item.title, "metric-label")); - article.append(textSpan(String(item.value), `status-value tone-${toneClass(item.tone)}`)); - article.append(textSpan(item.meta, "status-meta")); - return article; -} - -function infoCard(item) { - const article = document.createElement("article"); - article.className = "info-card"; - article.append(badge(statusLabel(item.tone), item.tone)); - const body = document.createElement("div"); - body.append(textSpan(item.title, "info-title")); - body.append(textSpan(item.detail, "info-detail")); - article.append(body); - return article; -} - -function badge(text, tone = text) { - const span = document.createElement("span"); - span.className = `badge tone-${toneClass(tone)}`; - span.textContent = text ?? "无"; - return span; -} - -function copyButton(value, label = "value") { - const button = document.createElement("button"); - button.className = "copy-chip"; - button.type = "button"; - button.textContent = "复制"; - button.title = `复制 ${label}`; - button.addEventListener("click", async () => { - await copyTextToClipboard(value); - button.textContent = "已复制"; - window.setTimeout(() => { - button.textContent = "复制"; - }, 1200); - }); - return button; -} - -async function copyTextToClipboard(value) { - const text = String(value ?? ""); - if (navigator.clipboard?.writeText) { - await navigator.clipboard.writeText(text); - return; - } - const input = document.createElement("textarea"); - input.value = text; - input.setAttribute("readonly", ""); - input.style.position = "fixed"; - input.style.left = "-9999px"; - document.body.append(input); - input.select(); - document.execCommand("copy"); - input.remove(); -} - -function safeLink(href, text, className) { - const anchor = document.createElement("a"); - anchor.href = href; - anchor.textContent = text ?? href; - anchor.rel = "noreferrer"; - if (className) anchor.className = className; - return anchor; -} - -function textSpan(text, className) { - const span = document.createElement("span"); - span.textContent = text ?? "无"; - if (className) span.className = className; - return span; -} - -function cell(text, className) { - const td = document.createElement("td"); - td.textContent = text ?? "无"; - if (className) td.className = className; - return td; -} - -function formatTimestamp(value) { - const date = new Date(value); - if (Number.isNaN(date.getTime())) return "未知"; - return shortTime(date.toISOString()); -} - -function tableCell(text, className, colspan = 1) { - const td = cell(text, className); - if (colspan > 1) td.colSpan = colspan; - return td; -} - -function runtimeSummaryFrom(live) { - return live.health.data?.runtime ?? live.healthLive.data?.runtime ?? live.adapter.data?.runtime ?? live.restIndex.data?.runtime ?? null; -} - -function messageFromPayload(payload) { - if (!payload || typeof payload !== "object") return "非 JSON 或空响应"; - if (payload.error && typeof payload.error === "object") { - return payload.error.message || payload.error.code || payload.reason || payload.message || JSON.stringify(payload.error).slice(0, 160); - } - if (typeof payload.error === "string") { - return payload.message ? `${payload.error}:${payload.message}` : payload.error; - } - return payload.code || payload.message || payload.reason || JSON.stringify(payload).slice(0, 160); -} - -function normalizeBlockedAgentResult(payload, httpStatus, traceId, fallbackMessage) { - const { reply, ...safePayload } = payload; - const payloadError = payload.error && typeof payload.error === "object" ? payload.error : {}; - const payloadErrorCode = payloadError.code ?? (typeof payload.error === "string" ? payload.error : null); - const providerStatus = payloadError.providerStatus ?? httpStatus; - const error = { - ...payloadError, - code: payloadErrorCode ?? "provider_unavailable", - message: payloadError.message ?? fallbackMessage ?? `Code Agent provider HTTP ${httpStatus}`, - providerStatus - }; - return { - ...safePayload, - status: "failed", - traceId: payload.traceId || traceId, - error, - availability: payload.availability ?? codeAgentAvailabilityFromResult({ error }) - }; -} - -function isBlockedAgentResponse(payload, httpStatus) { - return Boolean( - payload && - typeof payload === "object" && - ( - isHttpNon2xxStatus(httpStatus) || - isHttpNon2xxStatus(payload.error?.providerStatus) || - payload.status === "blocked" || - payload.error?.code === "provider_unavailable" || - payload.error?.code === "security_blocked" || - payload.error?.code === "tool_unavailable" || - payload.error?.code === "skills_unavailable" || - payload.availability?.status === "blocked" - ) - ); -} - -function isHttpNon2xxStatus(value) { - return typeof value === "number" && Number.isInteger(value) && (value < 200 || value > 299); -} - -function codeAgentAvailabilityFrom(live) { - return sanitizeCodeAgentAvailability(codeAgentAvailabilityFromLive(live)); -} - -function codeAgentAvailabilityFromResult(result) { - const missingEnv = Array.isArray(result?.error?.missingEnv) ? result.error.missingEnv : []; - const providerStatus = result?.error?.providerStatus; - const credentialBlocked = missingEnv.includes("OPENAI_API_KEY"); - return { - status: "blocked", - blocker: credentialBlocked ? "凭证缺口" : "provider_unavailable", - reason: result?.error?.code ?? "provider_unavailable", - summary: providerStatus - ? `真实后端已接入,但当前 provider 上游返回 HTTP ${providerStatus};本次保持 BLOCKED/provider_unavailable,不会冒充真实 Code Agent 回复。` - : "真实后端已接入,但当前 DEV provider Secret hwlab-code-agent-provider/openai-api-key 未注入,因此不能返回真实回复。", - missingEnv, - secretRefs: credentialBlocked ? [ - { - env: "OPENAI_API_KEY", - secretName: "hwlab-code-agent-provider", - secretKey: "openai-api-key", - redacted: true - } - ] : [] - }; -} - -function sanitizeCodeAgentAvailability(availability) { - if (!availability || typeof availability !== "object") return availability; - if (availability.status !== "blocked") return availability; - return { - ...availability, - blocker: "服务受阻", - summary: codeAgentBlockedSummary(availability) - }; -} - -function codeAgentStatusMessage(availability) { - if (availability?.longLivedSessionGate?.status === "pass" && availability?.codexStdioFeasibility?.canStartLongLivedCodexStdio === true) { - return { - role: "system", - title: "Code Agent 状态:Codex stdio 长会话可用", - text: "当前通道可创建和复用 repo-owned Codex stdio session,并暴露 cancel、reap、idle timeout 与 trace capture;硬件控制仍只走 cloud-api/HWLAB API/skill CLI。", - status: "source" - }; - } - if (availability?.runner?.ready === true) { - return { - role: "system", - title: "Code Agent 状态:Codex stdio gate 未通过", - text: `同源服务可响应,但完整 Codex stdio 长会话仍未通过 gate;不会用本地技能发现、shell/file 快捷命令或文本 fallback 冒充回复。${codeAgentBlockerDetail(availability)}`, - status: "blocked" - }; - } - if (availability?.status === "blocked") { - return { - role: "system", - title: "Code Agent 状态:服务受阻", - text: codeAgentBlockedSummary(availability), - status: "blocked" - }; - } - if (String(availability?.sourceKind ?? availability?.evidenceLevel ?? "").toUpperCase() === "SOURCE") { - return { - role: "system", - title: "Code Agent 状态:本地验证记录", - text: "当前只用于本地浏览器验证:可以检查发送、处理中、回复渲染和证据展示,但不会标记为实况完成。", - status: "source" - }; - } - if (availability?.status === "available") { - return { - role: "system", - title: "Code Agent 状态:真实通道已接入", - text: "受控对话通道已接入;发送后只有真实完成的回复才会标记为已完成。", - status: "source" - }; - } - return { - role: "system", - title: "Code Agent 状态:等待 Codex stdio 探测", - text: "正在确认服务状态;首屏不会宣称 Code Agent 可用,也不会把失败或静态内容当成真实回复。", - status: "source" - }; -} - -function codeAgentPromptText(availability) { - if (availability?.status === "blocked") { - return "可以继续输入并保留草稿;发送会走受控对话通道。服务恢复前会显示结构化失败,不会冒充真实 Codex 回复。"; - } - return "请在底部输入中文消息并点击发送;完成态只来自真实完成的后端回复。"; -} - -function codeAgentControlSummary(availability) { - if (latestCompletedAgentMessage()) { - return state.conversationId - ? `当前会话 ${state.conversationId};最近一次真实完成回复来自受控 Code Agent 接口,并带有可核验记录。` - : "最近一次真实完成回复来自受控 Code Agent 接口,并带有可核验记录。"; - } - if (isSourceFixtureCompletedChatMessage(latestChatResult())) { - return "最近一次为本地验证回复;它只证明浏览器对话体验,不作为实况完成证据。"; - } - if (availability?.status === "blocked") { - return codeAgentBlockedSummary(availability); - } - if (availability?.partialReady === true || availability?.runner?.ready === true) { - return `输入区会调用受控接口;完整 Codex stdio 长会话尚未可用时只返回结构化失败,不能用本地 shortcut 或 text fallback 冒充回复。${codeAgentBlockerDetail(availability)}`; - } - return "输入区会调用受控 Code Agent 接口;只有真实完成回复才显示为完成,不能因为只有会话编号就当成实况完成。"; -} - -function codeAgentBlockedSummary(availability) { - const structured = availability?.error?.userMessage ?? availability?.blocker?.userMessage; - if (structured) return structured; - const providerStatus = availability?.error?.providerStatus ?? availability?.providerStatus; - if (providerStatus) { - return `真实后端已接入,但当前上游响应 HTTP ${providerStatus};工作台保持只读和可重试,不会冒充真实 Code Agent 回复。`; - } - const blocker = codeAgentBlockerDetail(availability); - return `真实后端已接入,但 Code Agent 服务暂不可用;完整 Codex stdio Code Agent 仍受阻,工作台保持只读和可重试,不会冒充真实回复。${blocker}`; -} - -function codeAgentBlockerDetail(availability) { - const blockers = codeAgentBlockerCodes(availability); - if (blockers.length === 0 && availability?.reason) blockers.push(availability.reason); - if (blockers.length === 0) return ""; - const labels = blockers.slice(0, 3).map(codeAgentBlockerChineseLabel); - return ` 阻塞:${labels.join(";")}。证据 code=${blockers.slice(0, 3).join(",")}。`; -} - -function codeAgentBlockerCodes(availability) { - const values = [ - ...(Array.isArray(availability?.blockers) ? availability.blockers.map((blocker) => blocker?.code) : []), - ...(Array.isArray(availability?.blockerCodes) ? availability.blockerCodes : []), - ...(Array.isArray(availability?.longLivedSessionGate?.blockers) ? availability.longLivedSessionGate.blockers.map((blocker) => blocker?.code) : []), - ...(Array.isArray(availability?.codexStdioFeasibility?.blockers) ? availability.codexStdioFeasibility.blockers.map((blocker) => blocker?.code) : []), - ...(Array.isArray(availability?.runtimeContract?.stdioProtocol?.missingTools) && availability.runtimeContract.stdioProtocol.missingTools.length > 0 ? ["stdio_protocol_not_wired"] : []), - availability?.runtimeContract?.binary?.status === "missing" ? "codex_cli_binary_missing" : null, - availability?.runtimeContract?.lifecycleSupervisor?.status === "blocked" ? "runner_lifecycle_missing" : null - ].filter(Boolean).map((code) => String(code)); - return [...new Set(values)]; -} - -function codeAgentBlockerChineseLabel(code) { - return { - codex_cli_binary_missing: "未找到受控 Codex CLI binary", - codex_cli_not_executable: "Codex CLI 无法执行 --version", - codex_cli_native_dependency_missing: "Codex CLI native 依赖缺失", - runner_lifecycle_missing: "缺少 repo-owned lifecycle supervisor", - stdio_protocol_not_wired: "Codex stdio 协议尚未接入", - codex_stdio_supervisor_disabled: "Codex stdio supervisor 未启用", - codex_stdio_blocked_readonly_session_available: "旧 partial runner 不能作为 Codex stdio", - controlled_readonly_not_long_lived_stdio: "非 Codex stdio 长会话", - openai_responses_fallback_not_session: "文本 fallback 不是长会话", - one_shot_runner_not_long_lived: "一次性执行不是可复用 session", - provider_token_boundary: "token 边界未配置", - codex_home_missing: "CODEX_HOME 不存在或不可读", - codex_home_write_blocked: "CODEX_HOME 不可写", - workspace_mount_missing: "工作区挂载不可读", - workspace_write_boundary_blocked: "workspace-write 沙箱不可写", - codex_stdio_egress_boundary: "DEV egress 边界不合规" - }[code] ?? code; -} - -function untrustedCompletionMessage(result) { - const provider = result?.provider ?? "unknown"; - return `Code Agent 返回 completed,但缺少真实 provider/model/trace/conversation 证据,或 provider=${provider} 属于 echo/mock/stub;本次不会标记为真实完成。SOURCE fixture 只可显示为 SOURCE 回复,不能冒充 DEV-LIVE。`; -} - -function codeAgentAvailabilitySummary() { - if (state.codeAgentAvailability?.longLivedSessionGate?.status === "pass" && state.codeAgentAvailability?.codexStdioFeasibility?.canStartLongLivedCodexStdio === true) { - return "同源 API 可响应;Codex stdio 长会话 gate 已通过,支持真实 session 复用和 trace capture。"; - } - if (state.codeAgentAvailability?.runner?.ready === true) { - return `同源 API 可响应;完整 Codex stdio 长会话仍受阻,本地 shortcut 与文本 fallback 不会冒充真实 Code Agent 回复。${codeAgentBlockerDetail(state.codeAgentAvailability)}`; - } - if (state.codeAgentAvailability?.status === "blocked") { - return "同源 API 可响应;Code Agent 服务暂不可用,工作台保持只读和可重试。"; - } - if (String(state.codeAgentAvailability?.sourceKind ?? state.codeAgentAvailability?.evidenceLevel ?? "").toUpperCase() === "SOURCE") { - return "同源 API 可响应;当前 Code Agent 回复来源是本地验证记录,仅用于浏览器验证,不是实况完成。"; - } - if (state.codeAgentAvailability?.status === "available") { - return "同源 API 可响应;Code Agent 真实通道已接入,只有真实完成回复才显示为完成。"; - } - return "同源接口可响应。技术复核详情已放在二级页面。"; -} - -function latestCompletedAgentMessage() { - return [...state.chatMessages].reverse().find((message) => - message.role === "agent" && - isRealCompletedChatMessage(message) - ) ?? null; -} - -function isRealCompletedChatResult(result) { - const assistantReply = typeof result?.reply?.content === "string" ? result.reply.content.trim() : ""; - return result?.status === "completed" && assistantReply.length > 0 && !isSourceFixtureCompletion(result) && hasRealCodeAgentEvidence(result); -} - -function isSourceFixtureChatResult(result) { - const assistantReply = typeof result?.reply?.content === "string" ? result.reply.content.trim() : ""; - return result?.status === "completed" && assistantReply.length > 0 && isSourceFixtureCompletion(result); -} - -function isTextFallbackChatResult(result) { - const assistantReply = typeof result?.reply?.content === "string" ? result.reply.content.trim() : ""; - return result?.status === "completed" && - assistantReply.length > 0 && - result?.provider === "openai-responses" && - (result?.capabilityLevel === "text-chat-only" || result?.runner?.kind === "openai-responses-fallback") && - result?.longLivedSessionGate?.status !== "pass"; -} - -function isRealCompletedChatMessage(message) { - return message?.status === "completed" && !message.error && hasRealCodeAgentEvidence(message); -} - -function isSourceFixtureCompletedChatMessage(message) { - return message?.status === "source" && !message.error && message.sourceKind === "SOURCE"; -} - -function hasRealCodeAgentEvidence(value) { - if (isCodexRunnerCapableEvidence(value)) { - return true; - } - if (CODEX_RUNNER_CAPABLE_PROVIDERS.includes(String(value?.provider ?? "").trim().toLowerCase())) { - return false; - } - if (isTextFallbackChatResult(value)) { - return false; - } - return ( - isTrustedCodeAgentProvider(value?.provider) && - Boolean(value?.model) && - Boolean(value?.backend) && - Boolean(value?.traceId) && - Boolean(value?.conversationId || value?.sessionId) && - hasProviderTrace(value) && - Boolean(sessionIdFrom(value)) && - (!requiresCodexThreadEvidence(value) || Boolean(threadIdFrom(value))) && - !UNTRUSTED_CODE_AGENT_PROVIDER_PATTERN.test(`${value?.provider ?? ""} ${value?.backend ?? ""}`) - ); -} - -function isCodexRunnerCapableEvidence(value) { - return ( - CODEX_RUNNER_CAPABLE_PROVIDERS.includes(String(value?.provider ?? "").trim().toLowerCase()) && - value?.runner?.kind === CODEX_APP_SERVER_RUNNER_KIND && - value?.capabilityLevel === "long-lived-codex-stdio-session" && - value?.session?.status === "idle" && - typeof value?.session?.idleTimeoutMs === "number" && - Boolean(value?.session?.lastTraceId) && - value?.sessionMode === CODEX_APP_SERVER_SESSION_MODE && - value?.implementationType === CODEX_APP_SERVER_IMPLEMENTATION_TYPE && - Boolean(value?.sessionReuse) && - hasProviderTrace(value) && - Boolean(sessionIdFrom(value)) && - Boolean(threadIdFrom(value)) && - value?.session?.longLivedSession === true && - value?.session?.codexStdio === true && - value?.runner?.codexStdio === true && - value?.runner?.writeCapable === true && - value?.runner?.durableSession === true && - Boolean(value?.workspace || value?.runner?.workspace) && - ["workspace-write", "danger-full-access"].includes(value?.sandbox || value?.runner?.sandbox) && - Array.isArray(value?.toolCalls) && - value.toolCalls.some((tool) => tool?.status === "completed") && - value?.longLivedSessionGate?.status === "pass" && - value?.providerTrace?.protocol === CODEX_APP_SERVER_PROTOCOL && - /\bcodex\s+app-server\s+--listen\s+stdio:\/\//u.test(String(value?.providerTrace?.command ?? "")) && - ["completed", "succeeded"].includes(String(value?.providerTrace?.terminalStatus ?? "").toLowerCase()) && - Boolean(value?.runnerTrace) && - Boolean(value?.skills) - ); -} - -function isTrustedCodeAgentProvider(provider) { - const normalized = String(provider ?? "").trim().toLowerCase(); - return TRUSTED_CODE_AGENT_PROVIDERS.includes(normalized); -} - -function isSourceFixtureCompletion(value) { - const sourceKind = String(value?.sourceKind ?? value?.evidenceLevel ?? value?.providerTrace?.sourceKind ?? "").trim().toUpperCase(); - const traceSource = String(value?.providerTrace?.source ?? value?.providerTrace?.mode ?? "").trim().toUpperCase(); - return sourceKind === "SOURCE" || traceSource.startsWith("SOURCE"); -} - -function latestChatResult() { - return [...state.chatMessages].reverse().find((message) => message.role === "agent") ?? null; -} - -function deriveAgentChatStatus() { - if (state.chatPending) return "running"; - const latest = latestChatResult(); - if (latest?.status === "completed") return "completed"; - if (latest?.status === "source") return "source"; - if (latest?.status === "canceled") return "canceled"; - if (latest?.status === "timeout") return "timeout"; - if (latest?.status === "error") return "error"; - if (latest?.status === "failed") return latest.error?.code === "provider_unavailable" ? "blocked" : "failed"; - if (state.codeAgentAvailability?.status === "blocked") return "blocked"; - return "idle"; -} - -function currentConversationTone() { - if (latestCompletedAgentMessage()) return "dev-live"; - if (isSourceFixtureCompletedChatMessage(latestChatResult())) return "source"; - if (state.codeAgentAvailability?.status === "blocked" || ["failed", "timeout", "canceled", "error"].includes(latestChatResult()?.status)) return "blocked"; - return "source"; -} - -function statusLabel(value) { - const key = String(value ?? "unknown").trim(); - const normalized = key.toLowerCase(); - return STATUS_LABELS[normalized] ?? key; -} - -function gateStatusLabel(value, statusKey) { - const explicit = String(value ?? "").trim(); - const labels = { - pass: "通过", - blocked: "阻塞", - failed: "失败", - pending: "待验证", - info: "信息" - }; - return labels[statusKey] ?? (["通过", "阻塞", "失败", "待验证", "信息"].includes(explicit) ? explicit : "阻塞"); -} - -function normalizeGateStatusKey(value) { - const text = String(value ?? "").trim().toLowerCase(); - if (["通过", "pass", "passed", "ok", "ready"].includes(text)) return "pass"; - if (["失败", "failed", "failure", "error"].includes(text)) return "failed"; - if (["待验证", "pending", "loading", "unknown"].includes(text)) return "pending"; - if (["信息", "info", "information"].includes(text)) return "info"; - return "blocked"; -} - -function normalizeEvidenceList(value) { - const items = Array.isArray(value) ? value : [value]; - const normalized = items.map(oneLine).filter(Boolean); - return normalized.length > 0 ? normalized : ["live 后端未返回 evidence 字段"]; -} - -function oneLine(value) { - return String(value ?? "").replace(/\s+/gu, " ").trim().slice(0, 240); -} - -function roleLabel(role) { - return ( - { - agent: "Agent", - blocker: "阻塞", - system: "系统", - user: "用户" - }[String(role ?? "").toLowerCase()] ?? statusLabel(role) - ); -} - -function recordKindLabel(kind) { - return ( - { - audit: "审计", - evidence: "证据", - operation: "操作", - record: "记录", - report: "报告", - trace: "轨迹" - }[String(kind ?? "").toLowerCase()] ?? statusLabel(kind) - ); -} - -function internalGatePathnames() { - return new Set(["/gate", "/diagnostics/gate"]); -} - -function helpPathnames() { - return new Set(["/help"]); -} - -function valueOrUnavailable(liveValue, sourceValue) { - if (liveValue !== undefined && liveValue !== null) return liveValue; - if (sourceValue !== undefined && sourceValue !== null) return `${sourceValue} 来源 SOURCE`; - return "不可用"; -} - -function shortTime(value) { - return new Date(value).toISOString().replace("T", " ").replace(/\.\d{3}Z$/, "Z"); -} - -function toneClass(tone) { - return String(tone ?? "unknown").replace(/[^a-z0-9_-]/gi, "-").toLowerCase(); -} - -function replaceChildren(parent, ...children) { - parent.replaceChildren(...children.filter((child) => child && typeof child !== "string")); -} diff --git a/web/hwlab-cloud-web/app.ts b/web/hwlab-cloud-web/app.ts new file mode 100644 index 00000000..08c74323 --- /dev/null +++ b/web/hwlab-cloud-web/app.ts @@ -0,0 +1,912 @@ +import { ensureWorkbenchAuth, initWorkbenchLogout } from "./auth.ts"; +import { + codeAgentRuntimePathFromMessage, + runnerTraceSummary as compactRunnerTraceSummary, + skillsSummary as compactSkillsSummary, + toolCallsSummary as compactToolCallsSummary +} from "./code-agent-facts.ts"; +import { classifyCodeAgentStatusSummary, codeAgentAvailabilityFromLive } from "./code-agent-status.ts"; +import { classifyWorkbenchLiveStatus } from "./live-status.ts"; +import { marked } from "./third_party/marked/marked.esm.js"; +import { hardenRenderedMarkdown, renderMessageMarkdown } from "./message-markdown.ts"; + +const DEFAULT_API_TIMEOUT_MS = 4500; +const WORKBENCH_PROJECT_ID = "prj_device_pod_workbench"; +const DEFAULT_LIVE_SURFACE_TIMEOUT_MS = 12000; +const DEFAULT_CODE_AGENT_TIMEOUT_MS = 1800000; +const MAX_CODE_AGENT_TIMEOUT_MS = 2400000; +const DEFAULT_CODE_AGENT_SUBMIT_TIMEOUT_MS = 60000; +const DEFAULT_CODE_AGENT_CANCEL_TIMEOUT_MS = 30000; +const DEFAULT_GATEWAY_SHELL_TIMEOUT_MS = 120000; +const CODE_AGENT_TIMEOUT_STORAGE_KEY = "hwlab.workbench.codeAgentTimeoutMs.v1"; +const GATEWAY_SHELL_TIMEOUT_STORAGE_KEY = "hwlab.workbench.gatewayShellTimeoutMs.v1"; +const CODE_AGENT_PROVIDER_PROFILE_STORAGE_KEY = "hwlab.workbench.codeAgentProviderProfile.v1"; +const DEFAULT_CODE_AGENT_PROVIDER_PROFILE = "deepseek"; +const CODE_AGENT_PROVIDER_PROFILES = Object.freeze(["deepseek", "codex-api"]); +const CODE_AGENT_SESSION_STORAGE_KEY = "hwlab.workbench.codeAgentSession.v1"; +const CODE_AGENT_SESSION_STORAGE_VERSION = 1; +const CODE_AGENT_SESSION_STORAGE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; +const CODE_AGENT_SESSION_STORAGE_MESSAGE_LIMIT = 30; +const CODE_AGENT_SESSION_STORAGE_TRACE_EVENT_LIMIT = 80; +const CODE_AGENT_SESSION_PERSIST_DEBOUNCE_MS = 250; +const TRACE_STREAM_FALLBACK_MS = 2500; +const TRACE_POLL_INTERVAL_MS = 1000; +const FULL_TRACE_REPLAY_TIMEOUT_MS = 15000; +const API_TIMEOUT_MS = resolveTimeoutMs("apiTimeoutMs", DEFAULT_API_TIMEOUT_MS, { min: 500, max: 30000 }); +const LIVE_SURFACE_TIMEOUT_MS = resolveTimeoutMs("liveSurfaceTimeoutMs", DEFAULT_LIVE_SURFACE_TIMEOUT_MS, { min: 5000, max: 60000 }); +let CODE_AGENT_TIMEOUT_MS = resolveUserCodeAgentTimeoutMs(); +let GATEWAY_SHELL_TIMEOUT_MS = resolveUserGatewayShellTimeoutMs(); +let CODE_AGENT_PROVIDER_PROFILE = resolveUserCodeAgentProviderProfile(); +const CODE_AGENT_SUBMIT_TIMEOUT_MS = resolveTimeoutMs("codeAgentSubmitTimeoutMs", DEFAULT_CODE_AGENT_SUBMIT_TIMEOUT_MS, { min: configuredTimeoutMinMs("codeAgentSubmitTimeoutMs", 5000), max: 120000 }); +const CODE_AGENT_CANCEL_TIMEOUT_MS = resolveTimeoutMs("codeAgentCancelTimeoutMs", DEFAULT_CODE_AGENT_CANCEL_TIMEOUT_MS, { min: 5000, max: 120000 }); +const rpcReadMethods = Object.freeze([ + "system.health", + "cloud.adapter.describe" +]); + +const STATUS_LABELS = Object.freeze({ + active: "活动", + available: "可用", + blocked_after_cloud_api: "cloud-api 后阻塞", + blocked: "待处理", + canceled: "已取消", + completed: "完成", + connected: "已连接", + degraded: "降级", + disabled: "禁用", + entry: "入口", + failed: "失败", + healthy: "健康", + live: "实况", + ok: "正常", + open: "未关闭", + pass: "通过", + pending: "等待", + running: "处理中", + timeout: "超时", + error: "错误", + probing: "探测中", + ready: "就绪", + recorded: "已记录", + sent: "已发送", + requires: "依赖", + source: "来源", + unverified: "未验证", + "dry-run": "演练", + "dev-live": "实况已验证" +}); + + +const TRUSTED_CODE_AGENT_PROVIDERS = Object.freeze(["codex-stdio"]); +const CODEX_RUNNER_CAPABLE_PROVIDERS = Object.freeze(["codex-stdio"]); +const CODEX_APP_SERVER_RUNNER_KIND = "codex-app-server-stdio-runner"; +const CODEX_APP_SERVER_SESSION_MODE = "codex-app-server-stdio-long-lived"; +const CODEX_APP_SERVER_IMPLEMENTATION_TYPE = "repo-owned-codex-app-server-stdio-session"; +const CODEX_APP_SERVER_PROTOCOL = "codex-app-server-jsonrpc-stdio"; +const CODEX_STDIO_BLOCKER_MARKERS = Object.freeze([ + "codex_cli_binary_missing", + "runner_lifecycle_missing", + "stdio_protocol_not_wired", + "codex_stdio_supervisor_disabled", + "not-codex-stdio", + "not-write-capable", + "process-local-session-registry" +]); +const UNTRUSTED_CODE_AGENT_PROVIDER_PATTERN = /\b(?:echo|mock|fixture|stub|sample)\b/iu; +const LAYOUT_STORAGE_KEY = "hwlab.workbench.layout.v1"; +const LEFT_SIDEBAR_DEFAULT_WIDTH = 92; +const LEFT_SIDEBAR_MIN_WIDTH = 72; +const LEFT_SIDEBAR_MAX_WIDTH = 180; +const RIGHT_SIDEBAR_DEFAULT_WIDTH = 728; +const RIGHT_SIDEBAR_MIN_WIDTH = 560; +const RIGHT_SIDEBAR_MAX_WIDTH = 740; +const SCROLL_BOTTOM_PIN_PX = 24; +const SCROLL_USER_ACTIVITY_MS = 700; +const MESSAGE_MARKDOWN_STATUSES = Object.freeze(["completed", "source"]); + +const viewIds = new Set([...document.querySelectorAll("[data-view]")].map((view) => view.dataset.view)); +let rpcSequence = 0; + +const el = { + loginShell: byId("login-shell"), + shell: query("[data-app-shell]"), + logoutButton: byId("logout-button"), + leftSidebarToggle: byId("left-sidebar-toggle"), + leftSidebarResize: byId("left-sidebar-resize"), + rightSidebarResize: byId("right-sidebar-resize"), + routePath: byId("route-path"), + liveStatus: byId("live-status"), + liveDetail: byId("live-detail"), + liveBuildSummary: byId("live-build-summary"), + liveBuildToggle: byId("live-build-toggle"), + liveBuildLatest: byId("live-build-latest"), + liveBuildList: byId("live-build-list"), + agentChatStatus: byId("agent-chat-status"), + codeAgentSummary: byId("code-agent-summary"), + codeAgentSummaryIcon: byId("code-agent-summary-icon"), + codeAgentSummaryLabel: byId("code-agent-summary-label"), + codeAgentSummaryCapability: byId("code-agent-summary-capability"), + codeAgentSummaryTrace: byId("code-agent-summary-trace"), + codeAgentSummaryDetail: byId("code-agent-summary-detail"), + conversationList: byId("conversation-list"), + gateSourceStatus: byId("gate-source-status"), + gateStatusFilter: byId("gate-status-filter"), + gateSearch: byId("gate-search"), + gateRefresh: byId("gate-refresh"), + gateReviewBody: byId("gate-review-body"), + commandForm: byId("command-form"), + commandInput: byId("command-input"), + codeAgentProviderProfile: byId("code-agent-provider-profile"), + codeAgentTimeout: byId("code-agent-timeout"), + gatewayShellTimeout: byId("gateway-shell-timeout"), + commandSend: byId("command-send"), + commandClear: byId("command-clear"), + devicePodSelect: byId("device-pod-select"), + devicePodStatusTag: byId("device-pod-status-tag"), + devicePodSummary: byId("device-pod-summary"), + devicePodId: byId("device-pod-id"), + devicePodTarget: byId("device-pod-target"), + devicePodProfile: byId("device-pod-profile"), + devicePodFreshness: byId("device-pod-freshness"), + devicePodInterfaces: byId("device-pod-interfaces"), + deviceEventFollow: byId("device-event-follow"), + deviceEventJump: byId("device-event-jump"), + deviceEventScroll: byId("device-event-scroll"), + deviceEventText: byId("device-event-text"), + deviceDetailDialog: byId("device-detail-dialog"), + deviceDetailTitle: byId("device-detail-title"), + deviceDetailBody: byId("device-detail-body"), + helpContent: byId("help-content"), + helpStatus: byId("help-status") +}; + +let activeWorkbenchDialog = null; + +await ensureWorkbenchAuth({ + loginShell: el.loginShell, + appShell: el.shell +}); + +const state = { + conversationId: null, + sessionId: null, + threadId: null, + codeAgentAvailability: null, + chatMessages: [], + traceStreams: new Map(), + traceDetailsOpen: new Map(), + conversationRenderVersion: 0, + conversationScrollUserActiveUntil: 0, + conversationScrollPosition: { top: 0, left: 0 }, + traceScrollPositions: new Map(), + traceScrollUserActiveUntil: new Map(), + traceBodyScrollPositions: new Map(), + traceBodyScrollUserActiveUntil: new Map(), + traceProgrammaticScrollWrites: 0, + fullTraceReplayInFlight: new Set(), + resultReconciliationInFlight: new Set(), + canceledTraces: new Set(), + currentRequest: null, + sessionStatus: null, + chatPending: false, + liveSurface: null, + gateDiagnostics: { + loading: false, + payload: null, + rows: [], + error: null, + loadedAt: null + }, + devicePod: { + selectedDevicePodId: "device-pod-71-freq", + list: null, + status: null, + events: null, + chipId: null, + uart: null, + uartTail: null, + followEvents: true, + unreadEvents: 0, + lastEventLineCount: 0, + eventScrollUserActiveUntil: 0, + details: new Map() + }, + layout: { + leftSidebarCollapsed: false, + leftSidebarWidth: LEFT_SIDEBAR_DEFAULT_WIDTH, + leftDrag: null, + rightSidebarWidth: RIGHT_SIDEBAR_DEFAULT_WIDTH, + rightDrag: null + } +}; + +let codeAgentSessionPersistTimer = null; + +restoreCodeAgentSessionState(); + +initRoutes(); +initLayoutSizing(); +initLeftSidebarToggle(); +initLeftSidebarResize(); +initRightSidebarResize(); +initDevicePodPanel(); +initQuickActions(); +initCodeAgentTimeoutControl(); +initConversationScrollMemory(); +initCommandBar(); +initLiveBuildOverlay(); +initWorkbenchLogout(el.logoutButton); +el.logoutButton.addEventListener("click", clearCodeAgentSessionState); +initGateControls(); +installWorkbenchTestHooks(); +renderStaticWorkbench(); +renderProbePending(); +renderCodeAgentSummary(); +loadHelpSurface(); +loadLiveSurface().then(renderLiveSurface); +window.setTimeout(refreshRestoredCodeAgentTraces, 0); + +function byId(id) { + const element = document.getElementById(id); + if (!element) { + throw new Error(`missing required element #${id}`); + } + return element; +} + +function query(selector) { + const element = document.querySelector(selector); + if (!element) { + throw new Error(`missing required element ${selector}`); + } + return element; +} + +function resolveTimeoutMs(name, fallback, { min, max }) { + const config = globalThis.HWLAB_CLOUD_WEB_CONFIG?.timeouts ?? globalThis.HWLAB_CLOUD_WEB_TIMEOUTS ?? {}; + const configured = Number(config?.[name]); + if (!Number.isFinite(configured)) return fallback; + return Math.min(Math.max(Math.trunc(configured), min), max); +} + +function configuredTimeoutMinMs(name, fallback) { + const config = globalThis.HWLAB_CLOUD_WEB_CONFIG?.timeouts ?? globalThis.HWLAB_CLOUD_WEB_TIMEOUTS ?? {}; + const configured = Number(config?.[`${name}MinMs`]); + if (!Number.isFinite(configured)) return fallback; + return Math.max(1, Math.min(Math.trunc(configured), fallback)); +} + +function clampTimeoutMs(value, { min, max }) { + if (value === null || value === undefined) return null; + if (typeof value === "string" && value.trim() === "") return null; + const numeric = Number(value); + if (!Number.isFinite(numeric)) return null; + return Math.min(Math.max(Math.trunc(numeric), min), max); +} + +function resolveUserCodeAgentTimeoutMs() { + const min = configuredTimeoutMinMs("codeAgentTimeoutMs", 30000); + let stored = null; + try { + stored = window.localStorage?.getItem(CODE_AGENT_TIMEOUT_STORAGE_KEY) ?? null; + } catch { + stored = null; + } + return clampTimeoutMs(stored, { min, max: MAX_CODE_AGENT_TIMEOUT_MS }) + ?? resolveTimeoutMs("codeAgentTimeoutMs", DEFAULT_CODE_AGENT_TIMEOUT_MS, { min, max: MAX_CODE_AGENT_TIMEOUT_MS }); +} + +function resolveUserGatewayShellTimeoutMs() { + let stored = null; + try { + stored = window.localStorage?.getItem(GATEWAY_SHELL_TIMEOUT_STORAGE_KEY) ?? null; + } catch { + stored = null; + } + return clampTimeoutMs(stored, { min: 30000, max: 600000 }) + ?? resolveTimeoutMs("gatewayShellTimeoutMs", DEFAULT_GATEWAY_SHELL_TIMEOUT_MS, { min: 30000, max: 600000 }); +} + +function resolveUserCodeAgentProviderProfile() { + let stored = null; + try { + stored = window.localStorage?.getItem(CODE_AGENT_PROVIDER_PROFILE_STORAGE_KEY) ?? null; + } catch { + stored = null; + } + return normalizeCodeAgentProviderProfile(stored); +} + +function normalizeCodeAgentProviderProfile(value) { + const text = String(value ?? DEFAULT_CODE_AGENT_PROVIDER_PROFILE).trim().toLowerCase(); + return CODE_AGENT_PROVIDER_PROFILES.includes(text) ? text : DEFAULT_CODE_AGENT_PROVIDER_PROFILE; +} + +function initCodeAgentTimeoutControl() { + syncCodeAgentTimeoutControl(); + el.codeAgentProviderProfile.addEventListener("change", () => { + CODE_AGENT_PROVIDER_PROFILE = normalizeCodeAgentProviderProfile(el.codeAgentProviderProfile.value); + try { + window.localStorage?.setItem(CODE_AGENT_PROVIDER_PROFILE_STORAGE_KEY, CODE_AGENT_PROVIDER_PROFILE); + } catch { + // localStorage can be unavailable in hardened browser modes. + } + syncCodeAgentTimeoutControl(); + renderCodeAgentSummary(); + }); + el.codeAgentTimeout.addEventListener("change", () => { + const next = clampTimeoutMs(el.codeAgentTimeout.value, { min: 30000, max: MAX_CODE_AGENT_TIMEOUT_MS }) ?? DEFAULT_CODE_AGENT_TIMEOUT_MS; + CODE_AGENT_TIMEOUT_MS = next; + try { + window.localStorage?.setItem(CODE_AGENT_TIMEOUT_STORAGE_KEY, String(next)); + } catch { + // localStorage can be unavailable in hardened browser modes. + } + syncCodeAgentTimeoutControl(); + renderCodeAgentSummary(); + renderConversation(); + }); + el.gatewayShellTimeout.addEventListener("change", () => { + const next = clampTimeoutMs(el.gatewayShellTimeout.value, { min: 30000, max: 600000 }) ?? DEFAULT_GATEWAY_SHELL_TIMEOUT_MS; + GATEWAY_SHELL_TIMEOUT_MS = next; + try { + window.localStorage?.setItem(GATEWAY_SHELL_TIMEOUT_STORAGE_KEY, String(next)); + } catch { + // localStorage can be unavailable in hardened browser modes. + } + syncCodeAgentTimeoutControl(); + renderCodeAgentSummary(); + }); +} + +function syncCodeAgentTimeoutControl() { + el.codeAgentProviderProfile.value = CODE_AGENT_PROVIDER_PROFILE; + el.codeAgentProviderProfile.title = CODE_AGENT_PROVIDER_PROFILE === "codex-api" + ? "本次 Code Agent 请求使用原 Codex API / Responses 通道" + : "本次 Code Agent 请求使用 G14 DeepSeek Responses bridge"; + el.codeAgentTimeout.value = String(CODE_AGENT_TIMEOUT_MS); + el.codeAgentTimeout.title = `Code Agent 无新事件超过 ${formatTraceDuration(CODE_AGENT_TIMEOUT_MS)} 后才显示为超时`; + el.gatewayShellTimeout.value = String(GATEWAY_SHELL_TIMEOUT_MS); + el.gatewayShellTimeout.title = `Code Agent 调用 PC gateway wrapper 时默认使用 --timeout-ms ${GATEWAY_SHELL_TIMEOUT_MS}`; +} + +function restoreCodeAgentSessionState() { + let payload = null; + try { + const raw = window.localStorage?.getItem(CODE_AGENT_SESSION_STORAGE_KEY); + payload = raw ? JSON.parse(raw) : null; + } catch { + payload = null; + } + if (!payload || payload.version !== CODE_AGENT_SESSION_STORAGE_VERSION) return; + const updatedAtMs = Number(payload.updatedAtMs); + if (!Number.isFinite(updatedAtMs) || Date.now() - updatedAtMs > CODE_AGENT_SESSION_STORAGE_MAX_AGE_MS) { + clearCodeAgentSessionState(); + return; + } + state.conversationId = nonEmptyString(payload.conversationId); + state.sessionId = nonEmptyString(payload.sessionId); + state.threadId = nonEmptyString(payload.threadId); + state.sessionStatus = nonEmptyString(payload.sessionStatus); + state.chatMessages = Array.isArray(payload.chatMessages) + ? payload.chatMessages.map(restoreStoredChatMessage).filter(Boolean) + : []; + reconcileRestoredCodeAgentResults(); +} + +function restoreStoredChatMessage(message) { + if (!message || typeof message !== "object") return null; + const restored = { ...message }; + restored.id = nonEmptyString(restored.id) ?? nextProtocolId("msg"); + restored.role = restored.role === "user" ? "user" : restored.role === "system" ? "system" : "agent"; + restored.title = nonEmptyString(restored.title) ?? (restored.role === "user" ? "用户" : "Agent"); + restored.text = typeof restored.text === "string" ? restored.text : ""; + restored.status = nonEmptyString(restored.status) ?? "source"; + if (restored.status === "running") restored.status = "source"; + restored.conversationId = nonEmptyString(restored.conversationId) ?? state.conversationId; + restored.sessionId = nonEmptyString(restored.sessionId) ?? state.sessionId; + restored.threadId = nonEmptyString(restored.threadId) ?? state.threadId; + restored.traceId = nonEmptyString(restored.traceId); + if (restored.runnerTrace && typeof restored.runnerTrace === "object") { + restored.runnerTrace = restoreStoredRunnerTrace(restored.runnerTrace); + } + return restored; +} + +function restoreStoredRunnerTrace(trace) { + return { + ...trace, + events: Array.isArray(trace.events) ? trace.events : [], + eventCount: Number.isInteger(trace.eventCount) ? trace.eventCount : Array.isArray(trace.events) ? trace.events.length : 0, + eventsCompacted: trace.eventsCompacted === true, + fullTraceLoaded: trace.fullTraceLoaded === true + }; +} + +function scheduleCodeAgentSessionPersist() { + if (codeAgentSessionPersistTimer !== null) window.clearTimeout(codeAgentSessionPersistTimer); + codeAgentSessionPersistTimer = window.setTimeout(() => { + codeAgentSessionPersistTimer = null; + persistCodeAgentSessionState(); + }, CODE_AGENT_SESSION_PERSIST_DEBOUNCE_MS); +} + +function persistCodeAgentSessionState() { + if (!state.conversationId && !state.sessionId && !state.threadId && state.chatMessages.length === 0) { + clearCodeAgentSessionState(); + return; + } + const payload = { + version: CODE_AGENT_SESSION_STORAGE_VERSION, + updatedAtMs: Date.now(), + conversationId: state.conversationId, + sessionId: state.sessionId, + threadId: state.threadId, + sessionStatus: state.sessionStatus, + chatMessages: state.chatMessages.slice(-CODE_AGENT_SESSION_STORAGE_MESSAGE_LIMIT).map(storedChatMessage) + }; + try { + window.localStorage?.setItem(CODE_AGENT_SESSION_STORAGE_KEY, JSON.stringify(payload)); + } catch { + // Keep the in-memory conversation when localStorage is unavailable or full. + } +} + +function clearCodeAgentSessionState() { + if (codeAgentSessionPersistTimer !== null) { + window.clearTimeout(codeAgentSessionPersistTimer); + codeAgentSessionPersistTimer = null; + } + try { + window.localStorage?.removeItem(CODE_AGENT_SESSION_STORAGE_KEY); + } catch { + // Ignore storage availability errors. + } +} + +function storedChatMessage(message) { + const stored = { ...message }; + if (stored.runnerTrace && typeof stored.runnerTrace === "object") { + stored.runnerTrace = storedRunnerTrace(stored.runnerTrace); + } + delete stored.availability; + return stored; +} + +function storedRunnerTrace(trace) { + const events = Array.isArray(trace.events) ? trace.events : []; + const storedEvents = trace.fullTraceLoaded === true && events.length <= CODE_AGENT_SESSION_STORAGE_TRACE_EVENT_LIMIT + ? events + : []; + const fullTraceLoaded = storedEvents.length > 0 && trace.fullTraceLoaded === true; + return { + ...trace, + events: storedEvents, + eventsCompacted: !fullTraceLoaded, + fullTraceLoaded + }; +} + +function refreshRestoredCodeAgentTraces() { + for (const message of state.chatMessages) { + if (!message.traceId) continue; + if (message.runnerTrace?.fullTraceLoaded === true) continue; + replayFullTrace(message.id, { quiet: true }); + } +} + +function reconcileRestoredCodeAgentResults() { + for (const message of state.chatMessages) { + if (!message.traceId || message.role !== "agent") continue; + if (!shouldReconcileRestoredCodeAgentResult(message)) continue; + reconcileCodeAgentResult(message.id, { quiet: true }); + } +} + +function initLiveBuildOverlay() { + el.liveBuildSummary.open = false; + el.liveBuildToggle.addEventListener("click", (event) => { + event.preventDefault(); + el.liveBuildSummary.open = false; + openWorkbenchDialog({ + title: "构建版本明细", + body: cloneLiveBuildList(), + restoreFocus: el.liveBuildToggle + }); + }); +} + +function cloneLiveBuildList() { + const clone = el.liveBuildList.cloneNode(true); + clone.id = ""; + clone.hidden = false; + clone.removeAttribute("hidden"); + clone.tabIndex = 0; + clone.classList.remove("live-build-list-template"); + return clone; +} + +function openWorkbenchDialog({ title, body, restoreFocus = null }) { + closeWorkbenchDialog({ restoreFocus: false }); + const layer = document.createElement("div"); + layer.className = "workbench-dialog-layer"; + const backdrop = document.createElement("button"); + backdrop.type = "button"; + backdrop.className = "workbench-dialog-backdrop"; + backdrop.setAttribute("aria-label", "关闭弹窗"); + const dialog = document.createElement("section"); + dialog.className = "workbench-dialog"; + dialog.setAttribute("role", "dialog"); + dialog.setAttribute("aria-modal", "true"); + dialog.setAttribute("aria-label", title); + const head = document.createElement("div"); + head.className = "workbench-dialog-head"; + const heading = document.createElement("strong"); + heading.textContent = title; + const close = document.createElement("button"); + close.type = "button"; + close.className = "workbench-dialog-close"; + close.textContent = "关闭"; + close.setAttribute("aria-label", `关闭${title}`); + const content = document.createElement("div"); + content.className = "workbench-dialog-content"; + if (body) { + body.hidden = false; + body.removeAttribute("hidden"); + content.append(body); + } + head.append(heading, close); + dialog.append(head, content); + layer.append(backdrop, dialog); + + const closeDialog = () => closeWorkbenchDialog({ restoreFocus: true }); + const keydown = (event) => { + if (event.key !== "Escape") return; + event.preventDefault(); + closeDialog(); + }; + close.addEventListener("click", closeDialog); + backdrop.addEventListener("click", closeDialog); + document.addEventListener("keydown", keydown); + document.body.append(layer); + activeWorkbenchDialog = { layer, keydown, restoreFocus }; + close.focus(); +} + +function closeWorkbenchDialog({ restoreFocus = true } = {}) { + if (!activeWorkbenchDialog) return; + const { layer, keydown, restoreFocus: target } = activeWorkbenchDialog; + document.removeEventListener("keydown", keydown); + layer.remove(); + activeWorkbenchDialog = null; + if (restoreFocus && target && typeof target.focus === "function") { + target.focus(); + } +} + +function initRoutes() { + window.addEventListener("hashchange", () => showView(routeFromLocation())); + for (const button of document.querySelectorAll("[data-route]")) { + button.addEventListener("click", () => { + window.location.hash = `/${button.dataset.route}`; + }); + } + showView(routeFromLocation()); +} + +function initLayoutSizing() { + const persisted = readPersistedLayout(); + const leftSidebarWidth = clampLeftSidebarWidth(persisted?.leftSidebarWidth ?? LEFT_SIDEBAR_DEFAULT_WIDTH); + const rightSidebarWidth = clampRightSidebarWidth(persisted?.rightSidebarWidth ?? RIGHT_SIDEBAR_DEFAULT_WIDTH); + setLeftSidebarWidth(leftSidebarWidth, { persist: false }); + setLeftSidebarCollapsed(persisted?.leftSidebarCollapsed === true, { persist: false }); + setRightSidebarWidth(rightSidebarWidth, { persist: false }); +} + +function readPersistedLayout() { + try { + const raw = window.localStorage?.getItem(LAYOUT_STORAGE_KEY); + if (!raw) return null; + const payload = JSON.parse(raw); + if (payload?.version !== 1) return null; + return { + leftSidebarCollapsed: payload.leftSidebarCollapsed === true, + leftSidebarWidth: Number(payload.leftSidebarWidth), + rightSidebarWidth: Number(payload.rightSidebarWidth) + }; + } catch { + return null; + } +} + +function persistLayout() { + try { + window.localStorage?.setItem( + LAYOUT_STORAGE_KEY, + JSON.stringify({ + version: 1, + leftSidebarCollapsed: state.layout.leftSidebarCollapsed, + leftSidebarWidth: state.layout.leftSidebarWidth, + rightSidebarWidth: state.layout.rightSidebarWidth + }) + ); + } catch { + // Browser storage can be unavailable; layout still works for the session. + } +} + +function initLeftSidebarToggle() { + el.leftSidebarToggle.addEventListener("click", () => { + setLeftSidebarCollapsed(!state.layout.leftSidebarCollapsed); + }); +} + +function setLeftSidebarCollapsed(collapsed, options = {}) { + state.layout.leftSidebarCollapsed = collapsed === true; + el.shell.classList.toggle("is-left-sidebar-collapsed", state.layout.leftSidebarCollapsed); + el.leftSidebarToggle.setAttribute("aria-expanded", state.layout.leftSidebarCollapsed ? "false" : "true"); + el.leftSidebarToggle.setAttribute( + "aria-label", + state.layout.leftSidebarCollapsed ? "展开左侧导航" : "折叠左侧导航" + ); + el.leftSidebarToggle.title = state.layout.leftSidebarCollapsed ? "展开左侧导航" : "折叠左侧导航"; + el.leftSidebarToggle.textContent = state.layout.leftSidebarCollapsed ? "展开" : "收起"; + syncLeftSidebarResizeA11y(); + if (options.persist !== false) persistLayout(); +} + +function setLeftSidebarWidth(width, options = {}) { + const clamped = clampLeftSidebarWidth(width); + state.layout.leftSidebarWidth = clamped; + el.shell.style.setProperty("--left-sidebar-width", `${clamped}px`); + syncLeftSidebarResizeA11y(); + if (options.persist !== false) persistLayout(); + return clamped; +} + +function setRightSidebarWidth(width, options = {}) { + const clamped = clampRightSidebarWidth(width); + state.layout.rightSidebarWidth = clamped; + el.shell.style.setProperty("--right-sidebar-width", `${clamped}px`); + syncRightSidebarResizeA11y(); + if (options.persist !== false) persistLayout(); + return clamped; +} + +function clampLeftSidebarWidth(value) { + const numeric = Number(value); + const width = Number.isFinite(numeric) ? numeric : LEFT_SIDEBAR_DEFAULT_WIDTH; + const bounds = leftSidebarResizeBounds(); + return Math.min(Math.max(Math.round(width), bounds.min), bounds.max); +} + +function clampRightSidebarWidth(value) { + const numeric = Number(value); + const width = Number.isFinite(numeric) ? numeric : RIGHT_SIDEBAR_DEFAULT_WIDTH; + const bounds = rightSidebarResizeBounds(); + return Math.min(Math.max(Math.round(width), bounds.min), bounds.max); +} + +function boundedResizeRange(min, max, dynamicMax) { + const safeMin = Math.max(0, Math.round(min)); + const safeMax = Math.max(safeMin, Math.round(Math.min(max, Number.isFinite(dynamicMax) ? dynamicMax : max))); + return { min: safeMin, max: safeMax }; +} + +function leftSidebarResizeBounds() { + const styles = getComputedStyle(el.shell); + const cssMin = readCssPixel(styles.getPropertyValue("--left-sidebar-min-width"), LEFT_SIDEBAR_MIN_WIDTH); + const cssMax = readCssPixel(styles.getPropertyValue("--left-sidebar-max-width"), LEFT_SIDEBAR_MAX_WIDTH); + if (window.matchMedia("(max-width: 1240px)").matches) { + const fixedRailWidth = readCssPixel(styles.getPropertyValue("--rail-width"), LEFT_SIDEBAR_DEFAULT_WIDTH); + return { min: Math.round(fixedRailWidth), max: Math.round(fixedRailWidth) }; + } + const rightWidth = state.layout.rightSidebarWidth; + const dynamicMax = window.innerWidth - rightWidth - 420 - 4; + return boundedResizeRange(cssMin, cssMax, dynamicMax); +} + +function rightSidebarResizeBounds() { + const styles = getComputedStyle(el.shell); + const cssMin = readCssPixel(styles.getPropertyValue("--right-sidebar-min-width"), RIGHT_SIDEBAR_MIN_WIDTH); + const cssMax = readCssPixel(styles.getPropertyValue("--right-sidebar-max-width"), RIGHT_SIDEBAR_MAX_WIDTH); + if (window.matchMedia("(max-width: 1240px)").matches) { + return { min: 0, max: Math.max(0, window.innerWidth) }; + } + const railWidth = state.layout.leftSidebarCollapsed + ? readCssPixel(styles.getPropertyValue("--rail-collapsed-width"), LEFT_SIDEBAR_DEFAULT_WIDTH) + : state.layout.leftSidebarWidth; + const dynamicMax = window.innerWidth - railWidth - 420 - 4; + return boundedResizeRange(cssMin, cssMax, dynamicMax); +} + +function readCssPixel(value, fallback) { + const parsed = Number.parseFloat(String(value ?? "").trim()); + return Number.isFinite(parsed) ? parsed : fallback; +} + +function syncRightSidebarResizeA11y() { + const bounds = rightSidebarResizeBounds(); + const disabled = !canResizeRightSidebar(); + el.rightSidebarResize.tabIndex = disabled ? -1 : 0; + el.rightSidebarResize.setAttribute("aria-disabled", disabled ? "true" : "false"); + el.rightSidebarResize.setAttribute("aria-hidden", disabled ? "true" : "false"); + el.rightSidebarResize.setAttribute("aria-valuemin", String(bounds.min)); + el.rightSidebarResize.setAttribute("aria-valuemax", String(bounds.max)); + el.rightSidebarResize.setAttribute("aria-valuenow", String(state.layout.rightSidebarWidth)); + el.rightSidebarResize.setAttribute("aria-valuetext", `右侧 Device Pod 看板宽度 ${state.layout.rightSidebarWidth} 像素`); +} + +function syncLeftSidebarResizeA11y() { + const bounds = leftSidebarResizeBounds(); + const disabled = !canResizeLeftSidebar(); + el.leftSidebarResize.tabIndex = disabled ? -1 : 0; + el.leftSidebarResize.setAttribute("aria-disabled", disabled ? "true" : "false"); + el.leftSidebarResize.setAttribute("aria-hidden", disabled ? "true" : "false"); + el.leftSidebarResize.setAttribute("aria-valuemin", String(bounds.min)); + el.leftSidebarResize.setAttribute("aria-valuemax", String(bounds.max)); + el.leftSidebarResize.setAttribute("aria-valuenow", String(state.layout.leftSidebarWidth)); + el.leftSidebarResize.setAttribute("aria-valuetext", `左侧导航宽度 ${state.layout.leftSidebarWidth} 像素`); +} + +function routeFromLocation() { + const hashRoute = window.location.hash.replace(/^#\/?/, ""); + if (viewIds.has(hashRoute)) return hashRoute; + if (helpPathnames().has(window.location.pathname.replace(/\/+$/, "") || "/")) return "help"; + if (internalGatePathnames().has(window.location.pathname.replace(/\/+$/, "") || "/")) return "gate"; + return "workspace"; +} + +function showView(route) { + const activeRoute = viewIds.has(route) ? route : "workspace"; + for (const view of document.querySelectorAll("[data-view]")) { + view.hidden = view.dataset.view !== activeRoute; + } + for (const button of document.querySelectorAll("[data-route]")) { + const active = button.dataset.route === activeRoute; + button.classList.toggle("active", active); + button.setAttribute("aria-current", active ? "page" : "false"); + } + if (activeRoute === "gate" && state.gateDiagnostics.rows.length === 0 && !state.gateDiagnostics.loading) { + loadGateDiagnostics(); + } +} + +function initLeftSidebarResize() { + window.addEventListener("resize", () => { + if (canResizeLeftSidebar()) setLeftSidebarWidth(state.layout.leftSidebarWidth, { persist: false }); + syncLeftSidebarResizeA11y(); + }); + el.leftSidebarResize.addEventListener("pointerdown", (event) => { + if (!canResizeLeftSidebar()) return; + event.preventDefault(); + el.leftSidebarResize.setPointerCapture?.(event.pointerId); + state.layout.leftDrag = { + pointerId: event.pointerId, + startX: event.clientX, + startWidth: state.layout.leftSidebarWidth + }; + el.shell.classList.add("is-resizing"); + }); + el.leftSidebarResize.addEventListener("pointermove", (event) => { + const drag = state.layout.leftDrag; + if (!drag || drag.pointerId !== event.pointerId) return; + setLeftSidebarWidth(drag.startWidth + event.clientX - drag.startX, { persist: false }); + }); + el.leftSidebarResize.addEventListener("pointerup", finishLeftSidebarResize); + el.leftSidebarResize.addEventListener("pointercancel", finishLeftSidebarResize); + el.leftSidebarResize.addEventListener("keydown", (event) => { + if (!canResizeLeftSidebar()) return; + const keySteps = { + ArrowLeft: -8, + ArrowRight: 8, + PageUp: 24, + PageDown: -24 + }; + if (Object.hasOwn(keySteps, event.key)) { + event.preventDefault(); + setLeftSidebarWidth(state.layout.leftSidebarWidth + keySteps[event.key]); + } else if (event.key === "Home") { + event.preventDefault(); + setLeftSidebarWidth(leftSidebarResizeBounds().min); + } else if (event.key === "End") { + event.preventDefault(); + setLeftSidebarWidth(leftSidebarResizeBounds().max); + } + }); +} + +function finishLeftSidebarResize(event) { + const drag = state.layout.leftDrag; + if (!drag || drag.pointerId !== event.pointerId) return; + state.layout.leftDrag = null; + el.shell.classList.remove("is-resizing"); + persistLayout(); +} + +function canResizeLeftSidebar() { + return !state.layout.leftSidebarCollapsed && !window.matchMedia("(max-width: 1240px)").matches; +} + +function initRightSidebarResize() { + window.addEventListener("resize", () => { + if (canResizeRightSidebar()) setRightSidebarWidth(state.layout.rightSidebarWidth, { persist: false }); + syncRightSidebarResizeA11y(); + }); + el.rightSidebarResize.addEventListener("pointerdown", (event) => { + if (!canResizeRightSidebar()) return; + event.preventDefault(); + el.rightSidebarResize.setPointerCapture?.(event.pointerId); + state.layout.rightDrag = { + pointerId: event.pointerId, + startX: event.clientX, + startWidth: state.layout.rightSidebarWidth + }; + el.shell.classList.add("is-resizing"); + }); + el.rightSidebarResize.addEventListener("pointermove", (event) => { + const drag = state.layout.rightDrag; + if (!drag || drag.pointerId !== event.pointerId) return; + setRightSidebarWidth(drag.startWidth + drag.startX - event.clientX, { persist: false }); + }); + el.rightSidebarResize.addEventListener("pointerup", finishRightSidebarResize); + el.rightSidebarResize.addEventListener("pointercancel", finishRightSidebarResize); + el.rightSidebarResize.addEventListener("keydown", (event) => { + if (!canResizeRightSidebar()) return; + const keySteps = { + ArrowLeft: 16, + ArrowRight: -16, + PageUp: 48, + PageDown: -48 + }; + if (Object.hasOwn(keySteps, event.key)) { + event.preventDefault(); + setRightSidebarWidth(state.layout.rightSidebarWidth + keySteps[event.key]); + } else if (event.key === "Home") { + event.preventDefault(); + setRightSidebarWidth(rightSidebarResizeBounds().min); + } else if (event.key === "End") { + event.preventDefault(); + setRightSidebarWidth(rightSidebarResizeBounds().max); + } + }); +} + +function finishRightSidebarResize(event) { + const drag = state.layout.rightDrag; + if (!drag || drag.pointerId !== event.pointerId) return; + state.layout.rightDrag = null; + el.shell.classList.remove("is-resizing"); + persistLayout(); +} + +function canResizeRightSidebar() { + return !window.matchMedia("(max-width: 1240px)").matches; +} + +async function loadHelpSurface() { + el.helpStatus.textContent = "加载中"; + el.helpStatus.className = "state-tag tone-dry-run"; + el.helpContent.dataset.helpState = "loading"; + + try { + const response = await fetch("/help.md", { cache: "no-store" }); + if (!response.ok) { + throw new Error(`help.md HTTP ${response.status}`); + } + const markdown = await response.text(); + el.helpContent.innerHTML = marked.parse(markdown, { gfm: true, breaks: false }); + el.helpContent.dataset.helpState = "ready"; + el.helpStatus.textContent = "已就绪"; + el.helpStatus.className = "state-tag tone-source"; + } catch (error) { + el.helpContent.dataset.helpState = "error"; + el.helpContent.replaceChildren(); + const notice = document.createElement("div"); + notice.className = "help-error"; + notice.textContent = `帮助内容加载失败:${error.message}。默认工作台仍可继续使用。`; + el.helpContent.append(notice); + el.helpStatus.textContent = "加载失败"; + el.helpStatus.className = "state-tag tone-blocked"; + } +} diff --git a/web/hwlab-cloud-web/auth.test.mjs b/web/hwlab-cloud-web/auth.test.ts similarity index 96% rename from web/hwlab-cloud-web/auth.test.mjs rename to web/hwlab-cloud-web/auth.test.ts index 64dd7ceb..f35a8f3f 100644 --- a/web/hwlab-cloud-web/auth.test.mjs +++ b/web/hwlab-cloud-web/auth.test.ts @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { ensureWorkbenchAuth } from "./auth.mjs"; +import { ensureWorkbenchAuth } from "./auth.ts"; const AUTH_STORAGE_KEY = "hwlab.cloudWorkbench.auth.v1"; @@ -13,7 +13,7 @@ test("local auth restore refreshes the server cookie before showing the workbenc "/auth/login": { authenticated: true, user: { username: "admin" }, - expiresAt: "2026-05-27T00:00:00.000Z" + expiresAt: futureIso() } } }); @@ -119,6 +119,10 @@ function validLocalSession() { }; } +function futureIso() { + return new Date(Date.now() + 60 * 60 * 1000).toISOString(); +} + function elementStub() { return { hidden: false, diff --git a/web/hwlab-cloud-web/auth.mjs b/web/hwlab-cloud-web/auth.ts similarity index 100% rename from web/hwlab-cloud-web/auth.mjs rename to web/hwlab-cloud-web/auth.ts diff --git a/web/hwlab-cloud-web/code-agent-facts.test.mjs b/web/hwlab-cloud-web/code-agent-facts.test.ts similarity index 99% rename from web/hwlab-cloud-web/code-agent-facts.test.mjs rename to web/hwlab-cloud-web/code-agent-facts.test.ts index c203cfe9..7f19c036 100644 --- a/web/hwlab-cloud-web/code-agent-facts.test.mjs +++ b/web/hwlab-cloud-web/code-agent-facts.test.ts @@ -5,7 +5,7 @@ import { codeAgentAttributionFromMessage, codeAgentFactsFromMessage, codeAgentRuntimePathFromMessage -} from "./code-agent-facts.mjs"; +} from "./code-agent-facts.ts"; test("codex-readonly-runner is shown as partial read-only session registry", () => { const facts = codeAgentFactsFromMessage({ diff --git a/web/hwlab-cloud-web/code-agent-facts.mjs b/web/hwlab-cloud-web/code-agent-facts.ts similarity index 100% rename from web/hwlab-cloud-web/code-agent-facts.mjs rename to web/hwlab-cloud-web/code-agent-facts.ts diff --git a/web/hwlab-cloud-web/code-agent-status.test.mjs b/web/hwlab-cloud-web/code-agent-status.test.ts similarity index 99% rename from web/hwlab-cloud-web/code-agent-status.test.mjs rename to web/hwlab-cloud-web/code-agent-status.test.ts index f299f003..c5724f50 100644 --- a/web/hwlab-cloud-web/code-agent-status.test.mjs +++ b/web/hwlab-cloud-web/code-agent-status.test.ts @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { classifyCodeAgentStatusSummary } from "./code-agent-status.mjs"; +import { classifyCodeAgentStatusSummary } from "./code-agent-status.ts"; const liveRevision = Object.freeze({ healthLive: { diff --git a/web/hwlab-cloud-web/code-agent-status.mjs b/web/hwlab-cloud-web/code-agent-status.ts similarity index 99% rename from web/hwlab-cloud-web/code-agent-status.mjs rename to web/hwlab-cloud-web/code-agent-status.ts index 0af840d1..7d6b62be 100644 --- a/web/hwlab-cloud-web/code-agent-status.mjs +++ b/web/hwlab-cloud-web/code-agent-status.ts @@ -3,7 +3,7 @@ import { runnerTraceSummary as compactRunnerTraceSummary, skillsSummary as compactSkillsSummary, toolCallsSummary as compactToolCallsSummary -} from "./code-agent-facts.mjs"; +} from "./code-agent-facts.ts"; const GREEN_TONES = new Set(["ok", "available", "ready", "dev-live", "live", "pass"]); const SECRET_MARKER = /\b(?:OPENAI_API_KEY|DATABASE_URL|secret|secretref|token|password|passwd|credential|private key|kubeconfig|密钥|密码|凭证)\b/iu; diff --git a/web/hwlab-cloud-web/index.html b/web/hwlab-cloud-web/index.html index 8b646f7e..24f91241 100644 --- a/web/hwlab-cloud-web/index.html +++ b/web/hwlab-cloud-web/index.html @@ -6,7 +6,7 @@