feat: add Workbench debug fake SSE harness
This commit is contained in:
@@ -70,6 +70,7 @@ import {
|
||||
startAgentRunProjectionResume
|
||||
} from "./server-code-agent-http.ts";
|
||||
import { drainWorkbenchRealtimeConnections, handleWorkbenchReadModelHttp, handleWorkbenchRealtimeHttp } from "./server-workbench-http.ts";
|
||||
import { handleWorkbenchDebugFakeSseHttp } from "./workbench-debug-fake-sse.ts";
|
||||
import { handleWorkbenchLaunchHttp } from "./server-workbench-launch-http.ts";
|
||||
import { startWorkbenchEmptySessionGc } from "./workbench-empty-session-gc.ts";
|
||||
import { handleM3IoControlHttp } from "./server-m3-http.ts";
|
||||
@@ -733,6 +734,11 @@ async function handleRestAdapter(request, response, url, options) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === "/v1/workbench/debug/fake-sse" || url.pathname.startsWith("/v1/workbench/debug/fake-sse/")) {
|
||||
await handleWorkbenchDebugFakeSseHttp(request, response, url, options);
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === "/v1/workbench/sync" || url.pathname === "/v1/workbench/sessions" || url.pathname.startsWith("/v1/workbench/sessions/") || url.pathname.startsWith("/v1/workbench/turns/") || url.pathname.startsWith("/v1/workbench/traces/")) {
|
||||
await handleWorkbenchReadModelHttp(request, response, url, options);
|
||||
return;
|
||||
@@ -964,6 +970,7 @@ function navIdForRestPath(pathname, method = "GET") {
|
||||
if (pathname === "/v1/project-management" || pathname.startsWith("/v1/project-management/")) return "project.mdtodo";
|
||||
if (pathname === "/v1/api-keys" || pathname === "/v1/api-keys/default" || pathname.startsWith("/v1/api-keys/")) return "user.apiKeys";
|
||||
if (pathname === "/v1/users/me/profile" || pathname === "/v1/users/me/password") return "system.settings";
|
||||
if (pathname === "/v1/workbench/debug/fake-sse" || pathname.startsWith("/v1/workbench/debug/fake-sse/")) return "workbench.debug";
|
||||
if (pathname === "/v1/workbench/events" || pathname === "/v1/workbench/sync" || pathname === "/v1/workbench/launches" || pathname === "/v1/workbench/sessions" || pathname.startsWith("/v1/workbench/sessions/") || pathname.startsWith("/v1/workbench/turns/") || pathname.startsWith("/v1/workbench/traces/")) return "workbench.code";
|
||||
if (pathname === "/v1/agent/chat" || pathname === "/v1/agent/sessions" || pathname.startsWith("/v1/agent/sessions/") || pathname === "/v1/agent/chat/inspect" || pathname.startsWith("/v1/agent/chat/result/") || pathname.startsWith("/v1/agent/turns/") || pathname.startsWith("/v1/agent/traces/") || pathname === "/v1/agent/chat/cancel" || pathname === "/v1/agent/chat/steer") return "workbench.code";
|
||||
if (pathname === "/v1/admin/provider-profiles" || pathname.startsWith("/v1/admin/provider-profiles/")) return "admin.providerProfiles";
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { createServer } from "node:http";
|
||||
import { test } from "bun:test";
|
||||
|
||||
import { handleWorkbenchDebugFakeSseHttp } from "./workbench-debug-fake-sse.ts";
|
||||
|
||||
test("workbench debug fake SSE API resets, steps, appends, runs, and streams typed events", async () => {
|
||||
const server = createServer((request, response) => {
|
||||
const url = new URL(request.url ?? "/", "http://127.0.0.1");
|
||||
void handleWorkbenchDebugFakeSseHttp(request, response, url, { logger: null });
|
||||
});
|
||||
await listen(server);
|
||||
const baseUrl = serverUrl(server);
|
||||
const queueId = "api-test";
|
||||
const abort = new AbortController();
|
||||
try {
|
||||
const initial = await jsonFetch(`${baseUrl}/v1/workbench/debug/fake-sse?queueId=${queueId}`);
|
||||
assert.equal(initial.ok, true);
|
||||
assert.equal(initial.queue.queueId, queueId);
|
||||
assert.equal(initial.queue.cursor, 0);
|
||||
assert.equal(initial.queue.remaining, 6);
|
||||
|
||||
const stream = await fetch(`${baseUrl}/v1/workbench/debug/fake-sse/events?queueId=${queueId}`, { signal: abort.signal });
|
||||
assert.equal(stream.status, 200);
|
||||
assert.match(stream.headers.get("content-type") ?? "", /text\/event-stream/u);
|
||||
const reader = stream.body?.getReader();
|
||||
assert.ok(reader, "SSE response must expose a readable stream");
|
||||
const connected = await readUntil(reader, "workbench.connected");
|
||||
assert.match(connected, /workbench\.connected/u);
|
||||
|
||||
const reset = await jsonFetch(`${baseUrl}/v1/workbench/debug/fake-sse/reset`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ queueId, sequenceId: "trace-card-basic" })
|
||||
});
|
||||
assert.equal(reset.ok, true);
|
||||
assert.equal(reset.queue.remaining, 6);
|
||||
|
||||
const next = await jsonFetch(`${baseUrl}/v1/workbench/debug/fake-sse/next`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ queueId })
|
||||
});
|
||||
assert.equal(next.ok, true);
|
||||
assert.equal(next.delivered.eventName, "workbench.message.snapshot");
|
||||
assert.equal(next.delivered.deliveredTo, 1);
|
||||
assert.equal(next.queue.cursor, 1);
|
||||
const firstEvent = await readUntil(reader, "workbench.message.snapshot");
|
||||
assert.match(firstEvent, /workbench-realtime-authority-v2/u);
|
||||
assert.match(firstEvent, /msg_debug_agent/u);
|
||||
|
||||
const append = await jsonFetch(`${baseUrl}/v1/workbench/debug/fake-sse/append`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
queueId,
|
||||
events: [{
|
||||
type: "trace.event",
|
||||
sessionId: "ses_debug_fake_sse",
|
||||
threadId: "thr_debug_fake_sse",
|
||||
traceId: "trc_debug_fake_sse",
|
||||
event: {
|
||||
traceId: "trc_debug_fake_sse",
|
||||
projectedSeq: 99,
|
||||
createdAt: "2026-07-09T08:00:09.000Z",
|
||||
label: "assistant:message",
|
||||
type: "assistant_message",
|
||||
status: "running",
|
||||
message: "appended from api test"
|
||||
}
|
||||
}]
|
||||
})
|
||||
});
|
||||
assert.equal(append.ok, true);
|
||||
assert.equal(append.appended, 1);
|
||||
assert.equal(append.queue.eventCount, 7);
|
||||
assert.equal(append.queue.remaining, 6);
|
||||
|
||||
const run = await jsonFetch(`${baseUrl}/v1/workbench/debug/fake-sse/run`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ queueId })
|
||||
});
|
||||
assert.equal(run.ok, true);
|
||||
assert.equal(run.delivered.count, 6);
|
||||
assert.equal(run.queue.remaining, 0);
|
||||
const runStream = await readUntil(reader, "appended from api test");
|
||||
assert.match(runStream, /workbench\.trace\.event/u);
|
||||
} finally {
|
||||
abort.abort();
|
||||
await close(server);
|
||||
}
|
||||
});
|
||||
|
||||
async function jsonFetch(url: string, init: RequestInit = {}) {
|
||||
const response = await fetch(url, {
|
||||
...init,
|
||||
headers: {
|
||||
accept: "application/json",
|
||||
...(init.body ? { "content-type": "application/json" } : {}),
|
||||
...(init.headers ?? {})
|
||||
}
|
||||
});
|
||||
const body = await response.json();
|
||||
assert.equal(response.ok, true, JSON.stringify(body));
|
||||
return body as any;
|
||||
}
|
||||
|
||||
async function readUntil(reader: ReadableStreamDefaultReader<Uint8Array>, pattern: string): Promise<string> {
|
||||
const decoder = new TextDecoder();
|
||||
let text = "";
|
||||
const deadline = Date.now() + 3000;
|
||||
while (!text.includes(pattern)) {
|
||||
if (Date.now() > deadline) throw new Error(`SSE stream did not include ${pattern}: ${text}`);
|
||||
const next = await reader.read();
|
||||
if (next.done) break;
|
||||
text += decoder.decode(next.value, { stream: true });
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
async function listen(server: ReturnType<typeof createServer>) {
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
}
|
||||
|
||||
async function close(server: ReturnType<typeof createServer>) {
|
||||
await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
|
||||
}
|
||||
|
||||
function serverUrl(server: ReturnType<typeof createServer>): string {
|
||||
const address = server.address();
|
||||
assert.equal(typeof address, "object");
|
||||
assert.ok(address && typeof address.port === "number");
|
||||
return `http://127.0.0.1:${address.port}`;
|
||||
}
|
||||
@@ -0,0 +1,438 @@
|
||||
/*
|
||||
* SPEC: PJ2026-010401080313 Workbench实时权威 draft-2026-07-09-p1-single-step-debug.
|
||||
* Responsibility: debug-only in-memory fake Workbench SSE queues for Cloud Web single-step reducer/UI inspection.
|
||||
*/
|
||||
import { readBody, sendJson } from "./server-http-utils.ts";
|
||||
|
||||
const CONTRACT_VERSION = "workbench-debug-fake-sse-v1";
|
||||
const EVENT_CONTRACT_VERSION = "workbench-events-v1";
|
||||
const REALTIME_AUTHORITY = "workbench-realtime-authority-v2";
|
||||
const DEFAULT_QUEUE_ID = "trace-card";
|
||||
const BODY_LIMIT_BYTES = 256 * 1024;
|
||||
|
||||
const queues = new Map();
|
||||
const clientsByQueueId = new Map();
|
||||
|
||||
export async function handleWorkbenchDebugFakeSseHttp(request, response, url, options = {}) {
|
||||
const route = routeSuffix(url.pathname);
|
||||
try {
|
||||
if (route === "" || route === "/sequences") {
|
||||
if (request.method !== "GET") return methodNotAllowed(response, "GET");
|
||||
return sendJson(response, 200, describePayload(queueIdFromUrl(url)));
|
||||
}
|
||||
if (route === "/events") {
|
||||
if (request.method !== "GET") return methodNotAllowed(response, "GET");
|
||||
return openDebugSse(request, response, url);
|
||||
}
|
||||
if (route === "/reset") {
|
||||
if (request.method !== "POST") return methodNotAllowed(response, "POST");
|
||||
const body = await readJsonObject(request);
|
||||
if (!body.ok) return sendJson(response, 400, debugError("invalid_json", body.message));
|
||||
const queue = resetQueue(queueIdFromBodyOrUrl(body.value, url), String(body.value.sequenceId ?? "trace-card-basic"));
|
||||
return sendJson(response, 200, { ok: true, contractVersion: CONTRACT_VERSION, queue: describeQueue(queue), sequences: describeSequences() });
|
||||
}
|
||||
if (route === "/append") {
|
||||
if (request.method !== "POST") return methodNotAllowed(response, "POST");
|
||||
const body = await readJsonObject(request);
|
||||
if (!body.ok) return sendJson(response, 400, debugError("invalid_json", body.message));
|
||||
const queue = ensureQueue(queueIdFromBodyOrUrl(body.value, url));
|
||||
const events = appendEventsFromBody(body.value);
|
||||
if (events.length === 0) return sendJson(response, 400, debugError("fake_sse_events_required", "events must contain at least one object."));
|
||||
queue.events.push(...events.map((event, index) => normalizeRealtimeEvent(event, queue, queue.events.length + index + 1)));
|
||||
queue.updatedAt = new Date().toISOString();
|
||||
return sendJson(response, 200, { ok: true, contractVersion: CONTRACT_VERSION, appended: events.length, queue: describeQueue(queue) });
|
||||
}
|
||||
if (route === "/next") {
|
||||
if (request.method !== "POST") return methodNotAllowed(response, "POST");
|
||||
const body = await readOptionalJsonObject(request);
|
||||
const queue = ensureQueue(queueIdFromBodyOrUrl(body.value ?? {}, url));
|
||||
const delivered = deliverNext(queue);
|
||||
return sendJson(response, 200, { ok: true, contractVersion: CONTRACT_VERSION, delivered, queue: describeQueue(queue) });
|
||||
}
|
||||
if (route === "/run") {
|
||||
if (request.method !== "POST") return methodNotAllowed(response, "POST");
|
||||
const body = await readOptionalJsonObject(request);
|
||||
const queue = ensureQueue(queueIdFromBodyOrUrl(body.value ?? {}, url));
|
||||
const delivered = deliverAll(queue);
|
||||
return sendJson(response, 200, { ok: true, contractVersion: CONTRACT_VERSION, delivered, queue: describeQueue(queue) });
|
||||
}
|
||||
return sendJson(response, 404, debugError("workbench_debug_fake_sse_route_not_found", "Workbench debug fake SSE route is not implemented.", { route }));
|
||||
} catch (error) {
|
||||
options.logger?.warn?.({
|
||||
event: "workbench_debug_fake_sse_failed",
|
||||
route,
|
||||
errorName: error?.name ?? "Error",
|
||||
message: error instanceof Error ? error.message : String(error ?? "unknown"),
|
||||
valuesRedacted: true
|
||||
});
|
||||
return sendJson(response, 500, debugError("workbench_debug_fake_sse_failed", "Workbench debug fake SSE request failed."));
|
||||
}
|
||||
}
|
||||
|
||||
function openDebugSse(request, response, url) {
|
||||
const queue = ensureQueue(queueIdFromUrl(url));
|
||||
response.writeHead(200, {
|
||||
"content-type": "text/event-stream; charset=utf-8",
|
||||
"cache-control": "no-store, no-transform",
|
||||
connection: "keep-alive",
|
||||
"x-accel-buffering": "no",
|
||||
"x-content-type-options": "nosniff"
|
||||
});
|
||||
if (typeof response.flushHeaders === "function") response.flushHeaders();
|
||||
const clients = clientsForQueue(queue.queueId);
|
||||
clients.add(response);
|
||||
writeSse(response, "workbench.connected", {
|
||||
type: "connected",
|
||||
contractVersion: EVENT_CONTRACT_VERSION,
|
||||
realtimeAuthority: REALTIME_AUTHORITY,
|
||||
queue: describeQueue(queue),
|
||||
serverSentAt: new Date().toISOString()
|
||||
});
|
||||
request.on("close", () => clients.delete(response));
|
||||
}
|
||||
|
||||
function deliverNext(queue) {
|
||||
const event = queue.events[queue.cursor] ?? null;
|
||||
if (!event) return { ok: false, reason: "queue-empty", deliveredTo: 0 };
|
||||
queue.cursor += 1;
|
||||
queue.updatedAt = new Date().toISOString();
|
||||
return deliverEvent(queue.queueId, event);
|
||||
}
|
||||
|
||||
function deliverAll(queue) {
|
||||
const results = [];
|
||||
while (queue.cursor < queue.events.length) results.push(deliverNext(queue));
|
||||
return { ok: true, count: results.length, deliveredTo: results.reduce((sum, item) => sum + Number(item.deliveredTo ?? 0), 0) };
|
||||
}
|
||||
|
||||
function deliverEvent(queueId, event) {
|
||||
const clients = clientsForQueue(queueId);
|
||||
const eventName = eventNameFor(event);
|
||||
let deliveredTo = 0;
|
||||
for (const response of [...clients]) {
|
||||
if (response.destroyed || response.writableEnded) {
|
||||
clients.delete(response);
|
||||
continue;
|
||||
}
|
||||
if (writeSse(response, eventName, event)) deliveredTo += 1;
|
||||
}
|
||||
return { ok: true, eventName, eventType: event.type ?? null, traceId: event.traceId ?? null, deliveredTo };
|
||||
}
|
||||
|
||||
function writeSse(response, eventName, payload) {
|
||||
if (response.destroyed || response.writableEnded) return false;
|
||||
try {
|
||||
response.write(`event: ${eventName}\n`);
|
||||
const eventId = sseEventId(payload);
|
||||
if (eventId) response.write(`id: ${eventId}\n`);
|
||||
response.write(`data: ${JSON.stringify({ serverSentAt: new Date().toISOString(), ...payload })}\n\n`);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function ensureQueue(queueId = DEFAULT_QUEUE_ID) {
|
||||
const id = safeQueueId(queueId);
|
||||
const existing = queues.get(id);
|
||||
if (existing) return existing;
|
||||
return resetQueue(id, "trace-card-basic");
|
||||
}
|
||||
|
||||
function resetQueue(queueId = DEFAULT_QUEUE_ID, sequenceId = "trace-card-basic") {
|
||||
const id = safeQueueId(queueId);
|
||||
const sequence = builtinSequences().find((item) => item.sequenceId === sequenceId) ?? builtinSequences()[0];
|
||||
const now = new Date().toISOString();
|
||||
const queue = {
|
||||
queueId: id,
|
||||
sequenceId: sequence.sequenceId,
|
||||
cursor: 0,
|
||||
events: sequence.events.map((event, index) => normalizeRealtimeEvent(event, { queueId: id }, index + 1)),
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
};
|
||||
queues.set(id, queue);
|
||||
return queue;
|
||||
}
|
||||
|
||||
function normalizeRealtimeEvent(event, queue, fallbackVersion) {
|
||||
const source = recordValue(event) ?? {};
|
||||
const sessionId = textValue(source.sessionId) || "ses_debug_fake_sse";
|
||||
const threadId = textValue(source.threadId) || "thr_debug_fake_sse";
|
||||
const traceId = textValue(source.traceId) || "trc_debug_fake_sse";
|
||||
const type = textValue(source.type) || "trace.event";
|
||||
const entity = recordValue(source.entity) ?? defaultEntity(type, source, fallbackVersion);
|
||||
return {
|
||||
sessionId,
|
||||
threadId,
|
||||
traceId,
|
||||
...source,
|
||||
contractVersion: EVENT_CONTRACT_VERSION,
|
||||
realtimeAuthority: REALTIME_AUTHORITY,
|
||||
sessionId,
|
||||
threadId,
|
||||
traceId,
|
||||
type,
|
||||
entity,
|
||||
cursor: recordValue(source.cursor) ?? {
|
||||
traceSeq: finiteNumber(source.traceSeq ?? source.event?.projectedSeq ?? fallbackVersion),
|
||||
outboxSeq: finiteNumber(source.outboxSeq ?? fallbackVersion)
|
||||
},
|
||||
debug: {
|
||||
source: "workbench-debug-fake-sse",
|
||||
queueId: queue.queueId,
|
||||
valuesRedacted: true
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function defaultEntity(type, source, version) {
|
||||
const traceId = textValue(source.traceId) || "trc_debug_fake_sse";
|
||||
const messageId = textValue(source.message?.messageId ?? source.message?.id) || "msg_debug_agent";
|
||||
const family = type === "message.snapshot" ? "messages" : type === "turn.snapshot" ? "turns" : "traceEvents";
|
||||
const id = family === "messages" ? messageId : family === "turns" ? textValue(source.turn?.turnId ?? source.turn?.traceId) || traceId : `${traceId}:${version}`;
|
||||
return {
|
||||
family,
|
||||
id,
|
||||
version,
|
||||
outboxSeq: version,
|
||||
traceSeq: version,
|
||||
projectionRevision: `debug-${version}`,
|
||||
authority: REALTIME_AUTHORITY
|
||||
};
|
||||
}
|
||||
|
||||
function builtinSequences() {
|
||||
const sessionId = "ses_debug_fake_sse";
|
||||
const threadId = "thr_debug_fake_sse";
|
||||
const traceId = "trc_debug_fake_sse";
|
||||
const t0 = "2026-07-09T08:00:00.000Z";
|
||||
const events = [
|
||||
traceEvent({ projectedSeq: 1, createdAt: "2026-07-09T08:00:01.000Z", label: "assistant:message", type: "assistant_message", status: "running", message: "读取 Workbench 组件和 Trace 渲染上下文。", elapsedMs: 1000 }),
|
||||
traceEvent({ projectedSeq: 2, createdAt: "2026-07-09T08:00:03.000Z", label: "agentrun:tool:completed", type: "tool_call", status: "completed", toolName: "commandExecution", itemId: "tool_debug_rg", command: "rg -n \"TraceTimeline|message-card\" web/hwlab-cloud-web/src", stdoutSummary: "TraceTimeline.vue\\nConversationPanel.vue\\nWorkbenchMessageCard.vue", exitCode: 0, elapsedMs: 3000 }),
|
||||
traceEvent({ projectedSeq: 3, createdAt: "2026-07-09T08:00:05.000Z", label: "assistant:message", type: "assistant_message", status: "running", message: "Trace 卡片已收到 tool 事件,继续等待 terminal snapshot。", elapsedMs: 5000 }),
|
||||
traceEvent({ projectedSeq: 4, createdAt: "2026-07-09T08:00:07.000Z", label: "assistant:completed", type: "assistant_message", status: "completed", message: "Fake SSE Trace 卡片渲染完成。", final: true, terminal: true, replyAuthority: true, elapsedMs: 7000 }),
|
||||
traceEvent({ projectedSeq: 5, createdAt: "2026-07-09T08:00:07.500Z", label: "turn:completed", type: "completion", status: "completed", terminal: true, elapsedMs: 7500 })
|
||||
];
|
||||
return [
|
||||
{
|
||||
sequenceId: "trace-card-basic",
|
||||
label: "Trace card basic",
|
||||
eventCount: 6,
|
||||
events: [
|
||||
{
|
||||
type: "message.snapshot",
|
||||
sessionId,
|
||||
threadId,
|
||||
traceId,
|
||||
message: agentMessage({ sessionId, threadId, traceId, status: "running", createdAt: t0, events: [], eventCount: 0 })
|
||||
},
|
||||
...events.slice(0, 3).map((event, index) => ({
|
||||
type: "trace.event",
|
||||
sessionId,
|
||||
threadId,
|
||||
traceId,
|
||||
event,
|
||||
snapshot: traceSnapshot({ sessionId, threadId, traceId, status: "running", events: events.slice(0, index + 1), eventCount: index + 1, startedAt: t0 })
|
||||
})),
|
||||
{
|
||||
type: "trace.event",
|
||||
sessionId,
|
||||
threadId,
|
||||
traceId,
|
||||
event: events[3],
|
||||
snapshot: traceSnapshot({ sessionId, threadId, traceId, status: "completed", events: events.slice(0, 4), eventCount: 4, startedAt: t0, finishedAt: "2026-07-09T08:00:07.000Z", durationMs: 7000, finalText: "Fake SSE Trace 卡片渲染完成。" })
|
||||
},
|
||||
{
|
||||
type: "message.snapshot",
|
||||
sessionId,
|
||||
threadId,
|
||||
traceId,
|
||||
message: agentMessage({ sessionId, threadId, traceId, status: "completed", createdAt: t0, updatedAt: "2026-07-09T08:00:07.000Z", text: "Fake SSE Trace 卡片渲染完成。", events, eventCount: events.length, finishedAt: "2026-07-09T08:00:07.000Z", durationMs: 7000, finalText: "Fake SSE Trace 卡片渲染完成。" })
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
function traceEvent(input) {
|
||||
return { traceId: "trc_debug_fake_sse", source: "debug-fake-sse", sourceSeq: input.projectedSeq, ...input };
|
||||
}
|
||||
|
||||
function agentMessage(input) {
|
||||
return {
|
||||
id: "msg_debug_agent",
|
||||
messageId: "msg_debug_agent",
|
||||
role: "agent",
|
||||
title: "Code Agent",
|
||||
text: input.text ?? "",
|
||||
status: input.status,
|
||||
createdAt: input.createdAt,
|
||||
updatedAt: input.updatedAt ?? input.createdAt,
|
||||
sessionId: input.sessionId,
|
||||
threadId: input.threadId,
|
||||
traceId: input.traceId,
|
||||
turnId: input.traceId,
|
||||
timing: {
|
||||
startedAt: input.createdAt,
|
||||
lastEventAt: input.updatedAt ?? input.createdAt,
|
||||
finishedAt: input.finishedAt ?? null,
|
||||
durationMs: input.durationMs ?? null,
|
||||
valuesRedacted: true
|
||||
},
|
||||
runnerTrace: traceSnapshot({
|
||||
sessionId: input.sessionId,
|
||||
threadId: input.threadId,
|
||||
traceId: input.traceId,
|
||||
status: input.status,
|
||||
events: input.events,
|
||||
eventCount: input.eventCount,
|
||||
startedAt: input.createdAt,
|
||||
lastEventAt: input.updatedAt ?? input.createdAt,
|
||||
finishedAt: input.finishedAt ?? null,
|
||||
durationMs: input.durationMs ?? null,
|
||||
finalText: input.finalText
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
function traceSnapshot(input) {
|
||||
return {
|
||||
traceId: input.traceId,
|
||||
sessionId: input.sessionId,
|
||||
threadId: input.threadId,
|
||||
status: input.status,
|
||||
events: input.events,
|
||||
eventCount: input.eventCount ?? input.events?.length ?? 0,
|
||||
fullTraceLoaded: input.status === "completed",
|
||||
hasMore: false,
|
||||
startedAt: input.startedAt,
|
||||
lastEventAt: input.lastEventAt ?? input.events?.at(-1)?.createdAt ?? input.startedAt,
|
||||
finishedAt: input.finishedAt ?? null,
|
||||
durationMs: input.durationMs ?? null,
|
||||
timing: {
|
||||
startedAt: input.startedAt,
|
||||
lastEventAt: input.lastEventAt ?? input.events?.at(-1)?.createdAt ?? input.startedAt,
|
||||
finishedAt: input.finishedAt ?? null,
|
||||
durationMs: input.durationMs ?? null,
|
||||
valuesRedacted: true
|
||||
},
|
||||
finalResponse: input.finalText ? { text: input.finalText, valuesRedacted: true } : undefined,
|
||||
projection: {
|
||||
projectionStatus: "caught-up",
|
||||
projectionHealth: "caught-up",
|
||||
lastProjectedSeq: input.eventCount ?? input.events?.length ?? 0,
|
||||
updatedAt: input.lastEventAt ?? input.events?.at(-1)?.createdAt ?? input.startedAt,
|
||||
valuesRedacted: true
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function appendEventsFromBody(body) {
|
||||
const raw = Array.isArray(body.events) ? body.events : body.event ? [body.event] : [];
|
||||
return raw.filter((event) => event && typeof event === "object" && !Array.isArray(event));
|
||||
}
|
||||
|
||||
async function readJsonObject(request) {
|
||||
const body = await readBody(request, BODY_LIMIT_BYTES);
|
||||
try {
|
||||
const value = body ? JSON.parse(body) : {};
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return { ok: false, message: "body must be a JSON object" };
|
||||
return { ok: true, value };
|
||||
} catch (error) {
|
||||
return { ok: false, message: error instanceof Error ? error.message : "Invalid JSON body" };
|
||||
}
|
||||
}
|
||||
|
||||
async function readOptionalJsonObject(request) {
|
||||
if (Number(request.headers?.["content-length"] ?? 0) <= 0) return { ok: true, value: {} };
|
||||
return readJsonObject(request);
|
||||
}
|
||||
|
||||
function describePayload(queueId) {
|
||||
return { ok: true, contractVersion: CONTRACT_VERSION, sequences: describeSequences(), queue: describeQueue(ensureQueue(queueId)) };
|
||||
}
|
||||
|
||||
function describeSequences() {
|
||||
return builtinSequences().map((sequence) => ({ sequenceId: sequence.sequenceId, label: sequence.label, eventCount: sequence.events.length }));
|
||||
}
|
||||
|
||||
function describeQueue(queue) {
|
||||
return {
|
||||
queueId: queue.queueId,
|
||||
sequenceId: queue.sequenceId,
|
||||
cursor: queue.cursor,
|
||||
eventCount: queue.events.length,
|
||||
remaining: Math.max(0, queue.events.length - queue.cursor),
|
||||
connectedClients: clientsForQueue(queue.queueId).size,
|
||||
updatedAt: queue.updatedAt
|
||||
};
|
||||
}
|
||||
|
||||
function clientsForQueue(queueId) {
|
||||
const id = safeQueueId(queueId);
|
||||
let clients = clientsByQueueId.get(id);
|
||||
if (!clients) {
|
||||
clients = new Set();
|
||||
clientsByQueueId.set(id, clients);
|
||||
}
|
||||
return clients;
|
||||
}
|
||||
|
||||
function queueIdFromBodyOrUrl(body, url) {
|
||||
return safeQueueId(body.queueId ?? url.searchParams.get("queueId"));
|
||||
}
|
||||
|
||||
function queueIdFromUrl(url) {
|
||||
return safeQueueId(url.searchParams.get("queueId"));
|
||||
}
|
||||
|
||||
function safeQueueId(value) {
|
||||
const text = String(value ?? "").trim();
|
||||
return /^[A-Za-z0-9_.:-]{3,80}$/u.test(text) ? text : DEFAULT_QUEUE_ID;
|
||||
}
|
||||
|
||||
function eventNameFor(event) {
|
||||
switch (event?.type) {
|
||||
case "trace.snapshot": return "workbench.trace.snapshot";
|
||||
case "trace.event": return "workbench.trace.event";
|
||||
case "message.snapshot": return "workbench.message.snapshot";
|
||||
case "turn.snapshot": return "workbench.turn.snapshot";
|
||||
case "trace.unavailable": return "workbench.trace.unavailable";
|
||||
case "error": return "workbench.error";
|
||||
default: return "message";
|
||||
}
|
||||
}
|
||||
|
||||
function sseEventId(payload) {
|
||||
const raw = payload?.cursor?.outboxSeq ?? payload?.outboxSeq ?? payload?.entity?.outboxSeq;
|
||||
const text = textValue(raw);
|
||||
if (!text || /[\r\n]/u.test(text)) return null;
|
||||
return text.slice(0, 128);
|
||||
}
|
||||
|
||||
function routeSuffix(pathname) {
|
||||
return pathname.replace(/^\/v1\/workbench\/debug\/fake-sse/u, "");
|
||||
}
|
||||
|
||||
function methodNotAllowed(response, allowed) {
|
||||
return sendJson(response, 405, debugError("method_not_allowed", `Use ${allowed} for this Workbench debug route.`));
|
||||
}
|
||||
|
||||
function debugError(code, message, extra = {}) {
|
||||
return { ok: false, contractVersion: CONTRACT_VERSION, error: { code, message, layer: "workbench-debug-fake-sse", valuesRedacted: true }, ...extra };
|
||||
}
|
||||
|
||||
function recordValue(value) {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? value : null;
|
||||
}
|
||||
|
||||
function textValue(value) {
|
||||
const text = String(value ?? "").trim();
|
||||
return text || null;
|
||||
}
|
||||
|
||||
function finiteNumber(value) {
|
||||
const number = Number(value);
|
||||
return Number.isFinite(number) && number >= 0 ? Math.trunc(number) : null;
|
||||
}
|
||||
@@ -106,6 +106,8 @@ const runtimeConfigScript = `<script>\nwindow.HWLAB_CLOUD_WEB_CONFIG = {\n ...(
|
||||
|
||||
let state = createScenarioState("baseline");
|
||||
const sseClients = new Set<ServerResponse>();
|
||||
const debugSseClients = new Set<ServerResponse>();
|
||||
let debugFakeSseQueue = createDebugFakeSseQueue();
|
||||
|
||||
const server = createServer((request, response) => {
|
||||
void handleRequest(request, response).catch((error) => {
|
||||
@@ -151,6 +153,7 @@ async function handleRequest(request: IncomingMessage, response: ServerResponse)
|
||||
}
|
||||
if (path === "/auth/login" && method === "POST") return authLoginResponse(response);
|
||||
if (path === "/v1/workbench/events" && method === "GET") return sse(request, response, url);
|
||||
if (path === "/v1/workbench/debug/fake-sse" || path.startsWith("/v1/workbench/debug/fake-sse/")) return debugFakeSse(request, response, url, method);
|
||||
if (path === "/v1/workbench/launches" && method === "POST") return json(response, 201, await createWorkbenchLaunch(request));
|
||||
if (path === "/v1/workbench/sessions" && method === "GET") {
|
||||
await delay(state.sessionDelayMs);
|
||||
@@ -237,6 +240,10 @@ async function handleRequest(request: IncomingMessage, response: ServerResponse)
|
||||
if (path === "/v1/hwpod/specs") return json(response, 200, hwpodSpecsPayload());
|
||||
if (path === "/v1/hwpod-node-ops") return json(response, 200, hwpodNodeOpsPayload());
|
||||
if (path.startsWith("/v1/project-management")) return projectManagementPayload(request, response, url, method);
|
||||
if (path === "/v1/web-performance" && method === "POST") {
|
||||
const body = await readJson(request);
|
||||
return json(response, 202, { accepted: true, recorded: Array.isArray(body.events) ? body.events.length : 0, valuesRedacted: true });
|
||||
}
|
||||
if (path === "/v1/web-performance/summary") {
|
||||
if (state.scenarioId === "performance-summary-error") return errorDiagnosticResponse(response, 502, "/v1/web-performance/summary", "upstream_unavailable", "暂时无法连接上游。");
|
||||
const matrixStatus = performanceSummaryErrorStatus(state.scenarioId);
|
||||
@@ -2339,6 +2346,112 @@ function sse(request: IncomingMessage, response: ServerResponse, url: URL): void
|
||||
}, state.scenarioId === "progress-only-final-response" ? 5_000 : state.scenarioId === "event-replay" ? 1_500 : 350);
|
||||
}
|
||||
|
||||
async function debugFakeSse(request: IncomingMessage, response: ServerResponse, url: URL, method: string): Promise<void> {
|
||||
const suffix = url.pathname.replace(/^\/v1\/workbench\/debug\/fake-sse/u, "");
|
||||
if (suffix === "" || suffix === "/sequences") return json(response, 200, debugFakeSsePayload());
|
||||
if (suffix === "/events") {
|
||||
response.writeHead(200, { "content-type": "text/event-stream; charset=utf-8", "cache-control": "no-cache", connection: "keep-alive" });
|
||||
debugSseClients.add(response);
|
||||
request.on("close", () => debugSseClients.delete(response));
|
||||
return writeSse(response, "workbench.connected", { type: "connected", queue: debugFakeSseQueueSummary() });
|
||||
}
|
||||
if (method !== "POST") return json(response, 405, { ok: false, error: { code: "method_not_allowed" } });
|
||||
if (suffix === "/reset") {
|
||||
debugFakeSseQueue = createDebugFakeSseQueue();
|
||||
return json(response, 200, debugFakeSsePayload());
|
||||
}
|
||||
if (suffix === "/append") {
|
||||
const body = await readJson(request);
|
||||
const events = Array.isArray(body.events) ? body.events.filter((event): event is JsonRecord => Boolean(event && typeof event === "object" && !Array.isArray(event))) : [];
|
||||
debugFakeSseQueue.events.push(...events.map((event, index) => debugRealtimeEvent(event, debugFakeSseQueue.events.length + index + 1)));
|
||||
return json(response, 200, { ok: true, appended: events.length, queue: debugFakeSseQueueSummary() });
|
||||
}
|
||||
if (suffix === "/next") return json(response, 200, { ok: true, delivered: debugDeliverNext(), queue: debugFakeSseQueueSummary() });
|
||||
if (suffix === "/run") {
|
||||
let count = 0;
|
||||
while (debugFakeSseQueue.cursor < debugFakeSseQueue.events.length) {
|
||||
debugDeliverNext();
|
||||
count += 1;
|
||||
}
|
||||
return json(response, 200, { ok: true, delivered: { ok: true, count }, queue: debugFakeSseQueueSummary() });
|
||||
}
|
||||
return json(response, 404, { ok: false, error: { code: "debug_fake_sse_route_not_found" } });
|
||||
}
|
||||
|
||||
function debugDeliverNext(): JsonRecord {
|
||||
const event = debugFakeSseQueue.events[debugFakeSseQueue.cursor] as JsonRecord | undefined;
|
||||
if (!event) return { ok: false, reason: "queue-empty", deliveredTo: 0 };
|
||||
debugFakeSseQueue.cursor += 1;
|
||||
const eventName = debugEventName(event);
|
||||
for (const client of Array.from(debugSseClients)) writeSse(client, eventName, event);
|
||||
return { ok: true, eventName, eventType: event.type, traceId: event.traceId, deliveredTo: debugSseClients.size };
|
||||
}
|
||||
|
||||
function debugFakeSsePayload(): JsonRecord {
|
||||
return { ok: true, contractVersion: "workbench-debug-fake-sse-v1", sequences: [{ sequenceId: "trace-card-basic", label: "Trace card basic", eventCount: debugFakeSseQueue.events.length }], queue: debugFakeSseQueueSummary() };
|
||||
}
|
||||
|
||||
function debugFakeSseQueueSummary(): JsonRecord {
|
||||
return { queueId: "trace-card", sequenceId: "trace-card-basic", cursor: debugFakeSseQueue.cursor, eventCount: debugFakeSseQueue.events.length, remaining: Math.max(0, debugFakeSseQueue.events.length - debugFakeSseQueue.cursor), connectedClients: debugSseClients.size, updatedAt: new Date().toISOString() };
|
||||
}
|
||||
|
||||
function createDebugFakeSseQueue(): { cursor: number; events: JsonRecord[] } {
|
||||
const sessionId = "ses_debug_fake_sse";
|
||||
const threadId = "thr_debug_fake_sse";
|
||||
const traceId = "trc_debug_fake_sse";
|
||||
const finalText = "Fake SSE Trace 卡片渲染完成。";
|
||||
const events = [
|
||||
{ traceId, projectedSeq: 1, createdAt: "2026-07-09T08:00:01.000Z", label: "assistant:message", type: "assistant_message", status: "running", message: "读取 Workbench 组件和 Trace 渲染上下文。", elapsedMs: 1000 },
|
||||
{ traceId, projectedSeq: 2, createdAt: "2026-07-09T08:00:03.000Z", label: "agentrun:tool:completed", type: "tool_call", status: "completed", toolName: "commandExecution", itemId: "tool_debug_rg", command: "rg -n \"TraceTimeline|message-card\" web/hwlab-cloud-web/src", stdoutSummary: "TraceTimeline.vue\\nConversationPanel.vue\\nWorkbenchMessageCard.vue", exitCode: 0, elapsedMs: 3000 },
|
||||
{ traceId, projectedSeq: 3, createdAt: "2026-07-09T08:00:05.000Z", label: "assistant:message", type: "assistant_message", status: "running", message: "Trace 卡片已收到 tool 事件,继续等待 terminal snapshot。", elapsedMs: 5000 },
|
||||
{ traceId, projectedSeq: 4, createdAt: "2026-07-09T08:00:07.000Z", label: "assistant:completed", type: "assistant_message", status: "completed", message: finalText, final: true, terminal: true, replyAuthority: true, elapsedMs: 7000 },
|
||||
{ traceId, projectedSeq: 5, createdAt: "2026-07-09T08:00:07.500Z", label: "turn:completed", type: "completion", status: "completed", terminal: true, elapsedMs: 7500 }
|
||||
];
|
||||
return {
|
||||
cursor: 0,
|
||||
events: [
|
||||
debugRealtimeEvent({ type: "message.snapshot", sessionId, threadId, traceId, message: debugAgentMessage(sessionId, threadId, traceId, "running", []) }, 1),
|
||||
...events.slice(0, 3).map((event, index) => debugRealtimeEvent({ type: "trace.event", sessionId, threadId, traceId, event, snapshot: debugTraceSnapshot(sessionId, threadId, traceId, "running", events.slice(0, index + 1)) }, index + 2)),
|
||||
debugRealtimeEvent({ type: "trace.event", sessionId, threadId, traceId, event: events[3], snapshot: debugTraceSnapshot(sessionId, threadId, traceId, "completed", events.slice(0, 4)) }, 5),
|
||||
debugRealtimeEvent({ type: "message.snapshot", sessionId, threadId, traceId, message: debugAgentMessage(sessionId, threadId, traceId, "completed", events) }, 6)
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
function debugRealtimeEvent(event: JsonRecord, version: number): JsonRecord {
|
||||
const type = String(event.type ?? "trace.event");
|
||||
const traceId = String(event.traceId ?? "trc_debug_fake_sse");
|
||||
const message = event.message && typeof event.message === "object" ? event.message as JsonRecord : null;
|
||||
return {
|
||||
contractVersion: "workbench-events-v1",
|
||||
realtimeAuthority: "workbench-realtime-authority-v2",
|
||||
sessionId: event.sessionId ?? "ses_debug_fake_sse",
|
||||
threadId: event.threadId ?? "thr_debug_fake_sse",
|
||||
traceId,
|
||||
...event,
|
||||
type,
|
||||
entity: event.entity ?? { family: type === "message.snapshot" ? "messages" : "traceEvents", id: type === "message.snapshot" ? message?.messageId ?? "msg_debug_agent" : `${traceId}:${version}`, version, projectionRevision: `debug-${version}`, authority: "workbench-realtime-authority-v2" },
|
||||
cursor: event.cursor ?? { traceSeq: version, outboxSeq: version }
|
||||
};
|
||||
}
|
||||
|
||||
function debugAgentMessage(sessionId: string, threadId: string, traceId: string, status: string, events: JsonRecord[]): JsonRecord {
|
||||
const finalText = "Fake SSE Trace 卡片渲染完成。";
|
||||
return { id: "msg_debug_agent", messageId: "msg_debug_agent", role: "agent", title: "Code Agent", text: status === "completed" ? finalText : "", status, createdAt: "2026-07-09T08:00:00.000Z", updatedAt: "2026-07-09T08:00:07.000Z", sessionId, threadId, traceId, turnId: traceId, runnerTrace: debugTraceSnapshot(sessionId, threadId, traceId, status, events) };
|
||||
}
|
||||
|
||||
function debugTraceSnapshot(sessionId: string, threadId: string, traceId: string, status: string, events: JsonRecord[]): JsonRecord {
|
||||
const finalText = "Fake SSE Trace 卡片渲染完成。";
|
||||
return { traceId, sessionId, threadId, status, events, eventCount: events.length, fullTraceLoaded: status === "completed", hasMore: false, startedAt: "2026-07-09T08:00:00.000Z", lastEventAt: events.at(-1)?.createdAt ?? "2026-07-09T08:00:00.000Z", finishedAt: status === "completed" ? "2026-07-09T08:00:07.000Z" : null, durationMs: status === "completed" ? 7000 : null, finalResponse: status === "completed" ? { text: finalText, status: "completed" } : undefined };
|
||||
}
|
||||
|
||||
function debugEventName(event: JsonRecord): string {
|
||||
if (event.type === "message.snapshot") return "workbench.message.snapshot";
|
||||
if (event.type === "trace.snapshot") return "workbench.trace.snapshot";
|
||||
if (event.type === "turn.snapshot") return "workbench.turn.snapshot";
|
||||
return event.type === "error" ? "workbench.error" : "workbench.trace.event";
|
||||
}
|
||||
|
||||
function broadcastSse(eventName: string, payload: JsonRecord): void {
|
||||
for (const client of Array.from(sseClients)) writeSse(client, eventName, payload);
|
||||
}
|
||||
@@ -2426,6 +2539,7 @@ function recordLedger(method: string, url: URL): void {
|
||||
|
||||
function isLegacyWorkbenchPath(path: string): boolean {
|
||||
if (/^\/v1\/workbench\/(sessions(?:\/[^/]+(?:\/messages)?)?|events|turns\/[^/]+|traces\/[^/]+\/events)$/u.test(path)) return false;
|
||||
if (path === "/v1/workbench/debug/fake-sse" || path.startsWith("/v1/workbench/debug/fake-sse/")) return false;
|
||||
if (path === "/v1/agent/chat" || /^\/v1\/agent\/sessions(?:\/[^/]+(?:\/select)?)?$/u.test(path)) return false;
|
||||
return path.startsWith("/v1/workbench/") || path.startsWith("/v1/agent/");
|
||||
}
|
||||
@@ -2441,7 +2555,9 @@ function authPayload(): JsonRecord {
|
||||
|
||||
function authLoginResponse(response: ServerResponse): void {
|
||||
if (state.scenarioId === "auth-upstream-unavailable") return json(response, 502, { ok: false, status: "failed", error: { code: "upstream_unavailable", message: "auth upstream unavailable", retryable: true, layer: "proxy" } });
|
||||
return json(response, 401, { ok: false, status: "failed", error: { code: "invalid_credentials", message: "invalid credentials" } });
|
||||
if (state.scenarioId === "auth-invalid-credentials") return json(response, 401, { ok: false, status: "failed", error: { code: "invalid_credentials", message: "invalid credentials" } });
|
||||
response.setHeader("Set-Cookie", "hwlab_session=authenticated; Path=/; HttpOnly; SameSite=Lax");
|
||||
return json(response, 200, authPayload());
|
||||
}
|
||||
|
||||
function performanceSummaryErrorStatus(scenarioId: string): number | null {
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
export { fetchJson, fetchText, type ActivityRef, type ActivityRefSource, type ApiRequestOptions } from "./client";
|
||||
export { authAPI, HWLAB_WEB_SESSION_COOKIE } from "./auth";
|
||||
export { workbenchAPI } from "./workbench";
|
||||
export { workbenchDebugAPI, connectWorkbenchDebugFakeSse, type WorkbenchDebugFakeSseQueue, type WorkbenchDebugFakeSseResponse, type WorkbenchDebugFakeSseSequence, type WorkbenchDebugFakeSseStream } from "./workbench-debug";
|
||||
export { connectWorkbenchEvents, type WorkbenchEventStream, type WorkbenchRealtimeEvent } from "./workbench-events";
|
||||
export { agentAPI } from "./agent";
|
||||
export { hwpodAPI } from "./hwpod";
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
// SPEC: PJ2026-010401080313 Workbench实时权威 draft-2026-07-09-p1-single-step-debug.
|
||||
// Responsibility: Cloud Web client for Workbench debug-only fake SSE queues.
|
||||
|
||||
import { fetchJson, type ApiRequestOptions } from "@/api/client";
|
||||
import type { ApiResult } from "@/types";
|
||||
import type { WorkbenchRealtimeEvent } from "./workbench-events";
|
||||
|
||||
export interface WorkbenchDebugFakeSseSequence {
|
||||
sequenceId: string;
|
||||
label: string;
|
||||
eventCount: number;
|
||||
}
|
||||
|
||||
export interface WorkbenchDebugFakeSseQueue {
|
||||
queueId: string;
|
||||
sequenceId: string;
|
||||
cursor: number;
|
||||
eventCount: number;
|
||||
remaining: number;
|
||||
connectedClients: number;
|
||||
updatedAt?: string | null;
|
||||
}
|
||||
|
||||
export interface WorkbenchDebugFakeSseResponse {
|
||||
ok?: boolean;
|
||||
contractVersion?: string;
|
||||
sequences?: WorkbenchDebugFakeSseSequence[];
|
||||
queue?: WorkbenchDebugFakeSseQueue;
|
||||
appended?: number;
|
||||
delivered?: {
|
||||
ok?: boolean;
|
||||
count?: number;
|
||||
reason?: string;
|
||||
eventName?: string;
|
||||
eventType?: string | null;
|
||||
traceId?: string | null;
|
||||
deliveredTo?: number;
|
||||
};
|
||||
error?: { code?: string; message?: string; [key: string]: unknown };
|
||||
}
|
||||
|
||||
export interface WorkbenchDebugFakeSseStreamOptions {
|
||||
queueId: string;
|
||||
onEvent: (event: WorkbenchRealtimeEvent, eventName: string) => void;
|
||||
onOpen?: () => void;
|
||||
onError?: (event: Event) => void;
|
||||
}
|
||||
|
||||
export interface WorkbenchDebugFakeSseStream {
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
const DEBUG_FAKE_SSE_EVENTS = [
|
||||
"workbench.connected",
|
||||
"workbench.trace.snapshot",
|
||||
"workbench.trace.event",
|
||||
"workbench.trace.unavailable",
|
||||
"workbench.message.snapshot",
|
||||
"workbench.turn.snapshot",
|
||||
"workbench.error"
|
||||
];
|
||||
|
||||
export const workbenchDebugAPI = {
|
||||
describe: (queueId = "trace-card", options: ApiRequestOptions = {}): Promise<ApiResult<WorkbenchDebugFakeSseResponse>> => fetchJson(debugPath("", { queueId }), { ...options, timeoutName: "workbench debug fake sse" }),
|
||||
reset: (input: { queueId: string; sequenceId: string }, options: ApiRequestOptions = {}): Promise<ApiResult<WorkbenchDebugFakeSseResponse>> => fetchJson(debugPath("/reset"), { ...options, method: "POST", body: JSON.stringify(input), timeoutName: "workbench debug fake sse reset" }),
|
||||
append: (input: { queueId: string; events: WorkbenchRealtimeEvent[] }, options: ApiRequestOptions = {}): Promise<ApiResult<WorkbenchDebugFakeSseResponse>> => fetchJson(debugPath("/append"), { ...options, method: "POST", body: JSON.stringify(input), timeoutName: "workbench debug fake sse append" }),
|
||||
next: (input: { queueId: string }, options: ApiRequestOptions = {}): Promise<ApiResult<WorkbenchDebugFakeSseResponse>> => fetchJson(debugPath("/next"), { ...options, method: "POST", body: JSON.stringify(input), timeoutName: "workbench debug fake sse next" }),
|
||||
run: (input: { queueId: string }, options: ApiRequestOptions = {}): Promise<ApiResult<WorkbenchDebugFakeSseResponse>> => fetchJson(debugPath("/run"), { ...options, method: "POST", body: JSON.stringify(input), timeoutName: "workbench debug fake sse run" })
|
||||
};
|
||||
|
||||
export function connectWorkbenchDebugFakeSse(options: WorkbenchDebugFakeSseStreamOptions): WorkbenchDebugFakeSseStream | null {
|
||||
if (typeof EventSource === "undefined") return null;
|
||||
const source = new EventSource(debugPath("/events", { queueId: options.queueId }), { withCredentials: true });
|
||||
source.onopen = () => options.onOpen?.();
|
||||
source.onerror = (event) => options.onError?.(event);
|
||||
const listeners = DEBUG_FAKE_SSE_EVENTS.map((name) => {
|
||||
const listener = (event: MessageEvent) => {
|
||||
const payload = parseRealtimeEvent(event.data);
|
||||
if (payload) options.onEvent(payload, name);
|
||||
};
|
||||
source.addEventListener(name, listener);
|
||||
return { name, listener };
|
||||
});
|
||||
source.onmessage = (event) => {
|
||||
const payload = parseRealtimeEvent(event.data);
|
||||
if (payload) options.onEvent(payload, "message");
|
||||
};
|
||||
return {
|
||||
close() {
|
||||
for (const { name, listener } of listeners) source.removeEventListener(name, listener);
|
||||
source.close();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function debugPath(suffix: string, params: Record<string, string | null | undefined> = {}): string {
|
||||
const query = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
const text = typeof value === "string" ? value.trim() : "";
|
||||
if (text) query.set(key, text);
|
||||
}
|
||||
const qs = query.toString();
|
||||
return `/v1/workbench/debug/fake-sse${suffix}${qs ? `?${qs}` : ""}`;
|
||||
}
|
||||
|
||||
function parseRealtimeEvent(raw: string): WorkbenchRealtimeEvent | null {
|
||||
try {
|
||||
const value = JSON.parse(raw);
|
||||
return value && typeof value === "object" ? value as WorkbenchRealtimeEvent : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -32,7 +32,7 @@ interface NavSection {
|
||||
}
|
||||
|
||||
const navSections = [
|
||||
{ title: "工作台", items: [{ name: "CodeWorkbench", label: "Code", path: "/workbench", icon: "C", navId: "workbench.code" }, { name: "OpenCode", label: "OpenCode", path: "/opencode", icon: "O", navId: "opencode.root" }, { name: "Dashboard", label: "概览", path: "/dashboard", icon: "D", navId: "user.dashboard" }] },
|
||||
{ title: "工作台", items: [{ name: "CodeWorkbench", label: "Code", path: "/workbench", icon: "C", navId: "workbench.code" }, { name: "WorkbenchDebug", label: "调试", path: "/workbench/debug", icon: "T", navId: "workbench.debug" }, { name: "OpenCode", label: "OpenCode", path: "/opencode", icon: "O", navId: "opencode.root" }, { name: "Dashboard", label: "概览", path: "/dashboard", icon: "D", navId: "user.dashboard" }] },
|
||||
{ title: "项目", items: [{ name: "Projects", label: "项目", path: "/projects", icon: "P", navId: "project.overview" }, { name: "ProjectMdtodo", label: "MDTODO", path: "/projects/mdtodo", icon: "M", navId: "project.mdtodo" }] },
|
||||
{ title: "用户", items: [{ name: "ApiKeys", label: "API Keys", path: "/api-keys", icon: "K", navId: "user.apiKeys" }, { name: "Usage", label: "用量", path: "/usage", icon: "U", navId: "user.usage" }, { name: "Billing", label: "充值", path: "/billing", icon: "$", navId: "user.billing" }] },
|
||||
{ title: "管理", items: [{ name: "Access", label: "授权", path: "/admin/access", icon: "A", navId: "admin.access" }, { name: "AdminSecrets", label: "密钥", path: "/admin/secrets", icon: "K", navId: "admin.secrets" }, { name: "Users", label: "用户", path: "/admin/users", icon: "P", navId: "admin.users" }, { name: "AdminBilling", label: "账务", path: "/admin/billing", icon: "B", navId: "admin.billing" }, { name: "HwpodGroups", label: "HWPOD", path: "/admin/hwpod-groups", icon: "H", navId: "admin.hwpodGroups" }, { name: "ProviderProfiles", label: "Profiles", path: "/admin/provider-profiles", icon: "R", navId: "admin.providerProfiles" }] },
|
||||
|
||||
@@ -6,22 +6,17 @@ import { computed, nextTick, onMounted, ref, watch } from "vue";
|
||||
import type { ChatMessage } from "@/types";
|
||||
import ApiErrorDiagnostic from "@/components/common/ApiErrorDiagnostic.vue";
|
||||
import LoadingState from "@/components/common/LoadingState.vue";
|
||||
import StatusBadge from "@/components/common/StatusBadge.vue";
|
||||
import TraceTimeline from "@/components/agent/TraceTimeline.vue";
|
||||
import CodeAgentStatusSummary from "@/components/workbench/CodeAgentStatusSummary.vue";
|
||||
import MessageActions from "@/components/workbench/MessageActions.vue";
|
||||
import MessageMarkdown from "@/components/workbench/MessageMarkdown.vue";
|
||||
import MessageTraceDebugPanel from "@/components/workbench/MessageTraceDebugPanel.vue";
|
||||
import { workbenchTraceTimelinePolicy } from "@/config/runtime";
|
||||
import WorkbenchMessageCard from "@/components/workbench/WorkbenchMessageCard.vue";
|
||||
import { useWorkbenchBottomFollowScroll } from "@/composables/useWorkbenchScrollRuntime";
|
||||
import { useClipboard } from "@/composables/useClipboard";
|
||||
import { useWorkbenchNowTicker } from "@/composables/useWorkbenchNowTicker";
|
||||
import { useWorkbenchStore } from "@/stores/workbench";
|
||||
import { buildWorkbenchTimelineRows, workbenchTimelineSignature } from "@/stores/workbench-timeline-model";
|
||||
import { messageDiagnosticView } from "@/utils/workbench-error-runtime";
|
||||
import { acknowledgeWorkbenchVisibleAfterPaint } from "@/utils/workbench-performance";
|
||||
import { traceIdentityText } from "./message-rendering";
|
||||
import { traceLifecycleExpanded } from "./trace-lifecycle";
|
||||
|
||||
const workbench = useWorkbenchStore();
|
||||
const clipboard = useClipboard();
|
||||
@@ -31,10 +26,7 @@ const { following, keepBottomAfterUpdate, onScroll: onPanelScroll, scrollToBotto
|
||||
const detailMessageId = ref<string | null>(null);
|
||||
const visibleMessages = computed(() => workbench.sessionDetailLoading ? [] : workbench.activeMessages);
|
||||
const visibleTimelineRows = computed(() => buildWorkbenchTimelineRows(visibleMessages.value));
|
||||
const hasRunningAgentMessages = computed(() => visibleMessages.value.some(isRunningMessage));
|
||||
const { nowMs } = useWorkbenchNowTicker(hasRunningAgentMessages);
|
||||
const detailMessage = computed(() => workbench.activeMessages.find((message) => message.id === detailMessageId.value) ?? null);
|
||||
const traceTimelinePolicy = computed(() => workbenchTraceTimelinePolicy());
|
||||
const scrollSignature = computed(() => workbenchTimelineSignature(visibleTimelineRows.value));
|
||||
|
||||
watch(scrollSignature, async () => {
|
||||
@@ -57,33 +49,6 @@ function openDetails(message: ChatMessage): void {
|
||||
detailMessageId.value = message.id;
|
||||
}
|
||||
|
||||
function isRunningMessage(message: ChatMessage): boolean {
|
||||
return message.role === "agent" && ["pending", "running"].includes(String(message.status ?? "").toLowerCase());
|
||||
}
|
||||
|
||||
function isTerminalAgentMessage(message: ChatMessage): boolean {
|
||||
if (message.role !== "agent") return false;
|
||||
return ["completed", "failed", "blocked", "timeout", "canceled", "cancelled", "stale", "thread-resume-failed"].includes(String(message.status ?? "").trim().toLowerCase().replace(/_/gu, "-"));
|
||||
}
|
||||
|
||||
function visibleMessageText(message: ChatMessage): string {
|
||||
const text = String(message.text ?? "").trim();
|
||||
if (message.role === "agent" && !isTerminalAgentMessage(message)) return "";
|
||||
return text;
|
||||
}
|
||||
|
||||
function showStatusBadge(message: ChatMessage): boolean {
|
||||
return message.role !== "agent" || !isRunningMessage(message);
|
||||
}
|
||||
|
||||
function isAwaitingAgentBody(message: ChatMessage): boolean {
|
||||
return isRunningMessage(message) && !message.runnerTrace && !visibleMessageText(message);
|
||||
}
|
||||
|
||||
function showMessageText(message: ChatMessage): boolean {
|
||||
return Boolean(visibleMessageText(message));
|
||||
}
|
||||
|
||||
function messageHasDiagnostic(message: ChatMessage): boolean {
|
||||
return diagnosticFor(message).visible;
|
||||
}
|
||||
@@ -95,79 +60,6 @@ function diagnosticFor(message: ChatMessage) {
|
||||
function acknowledgeVisibleMessages(): void {
|
||||
acknowledgeWorkbenchVisibleAfterPaint({ messages: workbench.activeMessages, activeSessionId: workbench.activeSessionId, detailLoading: workbench.sessionDetailLoading });
|
||||
}
|
||||
|
||||
function traceStorageKey(message: ChatMessage): string {
|
||||
return `hwlab.workbench.trace-open.${message.traceId ?? message.runnerTrace?.traceId ?? message.id}`;
|
||||
}
|
||||
|
||||
function traceAutoExpanded(message: ChatMessage): boolean | null {
|
||||
return traceLifecycleExpanded(message, traceTimelinePolicy.value);
|
||||
}
|
||||
|
||||
function traceForDisplay(message: ChatMessage): ChatMessage["runnerTrace"] {
|
||||
return message.runnerTrace;
|
||||
}
|
||||
|
||||
function messageDurationMeta(message: ChatMessage): { text: string; label: string } | null {
|
||||
if (message.role !== "agent") return null;
|
||||
const timing = messageTimingForDisplay(message);
|
||||
const durationMs = isRunningMessage(message) ? durationSince(timing?.startedAt) : terminalMessageDurationMs(timing);
|
||||
if (durationMs === null) return null;
|
||||
const value = formatDuration(durationMs);
|
||||
return { text: `耗时 ${value}`, label: `总耗时:${value}` };
|
||||
}
|
||||
|
||||
function messageActivityMeta(message: ChatMessage): { text: string; label: string } | null {
|
||||
if (!isRunningMessage(message)) return null;
|
||||
const timing = messageTimingForDisplay(message);
|
||||
const ageMs = durationSince(timing?.lastEventAt);
|
||||
if (ageMs === null) return null;
|
||||
const value = formatDuration(ageMs) + "前";
|
||||
return { text: `最近 ${value}`, label: `最近事件:${value}` };
|
||||
}
|
||||
|
||||
function messageTimingForDisplay(message: ChatMessage): ChatMessage["timing"] {
|
||||
// The main message card consumes only the durable message timing projection.
|
||||
// Trace timing belongs to TraceTimeline detail/history reads; letting those
|
||||
// reads drive the card timer makes running turns jump when a later trace
|
||||
// snapshot carries an older startedAt/lastEventAt window.
|
||||
return message.timing ?? null;
|
||||
}
|
||||
|
||||
function terminalMessageDurationMs(timing: ChatMessage["timing"]): number | null {
|
||||
const durationMs = finiteDurationMs(timing?.durationMs);
|
||||
if (durationMs === 0 && timing?.finishedAt) return 1000;
|
||||
return durationMs;
|
||||
}
|
||||
|
||||
function durationSince(timestamp: unknown): number | null {
|
||||
const startedAtMs = timestampMs(timestamp);
|
||||
return startedAtMs === null ? null : Math.max(0, nowMs.value - startedAtMs);
|
||||
}
|
||||
|
||||
function timestampMs(value: unknown): number | null {
|
||||
if (typeof value !== "string") return null;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return null;
|
||||
const parsed = Date.parse(trimmed);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
function finiteDurationMs(value: unknown): number | null {
|
||||
const number = Number(value);
|
||||
return Number.isFinite(number) && number >= 0 ? Math.trunc(number) : null;
|
||||
}
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
const seconds = ms > 0 && ms < 1000 ? 1 : Math.max(0, Math.floor(ms / 1000));
|
||||
if (seconds < 60) return `${seconds} 秒`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) return `${minutes} 分 ${seconds % 60} 秒`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours} 小时 ${minutes % 60} 分`;
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days} 天 ${hours % 24} 小时`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -180,33 +72,7 @@ function formatDuration(ms: number): string {
|
||||
</div>
|
||||
<div v-else-if="row.type === 'Retry'" class="timeline-row timeline-retry" :data-session-id="row.sessionId || undefined" :data-trace-id="row.traceId || undefined">等待重试</div>
|
||||
<div v-else-if="row.type === 'Error'" class="timeline-row timeline-error" :data-session-id="row.sessionId || undefined" :data-trace-id="row.traceId || undefined">{{ row.text }}</div>
|
||||
<article
|
||||
v-else-if="row.message"
|
||||
class="message-card"
|
||||
:data-role="row.message.role"
|
||||
:data-status="row.message.status"
|
||||
:data-session-id="row.message.sessionId || undefined"
|
||||
:data-message-id="row.message.id || undefined"
|
||||
:data-trace-id="row.message.traceId || row.message.runnerTrace?.traceId || undefined"
|
||||
:data-turn-id="row.message.turnId || row.message.traceId || row.message.runnerTrace?.traceId || undefined"
|
||||
>
|
||||
<header v-if="row.message.role !== 'user'">
|
||||
<div class="message-header-main">
|
||||
<strong>{{ row.message.title }}</strong>
|
||||
<span v-if="messageDurationMeta(row.message)" class="message-timing-meta message-duration-meta" :aria-label="messageDurationMeta(row.message)?.label" :title="messageDurationMeta(row.message)?.label">{{ messageDurationMeta(row.message)?.text }}</span>
|
||||
</div>
|
||||
<div class="message-header-actions">
|
||||
<span v-if="messageActivityMeta(row.message)" class="message-activity-meta" :aria-label="messageActivityMeta(row.message)?.label" :title="messageActivityMeta(row.message)?.label">{{ messageActivityMeta(row.message)?.text }}</span>
|
||||
<span v-if="isRunningMessage(row.message)" class="agent-running-indicator" aria-label="运行中" role="status"><span aria-hidden="true" /></span>
|
||||
<StatusBadge v-if="showStatusBadge(row.message)" :status="row.message.status" />
|
||||
<button v-if="row.message.role === 'agent'" class="message-detail-button" type="button" aria-label="运行详情" title="运行详情" @click="openDetails(row.message)">!</button>
|
||||
</div>
|
||||
</header>
|
||||
<TraceTimeline v-if="row.message.role === 'agent' && row.message.runnerTrace" :trace="traceForDisplay(row.message)" :auto-expanded="traceAutoExpanded(row.message)" :storage-key="traceStorageKey(row.message)" />
|
||||
<LoadingState v-if="isAwaitingAgentBody(row.message)" class="message-loading" label="思考中..." compact />
|
||||
<MessageMarkdown v-if="showMessageText(row.message)" class="message-text" :source="visibleMessageText(row.message)" />
|
||||
<ApiErrorDiagnostic v-if="messageHasDiagnostic(row.message)" class="message-diagnostic projection-diagnostic" :error="diagnosticFor(row.message).text" :api-error="diagnosticFor(row.message).apiError" :diagnostic="diagnosticFor(row.message).diagnostic" compact />
|
||||
</article>
|
||||
<WorkbenchMessageCard v-else-if="row.message" :message="row.message" @details="openDetails" />
|
||||
</template>
|
||||
<div v-if="!workbench.sessionDetailLoading && workbench.activeMessages.length === 0 && workbench.error" class="conversation-empty-hint conversation-error-hint">加载失败:{{ workbench.error }}</div>
|
||||
<div v-else-if="!workbench.sessionDetailLoading && workbench.activeMessages.length === 0" class="conversation-empty-hint">发起对话,或从左侧选择 session。</div>
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
<script setup lang="ts">
|
||||
// SPEC: PJ2026-010401080313 Workbench实时权威 draft-2026-07-09-p1-single-step-debug; PJ2026-0106050514 Workbench实时运行面 draft-2026-06-30-p0-1297-spec-first.
|
||||
// Responsibility: shared Workbench message card and TraceTimeline rendering used by production Workbench and debug harnesses.
|
||||
|
||||
import { computed } from "vue";
|
||||
import type { ChatMessage } from "@/types";
|
||||
import ApiErrorDiagnostic from "@/components/common/ApiErrorDiagnostic.vue";
|
||||
import LoadingState from "@/components/common/LoadingState.vue";
|
||||
import StatusBadge from "@/components/common/StatusBadge.vue";
|
||||
import TraceTimeline from "@/components/agent/TraceTimeline.vue";
|
||||
import { useWorkbenchNowTicker } from "@/composables/useWorkbenchNowTicker";
|
||||
import { workbenchTraceTimelinePolicy } from "@/config/runtime";
|
||||
import { messageDiagnosticView } from "@/utils/workbench-error-runtime";
|
||||
import MessageMarkdown from "./MessageMarkdown.vue";
|
||||
import { traceLifecycleExpanded } from "./trace-lifecycle";
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
message: ChatMessage;
|
||||
detailEnabled?: boolean;
|
||||
traceAutoExpanded?: boolean | null;
|
||||
storageKeyPrefix?: string;
|
||||
}>(), {
|
||||
detailEnabled: true,
|
||||
traceAutoExpanded: null,
|
||||
storageKeyPrefix: "hwlab.workbench.trace-open"
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
details: [message: ChatMessage];
|
||||
}>();
|
||||
|
||||
const running = computed(() => isRunningMessage(props.message));
|
||||
const { nowMs } = useWorkbenchNowTicker(running);
|
||||
const traceTimelinePolicy = computed(() => workbenchTraceTimelinePolicy());
|
||||
const diagnostic = computed(() => messageDiagnosticView(props.message));
|
||||
const visibleText = computed(() => visibleMessageText(props.message));
|
||||
const durationMeta = computed(() => messageDurationMeta(props.message));
|
||||
const activityMeta = computed(() => messageActivityMeta(props.message));
|
||||
const autoExpanded = computed(() => props.traceAutoExpanded ?? traceLifecycleExpanded(props.message, traceTimelinePolicy.value));
|
||||
|
||||
function isRunningMessage(message: ChatMessage): boolean {
|
||||
return message.role === "agent" && ["pending", "running"].includes(String(message.status ?? "").toLowerCase());
|
||||
}
|
||||
|
||||
function isTerminalAgentMessage(message: ChatMessage): boolean {
|
||||
if (message.role !== "agent") return false;
|
||||
return ["completed", "failed", "blocked", "timeout", "canceled", "cancelled", "stale", "thread-resume-failed"].includes(String(message.status ?? "").trim().toLowerCase().replace(/_/gu, "-"));
|
||||
}
|
||||
|
||||
function visibleMessageText(message: ChatMessage): string {
|
||||
const text = String(message.text ?? "").trim();
|
||||
if (message.role === "agent" && !isTerminalAgentMessage(message)) return "";
|
||||
return text;
|
||||
}
|
||||
|
||||
function showStatusBadge(message: ChatMessage): boolean {
|
||||
return message.role !== "agent" || !isRunningMessage(message);
|
||||
}
|
||||
|
||||
function isAwaitingAgentBody(message: ChatMessage): boolean {
|
||||
return isRunningMessage(message) && !message.runnerTrace && !visibleMessageText(message);
|
||||
}
|
||||
|
||||
function messageDurationMeta(message: ChatMessage): { text: string; label: string } | null {
|
||||
if (message.role !== "agent") return null;
|
||||
const timing = messageTimingForDisplay(message);
|
||||
const durationMs = isRunningMessage(message) ? durationSince(timing?.startedAt) : terminalMessageDurationMs(timing);
|
||||
if (durationMs === null) return null;
|
||||
const value = formatDuration(durationMs);
|
||||
return { text: `耗时 ${value}`, label: `总耗时:${value}` };
|
||||
}
|
||||
|
||||
function messageActivityMeta(message: ChatMessage): { text: string; label: string } | null {
|
||||
if (!isRunningMessage(message)) return null;
|
||||
const timing = messageTimingForDisplay(message);
|
||||
const ageMs = durationSince(timing?.lastEventAt);
|
||||
if (ageMs === null) return null;
|
||||
const value = formatDuration(ageMs) + "前";
|
||||
return { text: `最近 ${value}`, label: `最近事件:${value}` };
|
||||
}
|
||||
|
||||
function messageTimingForDisplay(message: ChatMessage): ChatMessage["timing"] {
|
||||
return message.timing ?? null;
|
||||
}
|
||||
|
||||
function terminalMessageDurationMs(timing: ChatMessage["timing"]): number | null {
|
||||
const durationMs = finiteDurationMs(timing?.durationMs);
|
||||
if (durationMs === 0 && timing?.finishedAt) return 1000;
|
||||
return durationMs;
|
||||
}
|
||||
|
||||
function durationSince(timestamp: unknown): number | null {
|
||||
const startedAtMs = timestampMs(timestamp);
|
||||
return startedAtMs === null ? null : Math.max(0, nowMs.value - startedAtMs);
|
||||
}
|
||||
|
||||
function timestampMs(value: unknown): number | null {
|
||||
if (typeof value !== "string") return null;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return null;
|
||||
const parsed = Date.parse(trimmed);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
function finiteDurationMs(value: unknown): number | null {
|
||||
const number = Number(value);
|
||||
return Number.isFinite(number) && number >= 0 ? Math.trunc(number) : null;
|
||||
}
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
const seconds = ms > 0 && ms < 1000 ? 1 : Math.max(0, Math.floor(ms / 1000));
|
||||
if (seconds < 60) return `${seconds} 秒`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) return `${minutes} 分 ${seconds % 60} 秒`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours} 小时 ${minutes % 60} 分`;
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days} 天 ${hours % 24} 小时`;
|
||||
}
|
||||
|
||||
function traceStorageKey(message: ChatMessage): string {
|
||||
return `${props.storageKeyPrefix}.${message.traceId ?? message.runnerTrace?.traceId ?? message.id}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article
|
||||
class="message-card"
|
||||
:data-role="message.role"
|
||||
:data-status="message.status"
|
||||
:data-session-id="message.sessionId || undefined"
|
||||
:data-message-id="message.id || undefined"
|
||||
:data-trace-id="message.traceId || message.runnerTrace?.traceId || undefined"
|
||||
:data-turn-id="message.turnId || message.traceId || message.runnerTrace?.traceId || undefined"
|
||||
>
|
||||
<header v-if="message.role !== 'user'">
|
||||
<div class="message-header-main">
|
||||
<strong>{{ message.title }}</strong>
|
||||
<span v-if="durationMeta" class="message-timing-meta message-duration-meta" :aria-label="durationMeta.label" :title="durationMeta.label">{{ durationMeta.text }}</span>
|
||||
</div>
|
||||
<div class="message-header-actions">
|
||||
<span v-if="activityMeta" class="message-activity-meta" :aria-label="activityMeta.label" :title="activityMeta.label">{{ activityMeta.text }}</span>
|
||||
<span v-if="running" class="agent-running-indicator" aria-label="运行中" role="status"><span aria-hidden="true" /></span>
|
||||
<StatusBadge v-if="showStatusBadge(message)" :status="message.status" />
|
||||
<button v-if="detailEnabled && message.role === 'agent'" class="message-detail-button" type="button" aria-label="运行详情" title="运行详情" @click="emit('details', message)">!</button>
|
||||
</div>
|
||||
</header>
|
||||
<TraceTimeline v-if="message.role === 'agent' && message.runnerTrace" :trace="message.runnerTrace" :auto-expanded="autoExpanded" :storage-key="traceStorageKey(message)" />
|
||||
<LoadingState v-if="isAwaitingAgentBody(message)" class="message-loading" label="思考中..." compact />
|
||||
<MessageMarkdown v-if="visibleText" class="message-text" :source="visibleText" />
|
||||
<ApiErrorDiagnostic v-if="diagnostic.visible" class="message-diagnostic projection-diagnostic" :error="diagnostic.text" :api-error="diagnostic.apiError" :diagnostic="diagnostic.diagnostic" compact />
|
||||
</article>
|
||||
</template>
|
||||
@@ -5,6 +5,7 @@ import { createRouter, createWebHistory, type RouteRecordRaw } from "vue-router"
|
||||
import { installRouterGuards } from "./guards";
|
||||
|
||||
const CodeWorkbenchView = () => import("@/views/workbench/CodeWorkbenchView.vue");
|
||||
const WorkbenchDebugView = () => import("@/views/workbench/WorkbenchDebugView.vue");
|
||||
const OpenCodeFrameView = () => import("@/views/opencode/OpenCodeFrameView.vue");
|
||||
const ProjectsView = () => import("@/views/projects/ProjectsView.vue");
|
||||
const ProjectMdtodoView = () => import("@/views/projects/MdtodoView.vue");
|
||||
@@ -14,6 +15,7 @@ const routes: RouteRecordRaw[] = [
|
||||
{ path: "/login", name: "Login", component: () => import("@/views/LoginView.vue"), meta: { requiresAuth: false, title: "登录" } },
|
||||
{ path: "/register", name: "Register", component: () => import("@/views/RegisterView.vue"), meta: { requiresAuth: false, title: "注册" } },
|
||||
{ path: "/dashboard", name: "Dashboard", component: () => import("@/views/user/DashboardView.vue"), meta: { requiresAuth: true, navId: "user.dashboard", title: "平台概览", section: "user" } },
|
||||
{ path: "/workbench/debug", name: "WorkbenchDebug", component: WorkbenchDebugView, meta: { requiresAuth: true, navId: "workbench.debug", title: "Workbench 调试", section: "workbench-debug" } },
|
||||
{ path: "/workbench/sessions/:sessionId", alias: ["/workspace/sessions/:sessionId"], name: "CodeWorkbenchSession", component: CodeWorkbenchView, meta: { requiresAuth: true, navId: "workbench.code", title: "Code 工作台", section: "workbench" } },
|
||||
{ path: "/workbench", alias: ["/workspace"], name: "CodeWorkbench", component: CodeWorkbenchView, meta: { requiresAuth: true, navId: "workbench.code", title: "Code 工作台", section: "workbench" } },
|
||||
{ path: "/opencode", name: "OpenCode", component: OpenCodeFrameView, meta: { requiresAuth: true, navId: "opencode.root", title: "OpenCode", section: "opencode" } },
|
||||
|
||||
+1
-1
@@ -9,6 +9,6 @@ declare module "vue-router" {
|
||||
requiresAdmin?: boolean;
|
||||
navId?: string;
|
||||
title?: string;
|
||||
section?: "workbench" | "opencode" | "project" | "admin" | "user" | "system";
|
||||
section?: "workbench" | "workbench-debug" | "opencode" | "project" | "admin" | "user" | "system";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,6 +133,7 @@ function accessFromUnknown(value: unknown): AuthAccess | undefined {
|
||||
function firstAllowedNavPath(allowedIds: string[]): string {
|
||||
const ordered = [
|
||||
{ id: "workbench.code", path: "/workbench" },
|
||||
{ id: "workbench.debug", path: "/workbench/debug" },
|
||||
{ id: "opencode.root", path: "/opencode" },
|
||||
{ id: "project.mdtodo", path: "/projects/mdtodo" },
|
||||
{ id: "user.dashboard", path: "/dashboard" },
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
// SPEC: PJ2026-010401080313 Workbench实时权威 draft-2026-07-09-p1-single-step-debug.
|
||||
// Responsibility: isolated fake-SSE reducer harness for Workbench debug pages.
|
||||
|
||||
import type { WorkbenchRealtimeEvent } from "@/api/workbench-events";
|
||||
import { mergeRunnerTrace, snapshotToRunnerTrace, type TraceSnapshot } from "@/composables/workbench-trace-snapshot";
|
||||
import type { ChatMessage, TraceEvent } from "@/types";
|
||||
import { firstNonEmptyString } from "@/utils";
|
||||
import { reduceWorkbenchRealtimeEvent } from "./workbench-event-reducer";
|
||||
import { planWorkbenchRealtimeApply, type WorkbenchRealtimeApplyStep } from "./workbench-realtime-plan";
|
||||
|
||||
export interface WorkbenchDebugFakeSseLogEntry {
|
||||
id: string;
|
||||
eventName: string;
|
||||
eventType: string;
|
||||
actionType: string;
|
||||
stepTypes: string[];
|
||||
traceId: string | null;
|
||||
accepted: boolean;
|
||||
reason: string | null;
|
||||
observedAt: string;
|
||||
}
|
||||
|
||||
export interface WorkbenchDebugFakeSseState {
|
||||
message: ChatMessage | null;
|
||||
logs: WorkbenchDebugFakeSseLogEntry[];
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export function createWorkbenchDebugFakeSseState(): WorkbenchDebugFakeSseState {
|
||||
return { message: null, logs: [], error: null };
|
||||
}
|
||||
|
||||
export function applyWorkbenchDebugFakeSseEvent(state: WorkbenchDebugFakeSseState, event: WorkbenchRealtimeEvent, eventName: string): WorkbenchDebugFakeSseState {
|
||||
const reduced = reduceWorkbenchRealtimeEvent(event, eventName);
|
||||
const plan = planWorkbenchRealtimeApply(reduced.action);
|
||||
let next: WorkbenchDebugFakeSseState = {
|
||||
...state,
|
||||
logs: [debugLogEntry(event, eventName, reduced.action.type, plan.steps, reduced.action.type === "ignore" ? reduced.action.reason : null), ...state.logs].slice(0, 60)
|
||||
};
|
||||
for (const step of plan.steps) next = applyStep(next, step);
|
||||
if (reduced.action.type === "ignore") next = { ...next, error: reduced.action.reason };
|
||||
return next;
|
||||
}
|
||||
|
||||
function applyStep(state: WorkbenchDebugFakeSseState, step: WorkbenchRealtimeApplyStep): WorkbenchDebugFakeSseState {
|
||||
switch (step.type) {
|
||||
case "apply-message-snapshot":
|
||||
return step.realtimeEvent.message ? { ...state, message: normalizeDebugMessage(step.realtimeEvent.message), error: null } : state;
|
||||
case "apply-trace-event":
|
||||
return applyTraceEvent(state, step.traceId, step.event, step.snapshot, step.realtimeEvent);
|
||||
case "apply-trace-snapshot":
|
||||
return applyTraceSnapshot(state, step.traceId, step.snapshot);
|
||||
case "apply-turn-snapshot":
|
||||
return applyTurnSnapshot(state, step.turn);
|
||||
case "apply-projection-error":
|
||||
return { ...state, error: firstNonEmptyString(step.realtimeEvent.error?.message, step.realtimeEvent.reason, "projection error") ?? "projection error" };
|
||||
case "clear-active-trace":
|
||||
return state.message?.runnerTrace?.traceId === step.traceId ? { ...state, message: { ...state.message, runnerTrace: null } } : state;
|
||||
}
|
||||
}
|
||||
|
||||
function applyTraceEvent(state: WorkbenchDebugFakeSseState, traceId: string | null, event: WorkbenchRealtimeEvent["event"], snapshot: WorkbenchRealtimeEvent["snapshot"], realtimeEvent: WorkbenchRealtimeEvent): WorkbenchDebugFakeSseState {
|
||||
const id = firstNonEmptyString(traceId, event?.traceId, snapshot?.traceId, realtimeEvent.traceId);
|
||||
if (!id) return state;
|
||||
const message = state.message ?? debugMessageForTrace(id, realtimeEvent);
|
||||
const trace = traceFromRealtime(id, snapshot, event ? [event] : [], message.runnerTrace ?? null);
|
||||
return {
|
||||
...state,
|
||||
error: null,
|
||||
message: {
|
||||
...message,
|
||||
status: trace.status === "completed" ? "completed" : message.status,
|
||||
runnerTrace: mergeRunnerTrace(message.runnerTrace, trace),
|
||||
updatedAt: new Date().toISOString()
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function applyTraceSnapshot(state: WorkbenchDebugFakeSseState, traceId: string | null, snapshot: WorkbenchRealtimeEvent["snapshot"]): WorkbenchDebugFakeSseState {
|
||||
const id = firstNonEmptyString(traceId, snapshot?.traceId);
|
||||
if (!id || !snapshot) return state;
|
||||
const message = state.message ?? debugMessageForTrace(id, { sessionId: snapshot.sessionId, threadId: snapshot.threadId, traceId: id });
|
||||
const trace = traceFromRealtime(id, snapshot, Array.isArray(snapshot.events) ? snapshot.events : [], message.runnerTrace ?? null);
|
||||
return {
|
||||
...state,
|
||||
error: null,
|
||||
message: {
|
||||
...message,
|
||||
status: trace.status === "completed" ? "completed" : message.status,
|
||||
runnerTrace: mergeRunnerTrace(message.runnerTrace, trace),
|
||||
updatedAt: new Date().toISOString()
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function applyTurnSnapshot(state: WorkbenchDebugFakeSseState, turn: NonNullable<WorkbenchRealtimeEvent["turn"]>): WorkbenchDebugFakeSseState {
|
||||
const traceId = firstNonEmptyString(turn.traceId);
|
||||
if (!traceId || !state.message || firstNonEmptyString(state.message.traceId, state.message.runnerTrace?.traceId) !== traceId) return state;
|
||||
const status = normalizeStatus(turn.status);
|
||||
return status ? { ...state, message: { ...state.message, status, updatedAt: new Date().toISOString() } } : state;
|
||||
}
|
||||
|
||||
function traceFromRealtime(traceId: string, snapshot: WorkbenchRealtimeEvent["snapshot"], events: TraceEvent[], existing: ChatMessage["runnerTrace"]): NonNullable<ChatMessage["runnerTrace"]> {
|
||||
const snapshotEvents = Array.isArray(snapshot?.events) ? snapshot.events : null;
|
||||
const previousEvents = Array.isArray(existing?.events) ? existing.events : [];
|
||||
const nextEvents = snapshotEvents ?? mergeDebugTraceEvents(previousEvents, events);
|
||||
const source = (snapshot ?? {}) as TraceSnapshot;
|
||||
return snapshotToRunnerTrace({
|
||||
...source,
|
||||
traceId,
|
||||
status: firstNonEmptyString(source.status, events.at(-1)?.status, existing?.status) ?? "running",
|
||||
sessionId: source.sessionId ?? existing?.sessionId ?? null,
|
||||
threadId: source.threadId ?? existing?.threadId ?? null,
|
||||
events: nextEvents,
|
||||
eventCount: finiteNumber(source.eventCount) ?? nextEvents.length,
|
||||
fullTraceLoaded: source.fullTraceLoaded === true,
|
||||
hasMore: source.hasMore === true,
|
||||
eventSource: "debug-fake-sse"
|
||||
} as TraceSnapshot);
|
||||
}
|
||||
|
||||
function mergeDebugTraceEvents(previous: TraceEvent[], incoming: TraceEvent[]): TraceEvent[] {
|
||||
if (incoming.length === 0) return previous;
|
||||
const keys = new Set(previous.map(traceEventKey).filter((item): item is string => Boolean(item)));
|
||||
const merged = [...previous];
|
||||
for (const event of incoming) {
|
||||
const key = traceEventKey(event);
|
||||
if (key && keys.has(key)) continue;
|
||||
if (key) keys.add(key);
|
||||
merged.push(event);
|
||||
}
|
||||
return merged.sort((left, right) => (finiteNumber(left.projectedSeq) ?? 0) - (finiteNumber(right.projectedSeq) ?? 0));
|
||||
}
|
||||
|
||||
function traceEventKey(event: TraceEvent): string | null {
|
||||
return firstNonEmptyString(event.sourceEventId, event.id, event.itemId, event.projectedSeq != null ? `${event.traceId ?? "trace"}:${event.projectedSeq}` : null);
|
||||
}
|
||||
|
||||
function debugMessageForTrace(traceId: string, event: Pick<WorkbenchRealtimeEvent, "sessionId" | "threadId" | "traceId">): ChatMessage {
|
||||
const now = new Date().toISOString();
|
||||
return {
|
||||
id: `msg_debug_${traceId}`,
|
||||
messageId: `msg_debug_${traceId}`,
|
||||
role: "agent",
|
||||
title: "Code Agent",
|
||||
text: "",
|
||||
status: "running",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
traceId,
|
||||
turnId: traceId,
|
||||
sessionId: firstNonEmptyString(event.sessionId) ?? "ses_debug_fake_sse",
|
||||
threadId: firstNonEmptyString(event.threadId) ?? "thr_debug_fake_sse",
|
||||
runnerTrace: null,
|
||||
timing: { startedAt: now, lastEventAt: now, finishedAt: null, durationMs: null, valuesRedacted: true }
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeDebugMessage(message: ChatMessage): ChatMessage {
|
||||
const role = (String(message.role ?? "") === "assistant" ? "agent" : message.role) as ChatMessage["role"];
|
||||
return {
|
||||
...message,
|
||||
role,
|
||||
id: firstNonEmptyString(message.messageId, message.id) ?? "msg_debug_agent",
|
||||
messageId: firstNonEmptyString(message.messageId, message.id) ?? "msg_debug_agent",
|
||||
title: firstNonEmptyString(message.title) ?? (role === "user" ? "用户" : "Code Agent"),
|
||||
status: normalizeStatus(message.status) ?? "running",
|
||||
createdAt: firstNonEmptyString(message.createdAt) ?? new Date().toISOString(),
|
||||
text: role === "agent" && !isTerminalStatus(message.status) ? "" : String(message.text ?? "")
|
||||
};
|
||||
}
|
||||
|
||||
function debugLogEntry(event: WorkbenchRealtimeEvent, eventName: string, actionType: string, steps: WorkbenchRealtimeApplyStep[], reason: string | null): WorkbenchDebugFakeSseLogEntry {
|
||||
const ignored = actionType === "ignore";
|
||||
return {
|
||||
id: `${Date.now()}:${eventName}:${event.traceId ?? "none"}:${Math.random().toString(16).slice(2)}`,
|
||||
eventName,
|
||||
eventType: firstNonEmptyString(event.type) ?? "unknown",
|
||||
actionType,
|
||||
stepTypes: steps.map((step) => step.type),
|
||||
traceId: firstNonEmptyString(event.traceId, event.snapshot?.traceId, event.event?.traceId, event.message?.traceId) ?? null,
|
||||
accepted: !ignored,
|
||||
reason: ignored ? reason ?? "ignored" : null,
|
||||
observedAt: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeStatus(value: unknown): ChatMessage["status"] | null {
|
||||
const status = String(value ?? "").trim().toLowerCase().replace(/_/gu, "-");
|
||||
if (["pending", "sent", "running", "completed", "failed", "blocked", "timeout", "canceled"].includes(status)) return status as ChatMessage["status"];
|
||||
if (status === "cancelled") return "canceled";
|
||||
return null;
|
||||
}
|
||||
|
||||
function isTerminalStatus(value: unknown): boolean {
|
||||
return ["completed", "failed", "blocked", "timeout", "canceled", "cancelled"].includes(String(value ?? "").trim().toLowerCase().replace(/_/gu, "-"));
|
||||
}
|
||||
|
||||
function finiteNumber(value: unknown): number | null {
|
||||
const number = Number(value);
|
||||
return Number.isFinite(number) && number >= 0 ? Math.trunc(number) : null;
|
||||
}
|
||||
@@ -0,0 +1,420 @@
|
||||
<script setup lang="ts">
|
||||
// SPEC: PJ2026-010401080313 Workbench实时权威 draft-2026-07-09-p1-single-step-debug.
|
||||
// Responsibility: Workbench debug route for backend-driven fake SSE single-step Trace card inspection.
|
||||
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
|
||||
import { connectWorkbenchDebugFakeSse, workbenchDebugAPI, type WorkbenchDebugFakeSseQueue, type WorkbenchDebugFakeSseSequence, type WorkbenchDebugFakeSseStream } from "@/api";
|
||||
import StatusBadge from "@/components/common/StatusBadge.vue";
|
||||
import WorkbenchMessageCard from "@/components/workbench/WorkbenchMessageCard.vue";
|
||||
import type { WorkbenchRealtimeEvent } from "@/api/workbench-events";
|
||||
import { applyWorkbenchDebugFakeSseEvent, createWorkbenchDebugFakeSseState } from "@/stores/workbench-debug-fake-sse";
|
||||
|
||||
const queueId = "trace-card";
|
||||
const selectedSequenceId = ref("trace-card-basic");
|
||||
const sequences = ref<WorkbenchDebugFakeSseSequence[]>([]);
|
||||
const queue = ref<WorkbenchDebugFakeSseQueue | null>(null);
|
||||
const projection = ref(createWorkbenchDebugFakeSseState());
|
||||
const streamStatus = ref<"connecting" | "open" | "error" | "closed">("connecting");
|
||||
const busy = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
const appendDraft = ref(defaultAppendDraft());
|
||||
let stream: WorkbenchDebugFakeSseStream | null = null;
|
||||
|
||||
const message = computed(() => projection.value.message);
|
||||
const logs = computed(() => projection.value.logs);
|
||||
const queueSummary = computed(() => queue.value ? `${queue.value.cursor}/${queue.value.eventCount}` : "-");
|
||||
const streamBadgeStatus = computed(() => streamStatus.value === "open" ? "completed" : streamStatus.value === "error" ? "failed" : "running");
|
||||
const canStep = computed(() => !busy.value && (queue.value?.remaining ?? 0) > 0);
|
||||
|
||||
onMounted(async () => {
|
||||
await refreshDescription();
|
||||
openStream();
|
||||
await resetQueue();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stream?.close();
|
||||
stream = null;
|
||||
streamStatus.value = "closed";
|
||||
});
|
||||
|
||||
async function refreshDescription(): Promise<void> {
|
||||
const response = await workbenchDebugAPI.describe(queueId);
|
||||
if (!response.ok) {
|
||||
error.value = response.error || "Workbench debug fake SSE 后端不可用";
|
||||
return;
|
||||
}
|
||||
sequences.value = response.data?.sequences ?? [];
|
||||
queue.value = response.data?.queue ?? null;
|
||||
selectedSequenceId.value = queue.value?.sequenceId ?? sequences.value[0]?.sequenceId ?? "trace-card-basic";
|
||||
}
|
||||
|
||||
function openStream(): void {
|
||||
stream?.close();
|
||||
streamStatus.value = "connecting";
|
||||
stream = connectWorkbenchDebugFakeSse({
|
||||
queueId,
|
||||
onOpen: () => { streamStatus.value = "open"; },
|
||||
onError: () => { streamStatus.value = "error"; },
|
||||
onEvent: (event, eventName) => {
|
||||
if (eventName === "workbench.connected") return;
|
||||
projection.value = applyWorkbenchDebugFakeSseEvent(projection.value, event, eventName);
|
||||
error.value = projection.value.error;
|
||||
}
|
||||
});
|
||||
if (!stream) streamStatus.value = "error";
|
||||
}
|
||||
|
||||
async function resetQueue(): Promise<void> {
|
||||
busy.value = true;
|
||||
error.value = null;
|
||||
projection.value = createWorkbenchDebugFakeSseState();
|
||||
try {
|
||||
const response = await workbenchDebugAPI.reset({ queueId, sequenceId: selectedSequenceId.value });
|
||||
if (!response.ok) throw new Error(response.error || "reset failed");
|
||||
queue.value = response.data?.queue ?? queue.value;
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function stepNext(): Promise<void> {
|
||||
await controlQueue(() => workbenchDebugAPI.next({ queueId }));
|
||||
}
|
||||
|
||||
async function runAll(): Promise<void> {
|
||||
await controlQueue(() => workbenchDebugAPI.run({ queueId }));
|
||||
}
|
||||
|
||||
async function appendDraftEvents(): Promise<void> {
|
||||
error.value = null;
|
||||
const parsed = parseAppendDraft(appendDraft.value);
|
||||
if (!parsed.ok) {
|
||||
error.value = parsed.error;
|
||||
return;
|
||||
}
|
||||
await controlQueue(() => workbenchDebugAPI.append({ queueId, events: parsed.events }));
|
||||
}
|
||||
|
||||
async function controlQueue(operation: () => ReturnType<typeof workbenchDebugAPI.next>): Promise<void> {
|
||||
busy.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
const response = await operation();
|
||||
if (!response.ok) throw new Error(response.error || "queue operation failed");
|
||||
queue.value = response.data?.queue ?? queue.value;
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function parseAppendDraft(raw: string): { ok: true; events: WorkbenchRealtimeEvent[] } | { ok: false; error: string } {
|
||||
try {
|
||||
const value = JSON.parse(raw);
|
||||
const events = Array.isArray(value) ? value : Array.isArray(value?.events) ? value.events : [value];
|
||||
const normalized = events.filter((event: unknown): event is WorkbenchRealtimeEvent => Boolean(event && typeof event === "object" && !Array.isArray(event)));
|
||||
return normalized.length > 0 ? { ok: true, events: normalized } : { ok: false, error: "JSON 中没有可追加的 event 对象" };
|
||||
} catch (err) {
|
||||
return { ok: false, error: err instanceof Error ? err.message : "JSON 解析失败" };
|
||||
}
|
||||
}
|
||||
|
||||
function defaultAppendDraft(): string {
|
||||
return JSON.stringify({
|
||||
type: "trace.event",
|
||||
traceId: "trc_debug_fake_sse",
|
||||
sessionId: "ses_debug_fake_sse",
|
||||
threadId: "thr_debug_fake_sse",
|
||||
event: {
|
||||
traceId: "trc_debug_fake_sse",
|
||||
projectedSeq: 99,
|
||||
createdAt: "2026-07-09T08:00:09.000Z",
|
||||
label: "assistant:message",
|
||||
type: "assistant_message",
|
||||
status: "running",
|
||||
message: "这是前端临时追加到后端队列的 fake SSE 事件。",
|
||||
elapsedMs: 9000
|
||||
}
|
||||
}, null, 2);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="workbench-debug-route">
|
||||
<header class="workbench-debug-header">
|
||||
<div>
|
||||
<p class="page-eyebrow">Workbench Debug</p>
|
||||
<h1>实时调试台</h1>
|
||||
</div>
|
||||
<div class="workbench-debug-status">
|
||||
<StatusBadge :status="streamBadgeStatus" :label="`SSE ${streamStatus}`" />
|
||||
<span class="debug-counter">Queue {{ queueSummary }}</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<nav class="workbench-debug-tabs" aria-label="Workbench 调试子标签">
|
||||
<button class="debug-tab" data-active="true" type="button">SSE Trace</button>
|
||||
</nav>
|
||||
|
||||
<section class="workbench-debug-toolbar" aria-label="fake SSE 控制">
|
||||
<label>
|
||||
<span>序列</span>
|
||||
<select v-model="selectedSequenceId" class="input" :disabled="busy" @change="resetQueue">
|
||||
<option v-for="sequence in sequences" :key="sequence.sequenceId" :value="sequence.sequenceId">{{ sequence.label }} · {{ sequence.eventCount }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<button class="btn btn-secondary" type="button" :disabled="busy" @click="resetQueue">重置</button>
|
||||
<button class="btn btn-primary" type="button" :disabled="!canStep" @click="stepNext">单步发送</button>
|
||||
<button class="btn btn-secondary" type="button" :disabled="!canStep" @click="runAll">全速运行</button>
|
||||
</section>
|
||||
|
||||
<p v-if="error" class="workbench-debug-error">{{ error }}</p>
|
||||
|
||||
<div class="workbench-debug-grid">
|
||||
<section class="debug-surface" aria-label="Trace 卡片投影">
|
||||
<WorkbenchMessageCard v-if="message" :message="message" :detail-enabled="false" :trace-auto-expanded="true" storage-key-prefix="hwlab.workbench.debug.trace-open" />
|
||||
<div v-else class="debug-empty">等待第一条 fake SSE message.snapshot</div>
|
||||
</section>
|
||||
|
||||
<aside class="debug-side">
|
||||
<section class="debug-append" aria-label="追加 fake SSE">
|
||||
<div class="debug-section-title">
|
||||
<strong>Append</strong>
|
||||
<button class="btn btn-sm btn-secondary" type="button" :disabled="busy" @click="appendDraftEvents">追加到后端队列</button>
|
||||
</div>
|
||||
<textarea v-model="appendDraft" class="debug-json-input" spellcheck="false" />
|
||||
</section>
|
||||
|
||||
<section class="debug-log" aria-label="SSE 决策日志">
|
||||
<div class="debug-section-title">
|
||||
<strong>Reducer</strong>
|
||||
<span>{{ logs.length }} events</span>
|
||||
</div>
|
||||
<ol>
|
||||
<li v-for="entry in logs" :key="entry.id" :data-accepted="entry.accepted ? 'true' : 'false'">
|
||||
<code>{{ entry.eventType }}</code>
|
||||
<span>{{ entry.actionType }}</span>
|
||||
<small>{{ entry.stepTypes.join(", ") || entry.reason || "no-op" }}</small>
|
||||
</li>
|
||||
</ol>
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.workbench-debug-route {
|
||||
display: grid;
|
||||
min-height: 100%;
|
||||
grid-template-rows: auto auto auto auto minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.workbench-debug-header,
|
||||
.workbench-debug-toolbar,
|
||||
.debug-section-title,
|
||||
.workbench-debug-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.workbench-debug-header {
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
border-bottom: 1px solid #d8e1eb;
|
||||
padding: 4px 2px 12px;
|
||||
}
|
||||
|
||||
.workbench-debug-header h1 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.workbench-debug-status {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.debug-counter {
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 8px;
|
||||
background: white;
|
||||
padding: 7px 10px;
|
||||
color: #475569;
|
||||
font-size: 12px;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.workbench-debug-tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
border-bottom: 1px solid #d8e1eb;
|
||||
}
|
||||
|
||||
.debug-tab {
|
||||
border: 0;
|
||||
border-bottom: 2px solid transparent;
|
||||
background: transparent;
|
||||
padding: 10px 12px;
|
||||
color: #475569;
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.debug-tab[data-active="true"] {
|
||||
border-bottom-color: #0e7490;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.workbench-debug-toolbar {
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.workbench-debug-toolbar label {
|
||||
display: grid;
|
||||
width: min(360px, 100%);
|
||||
gap: 4px;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.workbench-debug-grid {
|
||||
display: grid;
|
||||
min-height: 0;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(320px, 420px);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.debug-surface,
|
||||
.debug-side,
|
||||
.debug-append,
|
||||
.debug-log {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.debug-surface {
|
||||
overflow: auto;
|
||||
border: 1px solid #d8e1eb;
|
||||
border-radius: 8px;
|
||||
background: #f8fafc;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.debug-empty,
|
||||
.workbench-debug-error {
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.debug-empty {
|
||||
border: 1px dashed #cbd5e1;
|
||||
background: white;
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.workbench-debug-error {
|
||||
border: 1px solid #fecaca;
|
||||
background: #fff1f2;
|
||||
color: #9f1239;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.debug-side {
|
||||
display: grid;
|
||||
grid-template-rows: minmax(240px, 1fr) minmax(160px, 0.8fr);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.debug-append,
|
||||
.debug-log {
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
gap: 8px;
|
||||
border: 1px solid #d8e1eb;
|
||||
border-radius: 8px;
|
||||
background: white;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.debug-section-title {
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.debug-section-title span {
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.debug-json-input {
|
||||
min-height: 0;
|
||||
resize: none;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 8px;
|
||||
background: #0f172a;
|
||||
padding: 10px;
|
||||
color: #d1fae5;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.debug-log ol {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 6px;
|
||||
min-height: 0;
|
||||
margin: 0;
|
||||
overflow: auto;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.debug-log li {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(96px, 0.7fr) minmax(96px, 0.7fr) minmax(0, 1fr);
|
||||
gap: 8px;
|
||||
border-left: 3px solid #22c55e;
|
||||
background: #f8fafc;
|
||||
padding: 8px;
|
||||
color: #334155;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.debug-log li[data-accepted="false"] {
|
||||
border-left-color: #ef4444;
|
||||
}
|
||||
|
||||
.debug-log code {
|
||||
color: #0f766e;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.debug-log small {
|
||||
overflow: hidden;
|
||||
color: #64748b;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.workbench-debug-grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.debug-side {
|
||||
grid-template-rows: 280px 220px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user