diff --git a/deploy/deploy.json b/deploy/deploy.json index 4520fba4..1bca6dfa 100644 --- a/deploy/deploy.json +++ b/deploy/deploy.json @@ -181,7 +181,10 @@ "HWLAB_RUNTIME_SUBSTITUTE_FORBIDDEN": "unidesk-backend,provider-gateway,microservice-proxy", "HWLAB_CLOUD_DB_URL": "secretRef:hwlab-cloud-api-dev-db/database-url", "HWLAB_CLOUD_DB_SSL_MODE": "require", - "HWLAB_CLOUD_DB_CONTRACT": "dev-redacted-presence-only" + "HWLAB_CLOUD_DB_CONTRACT": "dev-redacted-presence-only", + "HWLAB_CODE_AGENT_PROVIDER": "openai", + "HWLAB_CODE_AGENT_MODEL": "gpt-5.5", + "OPENAI_API_KEY": "secretRef:hwlab-code-agent-provider/openai-api-key" } }, { diff --git a/deploy/k8s/base/workloads.yaml b/deploy/k8s/base/workloads.yaml index 6fbbc627..3744f2fb 100644 --- a/deploy/k8s/base/workloads.yaml +++ b/deploy/k8s/base/workloads.yaml @@ -80,6 +80,24 @@ { "name": "HWLAB_CLOUD_DB_CONTRACT", "value": "dev-redacted-presence-only" + }, + { + "name": "HWLAB_CODE_AGENT_PROVIDER", + "value": "openai" + }, + { + "name": "HWLAB_CODE_AGENT_MODEL", + "value": "gpt-5.5" + }, + { + "name": "OPENAI_API_KEY", + "valueFrom": { + "secretKeyRef": { + "name": "hwlab-code-agent-provider", + "key": "openai-api-key", + "optional": true + } + } } ], "readinessProbe": { diff --git a/internal/cloud/code-agent-chat.mjs b/internal/cloud/code-agent-chat.mjs new file mode 100644 index 00000000..aae169bb --- /dev/null +++ b/internal/cloud/code-agent-chat.mjs @@ -0,0 +1,515 @@ +import { spawn } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { constants as fsConstants } from "node:fs"; +import { access, mkdir, readFile, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const DEFAULT_CODE_AGENT_TIMEOUT_MS = 120000; +const DEFAULT_CODEX_COMMAND = "codex"; +const DEFAULT_MODEL = "gpt-5.2"; +const DEFAULT_PROJECT_ID = "prj_hwlab-cloud-workbench"; +const CODE_AGENT_SYSTEM_PROMPT = [ + "你是 HWLAB 云工作台的 Code Agent。", + "请用中文直接回答用户的工作台问题。", + "当前最小版本可以帮助用户整理任务、查看云工作台资源和说明下一步;不要声称已经执行硬件变更。", + "不要输出 secret、token 或环境变量原文。" +].join("\n"); + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); + +export async function handleCodeAgentChat(params = {}, options = {}) { + const timestamp = nowIso(options.now); + const conversationId = cleanProtocolId(params.conversationId, "cnv") || `cnv_${randomUUID()}`; + const sessionId = conversationId; + const messageId = `msg_${randomUUID()}`; + const traceId = cleanProtocolId(params.traceId, "trc") || `trc_${randomUUID()}`; + const providerPlan = resolveProviderPlan(options.env ?? process.env, options); + const base = { + conversationId, + sessionId, + messageId, + status: "running", + createdAt: timestamp, + updatedAt: timestamp, + traceId, + provider: providerPlan.provider, + model: providerPlan.model, + backend: providerPlan.backend, + projectId: cleanProjectId(params.projectId) || DEFAULT_PROJECT_ID + }; + + try { + const message = normalizeUserMessage(params.message); + const providerResult = await callConfiguredProvider({ + providerPlan, + message, + conversationId, + traceId, + timeoutMs: options.timeoutMs, + env: options.env ?? process.env, + now: options.now, + callProvider: options.callProvider + }); + const completedAt = nowIso(options.now); + return { + ...base, + status: "completed", + updatedAt: completedAt, + provider: providerResult.provider ?? base.provider, + model: providerResult.model ?? base.model, + backend: providerResult.backend ?? base.backend, + reply: { + messageId, + role: "assistant", + content: providerResult.content, + createdAt: completedAt + }, + usage: providerResult.usage ?? null, + providerTrace: providerResult.providerTrace ?? null + }; + } catch (error) { + const failedAt = nowIso(options.now); + return { + ...base, + status: "failed", + updatedAt: failedAt, + error: normalizeChatError(error) + }; + } +} + +export function validateCodeAgentChatSchema(payload) { + const required = [ + "conversationId", + "sessionId", + "messageId", + "status", + "createdAt", + "updatedAt", + "traceId", + "provider", + "model", + "backend" + ]; + for (const field of required) { + if (!payload || typeof payload !== "object" || !Object.hasOwn(payload, field)) { + throw new Error(`code agent chat response missing ${field}`); + } + } + if (!["running", "completed", "failed"].includes(payload.status)) { + throw new Error(`code agent chat response has invalid status ${JSON.stringify(payload.status)}`); + } + if (Number.isNaN(Date.parse(payload.createdAt)) || Number.isNaN(Date.parse(payload.updatedAt))) { + throw new Error("code agent chat response timestamps must be RFC 3339 strings"); + } + if (payload.status === "failed" && typeof payload.error?.message !== "string") { + throw new Error("failed code agent chat response must include error.message"); + } + if (payload.status === "completed" && typeof payload.reply?.content !== "string") { + throw new Error("completed code agent chat response must include reply.content"); + } +} + +function resolveProviderPlan(env, options = {}) { + const provider = String(env.HWLAB_CODE_AGENT_PROVIDER || "auto").trim().toLowerCase(); + const model = firstNonEmpty( + options.model, + env.HWLAB_CODE_AGENT_MODEL, + env.OPENAI_MODEL, + env.CODE_QUEUE_DEFAULT_MODEL, + DEFAULT_MODEL + ); + + if (provider === "openai") { + return { + provider: "openai-responses", + model, + backend: "hwlab-cloud-api/openai-responses", + mode: "openai" + }; + } + if (provider === "codex-cli" || provider === "codex") { + return { + provider: "codex-cli", + model, + backend: "hwlab-cloud-api/codex-cli", + mode: "codex-cli" + }; + } + return { + provider: env.OPENAI_API_KEY ? "openai-responses" : "codex-cli", + model, + backend: env.OPENAI_API_KEY ? "hwlab-cloud-api/openai-responses" : "hwlab-cloud-api/codex-cli", + mode: env.OPENAI_API_KEY ? "openai" : "codex-cli", + auto: true + }; +} + +async function callConfiguredProvider({ + providerPlan, + message, + conversationId, + traceId, + timeoutMs, + env, + now, + callProvider +}) { + if (callProvider) { + return callProvider({ providerPlan, message, conversationId, traceId, timeoutMs, env, now }); + } + if (providerPlan.mode === "openai") { + return callOpenAiResponses({ providerPlan, message, conversationId, traceId, timeoutMs, env }); + } + return callCodexCli({ providerPlan, message, conversationId, traceId, timeoutMs, env }); +} + +async function callOpenAiResponses({ providerPlan, message, conversationId, traceId, timeoutMs, env }) { + if (!env.OPENAI_API_KEY) { + throw providerUnavailable("OPENAI_API_KEY is not configured for openai-responses", { + missingEnv: ["OPENAI_API_KEY"], + provider: providerPlan.provider, + model: providerPlan.model, + backend: providerPlan.backend + }); + } + + const endpoint = env.HWLAB_CODE_AGENT_OPENAI_BASE_URL || "https://api.openai.com/v1/responses"; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), effectiveTimeout(timeoutMs)); + let response; + let payload; + + try { + response = await fetch(endpoint, { + method: "POST", + headers: { + "content-type": "application/json", + authorization: `Bearer ${env.OPENAI_API_KEY}` + }, + body: JSON.stringify({ + model: providerPlan.model, + instructions: CODE_AGENT_SYSTEM_PROMPT, + input: [ + { + role: "user", + content: [ + { + type: "input_text", + text: buildAgentPrompt({ message, conversationId, traceId }) + } + ] + } + ], + store: false + }), + signal: controller.signal + }); + payload = await response.json().catch(() => null); + } catch (error) { + if (error.name === "AbortError") { + throw providerUnavailable(`OpenAI Responses request timed out after ${effectiveTimeout(timeoutMs)}ms`, { + provider: providerPlan.provider, + model: providerPlan.model, + backend: providerPlan.backend + }); + } + throw providerUnavailable(`OpenAI Responses request failed: ${error.message}`, { + provider: providerPlan.provider, + model: providerPlan.model, + backend: providerPlan.backend + }); + } finally { + clearTimeout(timer); + } + + if (!response.ok) { + throw providerUnavailable(`OpenAI Responses returned HTTP ${response.status}: ${payload?.error?.message || "request rejected"}`, { + provider: providerPlan.provider, + model: providerPlan.model, + backend: providerPlan.backend, + providerStatus: response.status + }); + } + + const content = extractOpenAiOutputText(payload); + if (!content) { + throw providerUnavailable("OpenAI Responses returned no assistant text", { + provider: providerPlan.provider, + model: providerPlan.model, + backend: providerPlan.backend + }); + } + + return { + provider: providerPlan.provider, + model: payload.model || providerPlan.model, + backend: providerPlan.backend, + content, + usage: payload.usage ?? null, + providerTrace: { + responseId: payload.id ?? null + } + }; +} + +async function callCodexCli({ providerPlan, message, conversationId, traceId, timeoutMs, env }) { + const command = firstNonEmpty(env.HWLAB_CODE_AGENT_CODEX_COMMAND, DEFAULT_CODEX_COMMAND); + if (!(await commandExists(command, env))) { + throw providerUnavailable(`Codex CLI command is not available: ${command}`, { + missingCommands: [command], + missingEnv: env.OPENAI_API_KEY ? [] : ["OPENAI_API_KEY"], + provider: providerPlan.provider, + model: providerPlan.model, + backend: providerPlan.backend, + command + }); + } + + const outputDir = await makeTempDir(); + const outputFile = path.join(outputDir, "last-message.txt"); + const args = [ + "exec", + "--cd", + repoRoot, + "--skip-git-repo-check", + "--sandbox", + "read-only", + "--ephemeral", + "--output-last-message", + outputFile + ]; + if (providerPlan.model) { + args.push("--model", providerPlan.model); + } + args.push("-"); + + const result = await spawnWithInput(command, args, buildAgentPrompt({ message, conversationId, traceId }), { + env, + timeoutMs: effectiveTimeout(timeoutMs) + }); + + try { + if (result.code !== 0) { + throw providerUnavailable(`Codex CLI exited with code ${result.code}`, { + provider: providerPlan.provider, + model: providerPlan.model, + backend: providerPlan.backend, + command: commandLine(command, args), + exitCode: result.code, + stderrSummary: redactText(tailText(result.stderr || result.stdout)) + }); + } + + const content = (await readFile(outputFile, "utf8")).trim(); + if (!content) { + throw providerUnavailable("Codex CLI completed without an assistant message", { + provider: providerPlan.provider, + model: providerPlan.model, + backend: providerPlan.backend, + command: commandLine(command, args), + stderrSummary: redactText(tailText(result.stderr || result.stdout)) + }); + } + + return { + provider: providerPlan.provider, + model: providerPlan.model, + backend: providerPlan.backend, + content, + usage: null, + providerTrace: { + command: commandLine(command, args), + stdoutSummary: redactText(tailText(result.stdout, 600)) + } + }; + } finally { + await rm(outputDir, { recursive: true, force: true }); + } +} + +function buildAgentPrompt({ message, conversationId, traceId }) { + return [ + CODE_AGENT_SYSTEM_PROMPT, + "", + `conversationId: ${conversationId}`, + `traceId: ${traceId}`, + "", + "用户消息:", + message + ].join("\n"); +} + +function normalizeUserMessage(value) { + if (typeof value !== "string") { + throw badRequest("message must be a string"); + } + const message = value.trim(); + if (!message) { + throw badRequest("message is required"); + } + if (message.length > 4000) { + throw badRequest("message exceeds 4000 characters"); + } + return message; +} + +function normalizeChatError(error) { + const normalized = { + code: error.code || "code_agent_failed", + message: error.message || "Code Agent request failed" + }; + for (const key of [ + "missingEnv", + "missingCommands", + "provider", + "model", + "backend", + "command", + "exitCode", + "providerStatus", + "stderrSummary" + ]) { + if (error[key] !== undefined) { + normalized[key] = error[key]; + } + } + return normalized; +} + +function badRequest(message) { + const error = new Error(message); + error.code = "invalid_params"; + return error; +} + +function providerUnavailable(message, details = {}) { + const error = new Error(message); + error.code = "provider_unavailable"; + Object.assign(error, details); + return error; +} + +function extractOpenAiOutputText(payload) { + if (typeof payload?.output_text === "string" && payload.output_text.trim()) { + return payload.output_text.trim(); + } + const chunks = []; + for (const item of payload?.output ?? []) { + for (const content of item.content ?? []) { + if (typeof content.text === "string") chunks.push(content.text); + else if (typeof content.output_text === "string") chunks.push(content.output_text); + } + } + return chunks.join("\n").trim(); +} + +async function commandExists(command, env) { + if (command.includes("/") || command.includes("\\")) { + try { + await access(command, fsConstants.X_OK); + return true; + } catch { + return false; + } + } + + const result = await spawnWithInput("sh", ["-lc", `command -v ${shellQuote(command)}`], "", { + env, + timeoutMs: 3000 + }); + return result.code === 0; +} + +function spawnWithInput(command, args, input, { env, timeoutMs }) { + return new Promise((resolve) => { + const child = spawn(command, args, { + cwd: repoRoot, + env, + stdio: ["pipe", "pipe", "pipe"] + }); + let stdout = ""; + let stderr = ""; + let settled = false; + const timer = setTimeout(() => { + if (!settled) { + child.kill("SIGTERM"); + } + }, timeoutMs); + + child.stdout.on("data", (chunk) => { + stdout += chunk; + }); + child.stderr.on("data", (chunk) => { + stderr += chunk; + }); + child.on("error", (error) => { + settled = true; + clearTimeout(timer); + resolve({ code: 127, stdout, stderr: `${stderr}\n${error.message}` }); + }); + child.on("close", (code) => { + settled = true; + clearTimeout(timer); + resolve({ code: code ?? 1, stdout, stderr }); + }); + child.stdin.end(input); + }); +} + +async function makeTempDir() { + const dir = path.join(os.tmpdir(), `hwlab-code-agent-${randomUUID()}`); + await mkdir(dir, { recursive: true }); + return dir; +} + +function cleanProtocolId(value, prefix) { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + if (!trimmed) return null; + if (/^[a-z][a-z0-9]*_[A-Za-z0-9._:-]+$/u.test(trimmed)) return trimmed; + return `${prefix}_${trimmed.replace(/[^A-Za-z0-9._:-]/gu, "-").slice(0, 80)}`; +} + +function cleanProjectId(value) { + return cleanProtocolId(value, "prj"); +} + +function nowIso(now) { + return typeof now === "function" ? now() : new Date().toISOString(); +} + +function effectiveTimeout(timeoutMs) { + return Number.isInteger(timeoutMs) && timeoutMs > 0 ? timeoutMs : DEFAULT_CODE_AGENT_TIMEOUT_MS; +} + +function firstNonEmpty(...values) { + for (const value of values) { + if (typeof value === "string" && value.trim()) return value.trim(); + } + return ""; +} + +function commandLine(command, args) { + return [command, ...args].map((part) => (/\s/u.test(part) ? JSON.stringify(part) : part)).join(" "); +} + +function tailText(value, maxLength = 1200) { + const text = String(value ?? "").trim(); + if (text.length <= maxLength) return text; + return text.slice(text.length - maxLength); +} + +function redactText(value) { + return String(value ?? "") + .replace(/Bearer\s+[A-Za-z0-9._~+/=-]+/gu, "Bearer ***") + .replace(/sk-[A-Za-z0-9._-]+/gu, "sk-***") + .replace(/([A-Za-z0-9_]*TOKEN[A-Za-z0-9_]*=)[^\s]+/giu, "$1***") + .replace(/([A-Za-z0-9_]*KEY[A-Za-z0-9_]*=)[^\s]+/giu, "$1***"); +} + +function shellQuote(value) { + return `'${String(value).replaceAll("'", "'\\''")}'`; +} diff --git a/internal/cloud/server.mjs b/internal/cloud/server.mjs index 3907fa9b..93c0d2e1 100644 --- a/internal/cloud/server.mjs +++ b/internal/cloud/server.mjs @@ -12,6 +12,7 @@ import { createErrorEnvelope, handleJsonRpcRequest } from "./json-rpc.mjs"; +import { handleCodeAgentChat } from "./code-agent-chat.mjs"; import { buildDbRuntimeReadiness } from "./db-contract.mjs"; import { createCloudRuntimeStore } from "../db/runtime-store.mjs"; @@ -146,6 +147,23 @@ async function handleRestAdapter(request, response, url, options) { adapter: "rest", status: "degraded", rpcBridge: "POST /v1/rpc/{method}", + codeAgent: { + endpoint: "POST /v1/agent/chat", + status: "available", + schema: [ + "conversationId", + "sessionId", + "messageId", + "status", + "createdAt", + "updatedAt", + "traceId", + "provider", + "model", + "backend", + "error.message" + ] + }, methods: SUPPORTED_RPC_METHODS, auditFields: AUDIT_FIELD_NAMES, db: await buildDbRuntimeReadiness(process.env, options.dbProbe), @@ -154,6 +172,11 @@ async function handleRestAdapter(request, response, url, options) { return; } + if (request.method === "POST" && url.pathname === "/v1/agent/chat") { + await handleCodeAgentChatHttp(request, response, options); + return; + } + if (request.method !== "POST" || !url.pathname.startsWith("/v1/rpc/")) { sendRestError(request, response, 404, "not_found", "REST route is not implemented in the L1 cloud-api runtime", { operation: `${request.method || "GET"} ${url.pathname}`, @@ -218,6 +241,68 @@ async function handleRestAdapter(request, response, url, options) { sendJson(response, statusForRpcResponse(rpcResponse), rpcResponse); } +async function handleCodeAgentChatHttp(request, response, options) { + const body = await readBody(request, options.bodyLimitBytes); + let params = {}; + + try { + params = body ? JSON.parse(body) : {}; + } catch (error) { + sendJson(response, 400, { + conversationId: "cnv_unassigned", + sessionId: "cnv_unassigned", + messageId: "msg_unassigned", + status: "failed", + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + traceId: getHeader(request, "x-trace-id") || "trc_unassigned", + provider: "unassigned", + model: process.env.HWLAB_CODE_AGENT_MODEL || process.env.OPENAI_MODEL || "unknown", + backend: "hwlab-cloud-api/code-agent-chat", + error: { + code: "parse_error", + message: "Invalid JSON body", + reason: error.message + } + }); + return; + } + + if (!params || typeof params !== "object" || Array.isArray(params)) { + sendJson(response, 400, { + conversationId: "cnv_unassigned", + sessionId: "cnv_unassigned", + messageId: "msg_unassigned", + status: "failed", + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + traceId: getHeader(request, "x-trace-id") || "trc_unassigned", + provider: "unassigned", + model: process.env.HWLAB_CODE_AGENT_MODEL || process.env.OPENAI_MODEL || "unknown", + backend: "hwlab-cloud-api/code-agent-chat", + error: { + code: "invalid_params", + message: "Code Agent chat body must be a JSON object" + } + }); + return; + } + + const payload = await handleCodeAgentChat( + { + ...params, + traceId: getHeader(request, "x-trace-id") || params.traceId + }, + { + callProvider: options.callCodeAgentProvider, + timeoutMs: options.codeAgentTimeoutMs, + env: options.env + } + ); + + sendJson(response, payload.status === "failed" && payload.error?.code === "invalid_params" ? 400 : 200, payload); +} + function sendRestError(request, response, statusCode, code, message, context = {}) { const requestId = getHeader(request, "x-request-id") || "req_unassigned"; sendJson(response, statusCode, { diff --git a/internal/cloud/server.test.mjs b/internal/cloud/server.test.mjs index 409f22b3..a83ccef6 100644 --- a/internal/cloud/server.test.mjs +++ b/internal/cloud/server.test.mjs @@ -178,3 +178,94 @@ test("cloud api health does not treat DB env presence-only as live readiness", a }); } }); + +test("cloud api /v1/agent/chat returns structured completed Code Agent payload", async () => { + const server = createCloudApiServer({ + callCodeAgentProvider: async ({ providerPlan, message, traceId }) => ({ + provider: providerPlan.provider, + model: providerPlan.model, + backend: providerPlan.backend, + content: `真实 provider stub: ${message} / ${traceId}`, + usage: null, + providerTrace: { + source: "test-provider" + } + }) + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + + try { + const { port } = server.address(); + const response = await fetch(`http://127.0.0.1:${port}/v1/agent/chat`, { + method: "POST", + headers: { + "content-type": "application/json", + "x-trace-id": "trc_server-test-agent-chat" + }, + body: JSON.stringify({ + conversationId: "cnv_server-test-agent-chat", + message: "请用一句话说明当前 HWLAB 工作台可以做什么。" + }) + }); + assert.equal(response.status, 200); + const payload = await response.json(); + assert.equal(payload.conversationId, "cnv_server-test-agent-chat"); + assert.equal(payload.sessionId, "cnv_server-test-agent-chat"); + assert.match(payload.messageId, /^msg_/); + assert.equal(payload.status, "completed"); + assert.equal(payload.traceId, "trc_server-test-agent-chat"); + assert.equal(payload.provider.length > 0, true); + assert.equal(payload.model.length > 0, true); + assert.equal(payload.backend.length > 0, true); + assert.equal(Number.isNaN(Date.parse(payload.createdAt)), false); + assert.equal(Number.isNaN(Date.parse(payload.updatedAt)), false); + assert.match(payload.reply.content, /HWLAB 工作台/); + } finally { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + } +}); + +test("cloud api /v1/agent/chat reports provider gaps without faking a reply", async () => { + const server = createCloudApiServer({ + env: { + PATH: "", + HWLAB_CODE_AGENT_PROVIDER: "codex-cli", + HWLAB_CODE_AGENT_MODEL: "gpt-test" + } + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + + try { + const { port } = server.address(); + const response = await fetch(`http://127.0.0.1:${port}/v1/agent/chat`, { + method: "POST", + headers: { + "content-type": "application/json" + }, + body: JSON.stringify({ + message: "你好" + }) + }); + assert.equal(response.status, 200); + const payload = await response.json(); + assert.match(payload.conversationId, /^cnv_/); + assert.match(payload.messageId, /^msg_/); + assert.equal(payload.status, "failed"); + assert.equal(payload.provider, "codex-cli"); + assert.equal(payload.model, "gpt-test"); + assert.equal(payload.backend, "hwlab-cloud-api/codex-cli"); + assert.equal(Number.isNaN(Date.parse(payload.createdAt)), false); + assert.equal(Number.isNaN(Date.parse(payload.updatedAt)), false); + assert.equal(payload.error.code, "provider_unavailable"); + assert.match(payload.error.message, /Codex CLI command is not available/); + assert.deepEqual(payload.error.missingCommands, ["codex"]); + assert.ok(payload.error.missingEnv.includes("OPENAI_API_KEY")); + assert.equal(Object.hasOwn(payload, "reply"), false); + } finally { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + } +}); diff --git a/package.json b/package.json index 4b75b44d..502cba1b 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "validate": "node scripts/validate-contract.mjs && node scripts/deploy-contract-plan.mjs --check && node scripts/deploy-desired-state-plan.mjs --check", - "check": "node --check internal/protocol/index.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/mvp-gate/summary.mjs && node --check internal/cloud/db-contract.mjs && node --check internal/cloud/json-rpc.mjs && node --check internal/cloud/server.mjs && node --check internal/sim/model.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-agent-mgr/main.mjs && node --check cmd/hwlab-agent-worker/main.mjs && node --check cmd/hwlab-box-simu/main.mjs && node --check cmd/hwlab-gateway-simu/main.mjs && node --check scripts/cloud-api-runtime-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-desired-state-plan.mjs && node --check scripts/src/deploy-desired-state-plan.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/validate-dev-m3-cardinality.mjs && node --check scripts/validate-artifact-catalog.mjs && node --check scripts/refresh-artifact-catalog.mjs && node --check scripts/dev-artifact-publish.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/d601-k3s-readonly-observability.mjs && node --check scripts/src/d601-k3s-readonly-observability.mjs && node --check scripts/l2-runtime-contract-smoke.mjs && node --check scripts/patch-panel-runtime-smoke.mjs && node --check scripts/export-web-gate-summary.mjs && node --check scripts/l6-cli-web-smoke.mjs && node --check tools/hwlab-cli/bin/hwlab-cli.mjs && node --check tools/hwlab-cli/lib/cli.mjs && node --check web/hwlab-cloud-web/app.mjs && node --check web/hwlab-cloud-web/gate-summary.mjs && node --check web/hwlab-cloud-web/scripts/check.mjs && node --check web/hwlab-cloud-web/scripts/build.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-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 --test scripts/deploy-desired-state-plan.test.mjs scripts/src/dev-deploy-apply.test.mjs internal/agent/index.test.mjs internal/audit/index.test.mjs internal/db/schema.test.mjs internal/cloud/json-rpc.test.mjs internal/cloud/server.test.mjs internal/patchpanel/model.test.mjs internal/patchpanel/runtime.test.mjs && node scripts/cloud-api-runtime-smoke.mjs && sh -n scripts/bootstrap-skills.sh scripts/worker-entrypoint.sh", + "check": "node --check internal/protocol/index.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/mvp-gate/summary.mjs && node --check internal/cloud/db-contract.mjs && node --check internal/cloud/json-rpc.mjs && node --check internal/cloud/code-agent-chat.mjs && node --check internal/cloud/server.mjs && node --check internal/sim/model.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-agent-mgr/main.mjs && node --check cmd/hwlab-agent-worker/main.mjs && node --check cmd/hwlab-box-simu/main.mjs && node --check cmd/hwlab-gateway-simu/main.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-desired-state-plan.mjs && node --check scripts/src/deploy-desired-state-plan.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/validate-dev-m3-cardinality.mjs && node --check scripts/validate-artifact-catalog.mjs && node --check scripts/refresh-artifact-catalog.mjs && node --check scripts/dev-artifact-publish.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/d601-k3s-readonly-observability.mjs && node --check scripts/src/d601-k3s-readonly-observability.mjs && node --check scripts/l2-runtime-contract-smoke.mjs && node --check scripts/patch-panel-runtime-smoke.mjs && node --check scripts/export-web-gate-summary.mjs && node --check scripts/l6-cli-web-smoke.mjs && node --check tools/hwlab-cli/bin/hwlab-cli.mjs && node --check tools/hwlab-cli/lib/cli.mjs && node --check web/hwlab-cloud-web/app.mjs && node --check web/hwlab-cloud-web/gate-summary.mjs && node --check web/hwlab-cloud-web/scripts/check.mjs && node --check web/hwlab-cloud-web/scripts/build.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-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 --test scripts/deploy-desired-state-plan.test.mjs scripts/src/dev-deploy-apply.test.mjs internal/agent/index.test.mjs internal/audit/index.test.mjs internal/db/schema.test.mjs internal/cloud/json-rpc.test.mjs internal/cloud/server.test.mjs internal/patchpanel/model.test.mjs internal/patchpanel/runtime.test.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", "cloud-api:smoke": "node scripts/cloud-api-runtime-smoke.mjs", "m1:smoke": "node scripts/m1-contract-smoke.mjs", diff --git a/scripts/code-agent-chat-smoke.mjs b/scripts/code-agent-chat-smoke.mjs new file mode 100644 index 00000000..16ab62fa --- /dev/null +++ b/scripts/code-agent-chat-smoke.mjs @@ -0,0 +1,80 @@ +#!/usr/bin/env node +import assert from "node:assert/strict"; + +import { + handleCodeAgentChat, + validateCodeAgentChatSchema +} from "../internal/cloud/code-agent-chat.mjs"; + +function logOk(name) { + process.stdout.write(`[code-agent-chat-smoke] ok ${name}\n`); +} + +const completed = await handleCodeAgentChat( + { + conversationId: "cnv_code-agent-chat-smoke", + traceId: "trc_code-agent-chat-smoke", + projectId: "prj_code-agent-chat-smoke", + message: "请用一句话说明当前 HWLAB 工作台可以做什么。" + }, + { + now: () => "2026-05-22T00:00:00.000Z", + callProvider: async ({ providerPlan }) => ({ + provider: providerPlan.provider, + model: providerPlan.model, + backend: providerPlan.backend, + content: "HWLAB 工作台可以用 Code Agent 对话来整理任务、查看资源并保持硬件变更受控。", + usage: null, + providerTrace: { + source: "schema-smoke-provider" + } + }) + } +); + +validateCodeAgentChatSchema(completed); +assert.equal(completed.conversationId, "cnv_code-agent-chat-smoke"); +assert.equal(completed.sessionId, "cnv_code-agent-chat-smoke"); +assert.match(completed.messageId, /^msg_/); +assert.equal(completed.status, "completed"); +assert.equal(completed.createdAt, "2026-05-22T00:00:00.000Z"); +assert.equal(completed.updatedAt, "2026-05-22T00:00:00.000Z"); +assert.equal(completed.traceId, "trc_code-agent-chat-smoke"); +assert.equal(completed.error, undefined); +assert.match(completed.reply.content, /Code Agent/); +logOk("completed schema"); + +const failed = await handleCodeAgentChat( + { + traceId: "trc_code-agent-chat-smoke-failed", + message: "你好" + }, + { + now: () => "2026-05-22T00:01:00.000Z", + env: { + PATH: "", + HWLAB_CODE_AGENT_PROVIDER: "codex-cli", + HWLAB_CODE_AGENT_MODEL: "gpt-test" + } + } +); + +validateCodeAgentChatSchema(failed); +assert.match(failed.conversationId, /^cnv_/); +assert.match(failed.sessionId, /^cnv_/); +assert.match(failed.messageId, /^msg_/); +assert.equal(failed.status, "failed"); +assert.equal(failed.createdAt, "2026-05-22T00:01:00.000Z"); +assert.equal(failed.updatedAt, "2026-05-22T00:01:00.000Z"); +assert.equal(failed.traceId, "trc_code-agent-chat-smoke-failed"); +assert.equal(failed.provider, "codex-cli"); +assert.equal(failed.model, "gpt-test"); +assert.equal(failed.backend, "hwlab-cloud-api/codex-cli"); +assert.equal(failed.error.code, "provider_unavailable"); +assert.match(failed.error.message, /Codex CLI command is not available/); +assert.deepEqual(failed.error.missingCommands, ["codex"]); +assert.ok(failed.error.missingEnv.includes("OPENAI_API_KEY")); +assert.equal(Object.hasOwn(failed, "reply"), false); +logOk("failed provider-gap schema"); + +process.stdout.write("[code-agent-chat-smoke] passed\n"); diff --git a/scripts/dev-artifact-publish.mjs b/scripts/dev-artifact-publish.mjs index 7320b77c..4d6f9bdd 100644 --- a/scripts/dev-artifact-publish.mjs +++ b/scripts/dev-artifact-publish.mjs @@ -460,7 +460,7 @@ async function serveCloudWeb() { if ( (request.method === "GET" && (url.pathname === "/v1" || url.pathname.startsWith("/v1/"))) || - (request.method === "POST" && url.pathname === "/json-rpc") + (request.method === "POST" && (url.pathname === "/json-rpc" || url.pathname === "/v1/agent/chat")) ) { await proxyCloudApi(request, response, url); return; @@ -700,6 +700,26 @@ async function buildService({ args, repo, commitId, shortCommit, service }) { }; } + if (service.runtimeKind === "cloud-web") { + const build = await run(process.execPath, ["web/hwlab-cloud-web/scripts/build.mjs"]); + if (build.code !== 0) { + return { + ...service, + image: ref, + imageTag: tag, + status: "build_failed", + digest: "not_published", + blocker: blocker({ + type: "environment_blocker", + scope: service.serviceId, + summary: "cloud web dist build failed before Docker build", + next: "Run node web/hwlab-cloud-web/scripts/build.mjs, fix the static asset build, then rerun --publish." + }), + logTail: tailText(`${build.stdout}\n${build.stderr}`) + }; + } + } + const port = servicePorts.get(service.serviceId) ?? 8080; const labels = [ ["org.opencontainers.image.source", "https://github.com/pikasTech/HWLAB"], diff --git a/scripts/src/dev-cloud-workbench-smoke-lib.mjs b/scripts/src/dev-cloud-workbench-smoke-lib.mjs index 80900dfd..f3460439 100644 --- a/scripts/src/dev-cloud-workbench-smoke-lib.mjs +++ b/scripts/src/dev-cloud-workbench-smoke-lib.mjs @@ -81,6 +81,7 @@ const workbenchMarkers = Object.freeze([ "resource-tree", "center-workspace", "conversation-list", + "agent-chat-status", "task-list", "right-sidebar", "hardware-list", @@ -198,7 +199,12 @@ function runStaticSmoke() { addCheck(checks, blockers, "same-origin-readonly-boundary", hasSameOriginReadOnlyBoundary(files.app), "Workbench data access stays same-origin and JSON-RPC diagnostics stay read-only.", { blocker: "safety_blocker", - evidence: ["/health/live", "/v1", "/json-rpc", ...readOnlyRpcMethods] + evidence: ["/health/live", "/v1", "/v1/agent/chat", "/json-rpc", ...readOnlyRpcMethods] + }); + + addCheck(checks, blockers, "code-agent-chat-primary-flow", hasCodeAgentChatContract(files), "Center Agent conversation sends to the controlled Code Agent chat endpoint and no longer uses 添加草稿 as the primary flow.", { + blocker: "runtime_blocker", + evidence: ["command-send", "agent-chat-status", "/v1/agent/chat", "conversationId/messageId/status/timestamps/error.message"] }); addCheck(checks, blockers, "no-hardware-write-api", noForbiddenWriteSurface(source), "Workbench source does not expose hardware write APIs or live mutation commands.", { @@ -446,12 +452,29 @@ function hasSameOriginReadOnlyBoundary(app) { return ( /fetchJson\("\/health\/live"\)/u.test(app) && /fetchJson\("\/v1"\)/u.test(app) && + /fetchJson\("\/v1\/agent\/chat"/u.test(app) && /fetchJson\("\/json-rpc"/u.test(app) && JSON.stringify(rpcCalls) === JSON.stringify([...readOnlyRpcMethods].sort()) && !/fetchJson\(\s*["']https?:\/\//u.test(app) ); } +function hasCodeAgentChatContract({ html, app }) { + return ( + /id=["']agent-chat-status["']/u.test(html) && + /id=["']command-send["']/u.test(html) && + />发送<\/button>/u.test(html) && + !/>添加草稿<\/button>/u.test(html) && + /placeholder=["']输入要发送给 Code Agent 的消息;不会直接触发硬件变更。["']/u.test(html) && + /fetchJson\("\/v1\/agent\/chat"/u.test(app) && + /conversationId/u.test(app) && + /messageId/u.test(app) && + /updatedAt/u.test(app) && + /error\?\.message/u.test(app) && + /Code Agent 调用失败/u.test(app) + ); +} + function noForbiddenWriteSurface(source) { return forbiddenWritePatterns.every((pattern) => !pattern.test(source)); } diff --git a/scripts/validate-contract.mjs b/scripts/validate-contract.mjs index e8f61a14..ea247c9b 100644 --- a/scripts/validate-contract.mjs +++ b/scripts/validate-contract.mjs @@ -153,5 +153,8 @@ assert.equal(cloudApi.env.HWLAB_IMAGE_TAG, deployManifest.commitId.slice(0, 7), assert.equal(cloudApi.env.HWLAB_CLOUD_DB_URL, "secretRef:hwlab-cloud-api-dev-db/database-url", "cloud-api DB URL must be a Secret reference placeholder"); assert.equal(cloudApi.env.HWLAB_CLOUD_DB_SSL_MODE, "require", "cloud-api DB SSL mode"); assert.equal(cloudApi.env.HWLAB_CLOUD_DB_CONTRACT, "dev-redacted-presence-only", "cloud-api DB contract marker"); +assert.equal(cloudApi.env.HWLAB_CODE_AGENT_PROVIDER, "openai", "cloud-api Code Agent provider"); +assert.equal(cloudApi.env.HWLAB_CODE_AGENT_MODEL, "gpt-5.5", "cloud-api Code Agent model"); +assert.equal(cloudApi.env.OPENAI_API_KEY, "secretRef:hwlab-code-agent-provider/openai-api-key", "cloud-api Code Agent OpenAI key must be a Secret reference placeholder"); console.log(`validated ${schemas.length + deploySchemas.length} JSON files and ${SERVICE_IDS.length} service ids`); diff --git a/web/hwlab-cloud-web/app.mjs b/web/hwlab-cloud-web/app.mjs index 788a38cc..b7c98e57 100644 --- a/web/hwlab-cloud-web/app.mjs +++ b/web/hwlab-cloud-web/app.mjs @@ -25,9 +25,11 @@ const STATUS_LABELS = Object.freeze({ open: "未关闭", pass: "通过", pending: "等待", + running: "处理中", probing: "探测中", ready: "就绪", recorded: "已记录", + sent: "已发送", requires: "依赖", source: "来源 SOURCE", "dry-run": "演练 DRY-RUN", @@ -49,6 +51,7 @@ const el = { routePath: byId("route-path"), liveStatus: byId("live-status"), liveDetail: byId("live-detail"), + agentChatStatus: byId("agent-chat-status"), conversationList: byId("conversation-list"), taskList: byId("task-list"), traceList: byId("trace-list"), @@ -60,6 +63,7 @@ const el = { validationList: byId("validation-list"), commandForm: byId("command-form"), commandInput: byId("command-input"), + commandSend: byId("command-send"), commandClear: byId("command-clear"), hardwareList: byId("hardware-list"), controlList: byId("control-list"), @@ -72,7 +76,9 @@ const el = { }; const state = { - drafts: [] + conversationId: null, + chatMessages: [], + chatPending: false }; initRoutes(); @@ -196,20 +202,85 @@ function selectSideTab(tabId) { } function initCommandBar() { - el.commandForm.addEventListener("submit", (event) => { + el.commandForm.addEventListener("submit", async (event) => { event.preventDefault(); const value = el.commandInput.value.trim(); - if (!value) return; - state.drafts.unshift({ + if (!value || state.chatPending) return; + const userMessage = { + id: nextProtocolId("msg"), + role: "user", + title: `用户 ${shortTime(new Date().toISOString())}`, text: value, + status: "sent", createdAt: new Date().toISOString() - }); + }; + const pendingMessage = { + id: nextProtocolId("msg"), + role: "agent", + title: "Code Agent 处理中", + text: "正在调用真实 Code Agent / Codex 后端,请稍候。", + status: "running", + createdAt: new Date().toISOString() + }; + state.chatMessages.push(userMessage, pendingMessage); + state.chatPending = true; el.commandInput.value = ""; + renderAgentChatStatus("running"); + renderConversation(); renderDrafts(); + + try { + const result = await sendAgentMessage(value, state.conversationId); + state.conversationId = result.conversationId || result.sessionId || state.conversationId; + const index = state.chatMessages.findIndex((message) => message.id === pendingMessage.id); + const status = result.status === "completed" ? "completed" : "failed"; + state.chatMessages[index] = { + ...pendingMessage, + title: result.status === "completed" ? sourceTitle(result) : "Code Agent 返回失败", + text: result.status === "completed" + ? result.reply?.content || "Code Agent 没有返回文本。" + : failureMessage(result), + status, + traceId: result.traceId, + messageId: result.messageId, + provider: result.provider, + model: result.model, + backend: result.backend, + updatedAt: result.updatedAt, + error: result.error + }; + if (result.status !== "completed") { + el.commandInput.value = value; + } + renderAgentChatStatus(status, result); + } catch (error) { + const index = state.chatMessages.findIndex((message) => message.id === pendingMessage.id); + state.chatMessages[index] = { + ...pendingMessage, + title: "发送失败", + text: `发送失败:${error.message}`, + status: "failed", + updatedAt: new Date().toISOString(), + error: { + message: error.message + } + }; + el.commandInput.value = value; + renderAgentChatStatus("failed"); + } finally { + state.chatPending = false; + renderConversation(); + renderDrafts(); + } }); el.commandClear.addEventListener("click", () => { - state.drafts = []; + state.chatMessages = []; + state.conversationId = null; + state.chatPending = false; + el.commandInput.value = ""; + renderAgentChatStatus("idle"); + renderConversation(); renderDrafts(); }); } @@ -240,6 +311,28 @@ function renderProbePending() { renderMethodList(rpcReadMethods, "pending"); } +async function sendAgentMessage(message, conversationId) { + const traceId = nextProtocolId("trc"); + const response = await fetchJson("/v1/agent/chat", { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Trace-Id": traceId + }, + body: JSON.stringify({ + message, + conversationId, + traceId, + projectId: gateSummary.topology.projectId + }) + }); + + if (!response.ok) { + throw new Error(response.error || "Code Agent 请求失败"); + } + return response.data; +} + async function loadLiveSurface() { const projectId = gateSummary.topology.projectId; const [healthLive, restIndex, health, adapter, audit, evidence] = await Promise.all([ @@ -440,24 +533,21 @@ function renderCapabilityList() { } function renderConversation() { - const messages = [ + const introMessages = [ { role: "system", title: "界面模式", - text: "这里是用户工作台:可以整理任务、查看资源、核对接线和可信记录。页面不会发送硬件变更。" + text: "这里是用户工作台:可以整理任务、查看资源、核对接线和可信记录。对话会发送到受控 Code Agent 后端;页面不会直接发送硬件变更。", + status: "source" }, { role: "agent", - title: "建议起步", - text: "先描述要检查的设备、端口或现象;我会把它保存成当前浏览器会话的任务草稿。" - }, - { - role: "user", - title: "可查看内容", - text: "左侧是项目资源和常用能力;右侧提供硬件状态、接线表和可信记录。技术复核内容在二级页面。" + title: "Code Agent", + text: "请在底部输入中文消息并点击发送,我会通过 cloud-api 调用真实 Code Agent / Codex 能力返回回复。", + status: "source" } ]; - replaceChildren(el.conversationList, ...messages.map(messageCard)); + replaceChildren(el.conversationList, ...[...introMessages, ...state.chatMessages].map(messageCard)); } function renderTaskList() { @@ -557,9 +647,11 @@ function controlRows() { tone: "source" }, { - title: "Agent 自动化", - detail: "输入区用于整理 Agent 任务草稿,不会直接调用 cloud-api 变更方法。", - tone: "source" + title: "Code Agent 对话", + detail: state.conversationId + ? `当前会话 ${state.conversationId};回复来自 cloud-api 受控 Code Agent 接口。` + : "输入区会调用 cloud-api 受控 Code Agent 接口,不会直接调用硬件写入方法。", + tone: state.conversationId ? "dev-live" : "source" }, { title: "只读探测", @@ -571,16 +663,16 @@ function controlRows() { function renderDrafts() { const rows = [ - ...state.drafts.map((draft) => ({ - title: `本地草稿 ${shortTime(draft.createdAt)}`, - detail: draft.text, - tone: "dry-run" + ...state.chatMessages.slice(-3).reverse().map((message) => ({ + title: `${roleLabel(message.role)} ${statusLabel(message.status)}`, + detail: message.text, + tone: message.status || "source" })), - ...(state.drafts.length === 0 + ...(state.chatMessages.length === 0 ? [ { - title: "草稿队列", - detail: "暂无本地草稿。输入区只在本地记录文本,不调用 cloud-api 变更方法。", + title: "对话状态", + detail: "暂无消息。输入区会发送到 Code Agent 后端,失败时会保留输入内容。", tone: "source" } ] @@ -763,6 +855,38 @@ function renderMethodList(methods, tone) { replaceChildren(el.methodList, ...methods.map((method) => badge(method, tone === "live" ? "dev-live" : tone))); } +function renderAgentChatStatus(status, result = null) { + const labels = { + idle: "等待输入", + running: "处理中", + completed: "已回复", + failed: "发送失败" + }; + el.agentChatStatus.textContent = labels[status] ?? statusLabel(status); + el.agentChatStatus.className = `state-tag tone-${toneClass(status === "completed" ? "dev-live" : status === "failed" ? "blocked" : "source")}`; + 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 sourceTitle(result) { + return `Code Agent 回复 ${shortTime(result.updatedAt ?? new Date().toISOString())}`; +} + +function failureMessage(result) { + 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 blockerCard(blocker) { return infoCard({ title: blockerTitle(blocker), @@ -773,10 +897,15 @@ function blockerCard(blocker) { function messageCard(message) { const article = document.createElement("article"); - article.className = `message-card message-${toneClass(message.role)}`; + 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(textSpan(message.text, "message-copy")); + const meta = [message.provider, message.model, message.backend, message.traceId].filter(Boolean).join(" / "); + const messageMeta = [message.messageId, meta].filter(Boolean).join(" / "); + if (messageMeta) { + article.append(textSpan(messageMeta, "message-meta")); + } return article; } diff --git a/web/hwlab-cloud-web/index.html b/web/hwlab-cloud-web/index.html index 9e515ba2..45d56554 100644 --- a/web/hwlab-cloud-web/index.html +++ b/web/hwlab-cloud-web/index.html @@ -68,7 +68,7 @@
Agent 工作区