feat: add Cloud Workbench Code Agent chat
Adds the Cloud Workbench Code Agent chat path and keeps provider/credential gaps explicit instead of faking replies.
This commit is contained in:
+4
-1
@@ -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"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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("'", "'\\''")}'`;
|
||||
}
|
||||
@@ -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, {
|
||||
|
||||
@@ -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()));
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
@@ -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"],
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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`);
|
||||
|
||||
+156
-27
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -68,7 +68,7 @@
|
||||
<p class="eyebrow">Agent 工作区</p>
|
||||
<h2 id="workspace-title">Agent 对话</h2>
|
||||
</div>
|
||||
<span class="state-tag tone-source">本地草稿</span>
|
||||
<span class="state-tag tone-source" id="agent-chat-status">等待输入</span>
|
||||
</div>
|
||||
<div class="conversation-list" id="conversation-list" aria-live="polite"></div>
|
||||
</div>
|
||||
@@ -196,10 +196,10 @@
|
||||
id="command-input"
|
||||
autocomplete="off"
|
||||
type="text"
|
||||
placeholder="在此编写 Agent 指令草稿;仅本地记录,不触发硬件变更。"
|
||||
placeholder="输入要发送给 Code Agent 的消息;不会直接触发硬件变更。"
|
||||
/>
|
||||
</div>
|
||||
<button class="command-button" type="submit">添加草稿</button>
|
||||
<button class="command-button" id="command-send" type="submit">发送</button>
|
||||
<button class="command-button secondary" id="command-clear" type="button">清空</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
@@ -86,9 +86,11 @@ for (const workbenchElement of [
|
||||
"explorer",
|
||||
"resource-tree",
|
||||
"conversation-list",
|
||||
"agent-chat-status",
|
||||
"task-list",
|
||||
"right-sidebar",
|
||||
"command-form",
|
||||
"command-send",
|
||||
"hardware-list",
|
||||
"control-list",
|
||||
"wiring-body",
|
||||
@@ -174,6 +176,19 @@ assert.match(html, /quick-actions/);
|
||||
assert.match(html, /编写任务/);
|
||||
assert.match(html, /查看接线/);
|
||||
assert.match(html, /查看状态/);
|
||||
assert.match(html, /placeholder="输入要发送给 Code Agent 的消息;不会直接触发硬件变更。"/);
|
||||
assert.match(html, />发送<\/button>/);
|
||||
assert.doesNotMatch(html, />添加草稿<\/button>/);
|
||||
assert.doesNotMatch(html, /主流程[\s\S]*添加草稿/u);
|
||||
assert.match(app, /sendAgentMessage/);
|
||||
assert.match(app, /fetchJson\("\/v1\/agent\/chat"/);
|
||||
assert.match(app, /Code Agent 调用失败/);
|
||||
assert.match(app, /state\.conversationId/);
|
||||
assert.match(app, /conversationId/);
|
||||
assert.match(app, /messageId/);
|
||||
assert.match(app, /createdAt/);
|
||||
assert.match(app, /updatedAt/);
|
||||
assert.match(app, /error\?\.message/);
|
||||
assert.doesNotMatch(html, /M3 Diagnostics Console/);
|
||||
assert.match(styles, /html,\s*\nbody\s*{[^}]*height:\s*100%;[^}]*overflow:\s*hidden;/s);
|
||||
assert.match(styles, /body\s*>\s*\[data-app-shell\]\s*{[^}]*min-height:\s*0;/s);
|
||||
@@ -191,6 +206,7 @@ assert.match(artifactPublisher, /readOnlyRpcMethods/);
|
||||
assert.match(artifactPublisher, /requestUpstream/);
|
||||
assert.match(artifactPublisher, /from "node:http"/);
|
||||
assert.doesNotMatch(artifactPublisher, /await fetch\(target/);
|
||||
assert.match(artifactPublisher, /url\.pathname === "\/v1\/agent\/chat"/);
|
||||
assert.match(artifactPublisher, /"system\.health"/);
|
||||
assert.match(artifactPublisher, /"cloud\.adapter\.describe"/);
|
||||
assert.match(artifactPublisher, /"audit\.event\.query"/);
|
||||
|
||||
@@ -466,6 +466,14 @@ h3 {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.message-card.status-running {
|
||||
border-color: rgba(223, 170, 85, 0.52);
|
||||
}
|
||||
|
||||
.message-card.status-failed {
|
||||
border-color: rgba(231, 110, 94, 0.58);
|
||||
}
|
||||
|
||||
.message-role {
|
||||
grid-row: span 2;
|
||||
color: var(--accent);
|
||||
@@ -482,6 +490,15 @@ h3 {
|
||||
|
||||
.message-copy {
|
||||
color: var(--muted);
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.message-meta {
|
||||
grid-column: 2;
|
||||
color: var(--dim);
|
||||
font-family: var(--mono);
|
||||
font-size: 10px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
@@ -632,6 +649,12 @@ h3 {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.command-button:disabled,
|
||||
.input-shell input:disabled {
|
||||
cursor: progress;
|
||||
opacity: 0.68;
|
||||
}
|
||||
|
||||
.command-button.secondary {
|
||||
border-color: var(--line-strong);
|
||||
background: var(--surface-2);
|
||||
|
||||
Reference in New Issue
Block a user