This commit is contained in:
2026-08-31 14:16:08 +08:00
parent b1b226593a
commit 7533b298f1
30 changed files with 6976 additions and 253 deletions
+1644 -2
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -16,6 +16,7 @@
"@radix-ui/react-collapsible": "^1.1.20",
"@radix-ui/react-dropdown-menu": "^2.1.24",
"@radix-ui/react-slider": "^1.4.7",
"@types/react-syntax-highlighter": "^15.5.13",
"ai": "7.0.37",
"animejs": "^4.5.0",
"clsx": "^2.1.1",
@@ -23,6 +24,10 @@
"next": "16.2.6",
"react": "19.2.4",
"react-dom": "19.2.4",
"react-json-view-lite": "^2.5.0",
"react-markdown": "^10.1.0",
"react-syntax-highlighter": "^16.1.1",
"remark-gfm": "^4.0.1",
"tailwind-merge": "^3.6.0",
"three": "0.160.0",
"three-mesh-bvh": "^0.8.0"
+22 -5
View File
@@ -56,14 +56,31 @@ export async function POST(request: NextRequest) {
}
const stream = createUIMessageStream({
execute: async ({ writer }) => {
const textId = `assistant_${Date.now()}`;
writer.write({ type: "start", messageId: textId });
writer.write({ type: "text-start", id: textId });
const messageId = `assistant_${Date.now()}`;
writer.write({ type: "start", messageId });
let textPartIndex = 0;
let textId: string | null = null;
let sequence = 0;
for await (const item of parseSse(upstream)) {
const chunk = backendEventToUiChunk(item, textId);
if (item.event === "done") continue;
sequence += 1;
if (item.event === "text_delta") {
if (!textId) {
textId = `${messageId}_text_${textPartIndex++}`;
writer.write({ type: "text-start", id: textId });
}
const chunk = backendEventToUiChunk(item, textId, sequence);
if (chunk) writer.write(chunk);
continue;
}
if (textId) {
writer.write({ type: "text-end", id: textId });
textId = null;
}
const chunk = backendEventToUiChunk(item, `${messageId}_text_${textPartIndex}`, sequence);
if (chunk) writer.write(chunk);
}
writer.write({ type: "text-end", id: textId });
if (textId) writer.write({ type: "text-end", id: textId });
writer.write({ type: "finish", finishReason: "stop" });
},
});
@@ -9,3 +9,10 @@ export async function GET(_request: NextRequest, context: { params: Promise<{ ta
if (!response.ok) return NextResponse.json({ error: await readBackendError(response) }, { status: response.status });
return NextResponse.json(await response.json());
}
export async function DELETE(_request: NextRequest, context: { params: Promise<{ taskId: string }> }) {
const { taskId } = await context.params;
const response = await backendFetch(`/v1/tasks/${encodeURIComponent(taskId)}`, { method: "DELETE" });
if (!response.ok) return NextResponse.json({ error: await readBackendError(response) }, { status: response.status });
return NextResponse.json(await response.json());
}
+23 -11
View File
@@ -395,17 +395,6 @@ button:disabled {
.studio-main { display: flex; min-height: 0; flex: 1; }
.agent-pane { display: flex; width: 420px; min-width: 0; min-height: 0; flex: 0 0 auto; flex-direction: column; border-right: 1px solid var(--ui-border); background: var(--ui-panel); }
.preview-pane { position: relative; min-width: 0; min-height: 0; flex: 1; background: var(--ui-viewer-bg); }
.generation-status { position: absolute; z-index: 30; top: 12px; right: 12px; width: min(260px, calc(100% - 24px)); max-height: min(42vh, 360px); overflow: auto; border: 1px solid var(--ui-border); border-radius: 6px; background: var(--ui-glass-popover); box-shadow: var(--ui-shadow-soft); backdrop-filter: blur(12px); color: var(--ui-text); padding: 10px; font-size: 12px; }
.generation-status-heading { display: flex; align-items: center; gap: 6px; color: var(--ui-text-strong); font-weight: 650; }
.generation-status-active { margin: 6px 0 8px; color: var(--ui-accent-text); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; overflow-wrap: anywhere; }
.generation-status ul { display: grid; gap: 4px; margin: 0; padding: 0; list-style: none; }
.generation-status li { display: flex; justify-content: space-between; gap: 8px; border-top: 1px solid var(--ui-border-muted); padding-top: 4px; color: var(--ui-text-muted); }
.generation-status li[data-status="completed"] small { color: var(--ui-success); }
.generation-status li[data-status="planned"] small { color: var(--ui-text-subtle); }
.generation-status li[data-status="failed"] small { color: var(--ui-error); }
.generation-status details { margin: 8px 0; border-top: 1px solid var(--ui-border-muted); padding-top: 6px; }
.generation-status summary { cursor: pointer; color: var(--ui-text-strong); }
.generation-status pre { margin: 6px 0 0; max-height: 132px; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; font: 11px/1.45 ui-monospace, SFMono-Regular, Menlo, monospace; color: var(--ui-text-muted); }
.agent-thread-shell, .thread-root { display: flex; min-height: 0; flex: 1; flex-direction: column; }
.agent-thread-shell { position: relative; }
.spin { animation: ui-spin 900ms linear infinite; }
@@ -421,6 +410,7 @@ button:disabled {
.assistant-row .message-role svg { color: var(--ui-accent); }
.message-content { min-width: 0; color: var(--ui-text); }
.message-text { margin: 0; font-size: 12px; line-height: 1.65; overflow-wrap: anywhere; white-space: pre-wrap; }
.message-text .markdown-document { margin-top: 0; border-left: 0; padding: 0; font-size: inherit; }
.message-request-error { display: flex; align-items: flex-start; gap: 7px; margin-top: 8px; color: var(--ui-error-text); font-size: 11px; line-height: 1.45; }
.message-request-error svg { flex: 0 0 auto; margin-top: 1px; }
.thread-empty { color: var(--ui-text-muted); font-size: 12px; line-height: 1.55; padding: 2px 0 14px; }
@@ -448,7 +438,29 @@ button:disabled {
.cad-message-heading { display: flex; min-width: 0; align-items: center; gap: 7px; color: var(--ui-text); font-size: 12px; font-weight: 600; }
.cad-message-heading svg { flex: 0 0 auto; color: var(--ui-accent); }
.cad-status-label { color: var(--ui-text-subtle); font-size: 10px; font-weight: 500; text-transform: uppercase; }
.cad-tool-name { overflow: hidden; max-width: 46%; border: 1px solid var(--ui-border-muted); border-radius: 3px; background: var(--ui-control-bg); color: var(--ui-accent-text); font: 10px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace; padding: 1px 4px; text-overflow: ellipsis; white-space: nowrap; }
.cad-message-copy { margin-left: 21px; color: var(--ui-text-muted); font-size: 11px; line-height: 1.45; overflow-wrap: anywhere; }
.cad-event-details { margin: 4px 0 0 21px; color: var(--ui-text-subtle); font-size: 10px; }
.cad-event-details summary { cursor: pointer; width: fit-content; }
.cad-event-details pre { max-height: 180px; overflow: auto; margin: 4px 0 0; border-left: 2px solid var(--ui-border-muted); padding-left: 8px; white-space: pre-wrap; overflow-wrap: anywhere; color: var(--ui-text-muted); font: 10px/1.45 ui-monospace, SFMono-Regular, Menlo, monospace; }
.cad-event-details ul { display: grid; gap: 3px; margin: 4px 0 0; padding-left: 14px; color: var(--ui-text-muted); line-height: 1.4; }
.cad-document-details { margin-top: 7px; }
.markdown-document { margin-top: 7px; border-left: 2px solid var(--ui-accent-border); padding: 2px 0 2px 10px; color: var(--ui-text); font-size: 11px; line-height: 1.65; }
.markdown-document > :first-child { margin-top: 0; }.markdown-document > :last-child { margin-bottom: 0; }
.markdown-document h1, .markdown-document h2, .markdown-document h3 { margin: 12px 0 5px; color: var(--ui-text-strong); line-height: 1.3; }
.markdown-document h1 { font-size: 14px; }.markdown-document h2 { font-size: 13px; }.markdown-document h3 { font-size: 12px; }
.markdown-document p { margin: 5px 0; }.markdown-document ul, .markdown-document ol { display: block; margin: 5px 0; padding-left: 20px; color: var(--ui-text); }
.markdown-document li { margin: 2px 0; }.markdown-document li::marker { color: var(--ui-accent); }
.markdown-document input[type="checkbox"] { margin: 0 6px 0 0; accent-color: var(--ui-accent); }
.markdown-document blockquote { margin: 7px 0; border-left: 2px solid var(--ui-border-strong); padding-left: 9px; color: var(--ui-text-muted); }
.markdown-document a { color: var(--ui-link); text-decoration: underline; text-underline-offset: 2px; }
.markdown-document table { width: 100%; margin: 7px 0; border-collapse: collapse; font-size: 10px; }.markdown-document th, .markdown-document td { border: 1px solid var(--ui-border); padding: 5px 7px; text-align: left; }.markdown-document th { background: var(--ui-panel-raised); color: var(--ui-text-strong); }
.inline-code { border: 1px solid var(--ui-border-muted); border-radius: 3px; background: var(--ui-control-bg); color: var(--ui-accent-text); padding: 1px 4px; font: 0.92em/1.4 ui-monospace, SFMono-Regular, Menlo, monospace; }
.code-viewer { position: relative; max-width: 100%; margin: 7px 0; overflow: auto; border: 1px solid var(--ui-border-strong); border-radius: 4px; background: #171a1d; color: #e8e8e3; }
.code-viewer pre { max-height: 320px; margin: 0 !important; border: 0; white-space: pre; font-size: 10px; line-height: 1.5; }
.code-language { position: sticky; left: 100%; top: 0; z-index: 1; display: block; width: max-content; margin: 5px 6px -18px auto; color: #969b9f; font-size: 9px; text-transform: uppercase; }
.json-tree { max-height: 260px; overflow: auto; margin-top: 6px; border: 1px solid var(--ui-border); border-radius: 4px; background: var(--ui-control-bg); padding: 7px; color: var(--ui-text); font: 10px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; }
.json-tree .json-view--property { color: var(--ui-accent-text); }.json-tree .json-view--string { color: var(--ui-success-text); }.json-tree .json-view--number, .json-tree .json-view--boolean { color: var(--ui-secondary-text); }
.cad-progress.is-error .cad-message-heading, .cad-progress.is-error .cad-message-heading svg, .cad-error .cad-message-heading, .cad-error .cad-message-heading svg { color: var(--ui-error-text); }
.cad-result { margin-top: 12px; }
.cad-result-title { margin-left: 21px; color: var(--ui-accent-text); font-size: 12px; font-weight: 600; line-height: 1.45; overflow-wrap: anywhere; }
+28 -23
View File
@@ -165,6 +165,30 @@ export function AgentStudio() {
if (error.stage === "generation") setTaskRunning(false);
}, []);
const handleCancel = useCallback(() => {
setTaskRunning(false);
setLastError("正在停止 CAD 任务...");
void (async () => {
let taskId = selectedTaskId;
if (!taskId && conversationId) {
const conversationResponse = await fetch(`/api/conversations/${encodeURIComponent(conversationId)}`, { cache: "no-store" });
if (conversationResponse.ok) {
const conversation = await conversationResponse.json() as ConversationRecord;
taskId = conversation.current_task_id || "";
}
}
if (!taskId) throw new Error("尚未取得运行中的任务编号,请稍后重试");
const response = await fetch(`/api/tasks/${encodeURIComponent(taskId)}`, { method: "DELETE" });
if (!response.ok) {
const payload = await response.json().catch(() => ({})) as { error?: string };
throw new Error(payload.error || "停止 CAD 任务失败");
}
setLastError("CAD 任务已停止");
})().catch((error) => {
setLastError(error instanceof Error ? error.message : "停止 CAD 任务失败");
});
}, [conversationId, selectedTaskId]);
const handleUpload = useCallback(async (files: FileList | null) => {
const selectedFiles = Array.from(files || []);
if (!selectedFiles.length) return;
@@ -247,6 +271,7 @@ export function AgentStudio() {
uploading={uploading}
uploadError={uploadError}
onUpload={handleUpload}
onCancel={handleCancel}
theme={theme}
onToggleTheme={toggleTheme}
providerId={providerId}
@@ -257,7 +282,6 @@ export function AgentStudio() {
onCadError={handleError}
onSelectionChange={setViewerSelection}
taskRunning={taskRunning}
taskRecord={taskRecord}
/>
</AgentRuntime>
);
@@ -478,6 +502,7 @@ function StudioShell({
uploading,
uploadError,
onUpload,
onCancel,
theme,
onToggleTheme,
providerId,
@@ -488,7 +513,6 @@ function StudioShell({
onCadError,
onSelectionChange,
taskRunning,
taskRecord,
}: {
config: BackendConfig | null;
cadResult: CadResult | null;
@@ -497,6 +521,7 @@ function StudioShell({
uploading: boolean;
uploadError: string;
onUpload: (files: FileList | null) => void;
onCancel: () => void;
theme: "light" | "dark";
onToggleTheme: () => void;
providerId: string;
@@ -507,7 +532,6 @@ function StudioShell({
onCadError: (error: CadError) => void;
onSelectionChange: (selection: ViewerSelectionContext | null) => void;
taskRunning: boolean;
taskRecord: TaskRecord | null;
}) {
const running = useAuiState((state) => state.thread.isRunning) || taskRunning;
const provider = config?.providers.find((item) => item.id === providerId);
@@ -531,9 +555,8 @@ function StudioShell({
{!config?.configured ? <div className="config-warning"><AlertCircle size={16} /><span></span></div> : null}
{config?.autonomous_generation && !config.review_configured ? <div className="config-warning"><AlertCircle size={16} /><span>{config.review_error || "请配置独立视觉模型。"}</span></div> : null}
<div className="studio-main">
<aside className="agent-pane"><AgentThread attachments={attachments} uploading={uploading} uploadError={uploadError} taskRunning={taskRunning} onUpload={onUpload} /></aside>
<aside className="agent-pane"><AgentThread attachments={attachments} uploading={uploading} uploadError={uploadError} taskRunning={taskRunning} onUpload={onUpload} onCancel={onCancel} /></aside>
<section className="preview-pane">
<GenerationStatus task={taskRecord} />
<CadViewerPreview result={cadResult} isGenerating={running} lastError={lastError} theme={theme} onError={handleViewerError} onSelectionChange={onSelectionChange} />
</section>
</div>
@@ -541,24 +564,6 @@ function StudioShell({
);
}
function GenerationStatus({ task }: { task: TaskRecord | null }) {
if (task?.lifecycle !== "running") return null;
const agent = task.agent_state;
const events = agent?.recent_events || [];
return (
<aside className="generation-status" aria-live="polite">
<div className="generation-status-heading"><Loader2 className="spin" size={14} /><span></span></div>
<div className="generation-status-active">{task.active_candidate_id ? `候选 ${task.active_candidate_id}` : task.active_revision || "正在编写冻结需求"}</div>
{task.requirements_markdown ? <details open><summary></summary><pre>{task.requirements_markdown}</pre></details> : null}
{task.completion_checklist_markdown ? <details open><summary></summary><pre>{task.completion_checklist_markdown}</pre></details> : null}
{agent?.completion_ledger?.items?.length ? <ul className="completion-ledger">{agent.completion_ledger.items.map((item, index) => <li key={`${item.item || "item"}-${index}`}><span>{item.status === "complete" ? "完成" : item.status === "uncertain" ? "待确认" : "缺失"}</span><small>{item.item}{item.evidence ? `${item.evidence}` : ""}</small></li>)}</ul> : null}
{agent?.last_review ? <div className="generation-status-active">{agent.last_review.decision || "已记录"}</div> : null}
{agent?.last_diagnostic ? <div className="generation-status-active">{agent.last_diagnostic}</div> : null}
{events.length ? <ul>{events.slice(-6).map((item, index) => <li key={`${item.at || "event"}-${index}`}><span>{item.kind || item.tool || "工具"}</span><small>{item.message || "已更新"}</small></li>)}</ul> : null}
</aside>
);
}
function StudioLoading() {
return (
<main className="boot-screen">
+13 -6
View File
@@ -2,16 +2,17 @@
import { Bot, Check, CircleAlert, FileImage, FileText, Loader2, MessageSquare, Paperclip, Send, Sparkles, Upload } from "lucide-react";
import { useRef, useState, type ChangeEvent, type DragEvent } from "react";
import { ComposerPrimitive, MessagePrimitive, ThreadPrimitive, useAuiState } from "@assistant-ui/react";
import { ComposerPrimitive, MessagePrimitive, ThreadPrimitive, useAui, useAuiState } from "@assistant-ui/react";
import type { CadAttachment } from "@/lib/cad-types";
import { CadErrorPart, CadProgressPart, CadResultPart, TextPart } from "./cad-message-parts";
export function AgentThread({ attachments, uploading, uploadError, taskRunning = false, onUpload }: {
export function AgentThread({ attachments, uploading, uploadError, taskRunning = false, onUpload, onCancel }: {
attachments: CadAttachment[];
uploading: boolean;
uploadError: string;
taskRunning?: boolean;
onUpload: (files: FileList | null) => void;
onCancel?: () => void;
}) {
const fileInput = useRef<HTMLInputElement>(null);
const dragDepth = useRef(0);
@@ -65,7 +66,7 @@ export function AgentThread({ attachments, uploading, uploadError, taskRunning =
</ThreadPrimitive.Empty>
<div className="message-list"><ThreadPrimitive.Messages components={{ UserMessage, AssistantMessage }} /></div>
</ThreadPrimitive.Viewport>
<Composer fileInput={fileInput} uploading={uploading} taskRunning={taskRunning} onUpload={onUpload} />
<Composer fileInput={fileInput} uploading={uploading} taskRunning={taskRunning} onUpload={onUpload} onCancel={onCancel} />
</ThreadPrimitive.Root>
{isDraggingFiles ? <div className="file-drop-overlay" role="status" aria-live="polite"><Upload size={24} /><span></span></div> : null}
</div>
@@ -104,8 +105,14 @@ function AssistantMessage() {
);
}
function Composer({ fileInput, uploading, taskRunning = false, onUpload }: { fileInput: React.RefObject<HTMLInputElement | null>; uploading: boolean; taskRunning?: boolean; onUpload: (files: FileList | null) => void }) {
const running = useAuiState((state) => state.thread.isRunning) || taskRunning;
function Composer({ fileInput, uploading, taskRunning = false, onUpload, onCancel }: { fileInput: React.RefObject<HTMLInputElement | null>; uploading: boolean; taskRunning?: boolean; onUpload: (files: FileList | null) => void; onCancel?: () => void }) {
const aui = useAui();
const chatRunning = useAuiState((state) => state.thread.isRunning);
const running = chatRunning || taskRunning;
const handleCancel = () => {
onCancel?.();
if (chatRunning) aui.thread().cancelRun();
};
const handleFileChange = (event: ChangeEvent<HTMLInputElement>) => {
if (event.currentTarget.files?.length) onUpload(event.currentTarget.files);
event.currentTarget.value = "";
@@ -119,7 +126,7 @@ function Composer({ fileInput, uploading, taskRunning = false, onUpload }: { fil
<span><Check size={14} aria-hidden="true" /> Enter Shift + Enter </span>
<div className="composer-actions">
{running ? (
<span className="text-[11px] text-[var(--ui-text-subtle)]"><Loader2 className="mr-1 inline spin" size={13} aria-hidden="true" /></span>
<button type="button" className="composer-command composer-cancel" title="停止生成" aria-label="停止生成" onClick={handleCancel}><Loader2 className="spin" size={14} aria-hidden="true" /></button>
) : (
<>
<button type="button" className="composer-command composer-upload" title="上传图片或文档" aria-label="上传图片或文档" aria-busy={uploading || undefined} disabled={uploading} onClick={() => fileInput.current?.click()}>{uploading ? <Loader2 className="spin" size={14} aria-hidden="true" /> : <Paperclip size={14} aria-hidden="true" />}</button>
+20 -4
View File
@@ -1,32 +1,48 @@
"use client";
import { AlertTriangle, Box, Check, Download, Loader2 } from "lucide-react";
import { AlertTriangle, Box, Check, Download, Eye, FileCheck, Loader2, RotateCcw, Search, Wrench } from "lucide-react";
import { encodeArtifactUrl } from "@/lib/cad-artifacts";
import type { CadError, CadProgress, CadResult } from "@/lib/cad-types";
import { JsonTree, MarkdownDocument } from "./rich-content";
export function TextPart({ text }: { text: string }) {
if (!text.trim()) return null;
return <p className="message-text">{text}</p>;
return <div className="message-text"><MarkdownDocument>{text}</MarkdownDocument></div>;
}
export function CadProgressPart({ data }: { data: CadProgress }) {
if (data.step === "agent_stream") return null;
const status = String(data.status || "").toLowerCase();
const isRunning = status === "running";
const isError = status === "error";
const statusLabel = isRunning ? "进行中" : isError ? "失败" : status === "success" ? "完成" : data.status;
const Icon = data.step === "tool_call" ? Wrench : data.step.includes("review") || data.step === "final_review" ? Eye : data.step === "rollback" ? RotateCcw : data.step.includes("requirements") || data.step.includes("checklist") ? FileCheck : data.step.includes("diagnostic") ? Search : isError ? AlertTriangle : Check;
const evidence = data.evidence || (Array.isArray(data.review?.evidence) ? data.review.evidence.map(String) : []);
const documentTitle = data.step === "requirements_document" ? "冻结需求内容" : data.step === "completion_checklist" ? "完成清单内容" : "";
const documentMarkdown = data.step === "requirements_document" ? withoutRequirementsFilename(data.markdown || "") : data.markdown || "";
return (
<div className={`cad-message cad-progress${isError ? " is-error" : ""}`} role="status" aria-live="polite">
<div className="cad-message-heading">
{isRunning ? <Loader2 className="spin" size={14} aria-hidden="true" /> : isError ? <AlertTriangle size={14} aria-hidden="true" /> : <Check size={14} aria-hidden="true" />}
{isRunning ? <Loader2 className="spin" size={14} aria-hidden="true" /> : <Icon size={14} aria-hidden="true" />}
<span>{data.label || data.step}</span>
{data.tool ? <code className="cad-tool-name">{data.tool}</code> : null}
<span className="cad-status-label">{statusLabel}</span>
</div>
{data.message ? <div className="cad-message-copy">{data.message}</div> : null}
{documentMarkdown ? <details className="cad-event-details cad-document-details" open={data.step === "requirements_document"}><summary>{documentTitle || "文档内容"}</summary><MarkdownDocument>{documentMarkdown}</MarkdownDocument></details> : null}
{data.arguments ? <details className="cad-event-details"><summary></summary><JsonTree data={data.arguments} /></details> : null}
{data.result !== undefined ? <details className="cad-event-details"><summary></summary><JsonTree data={data.result} /></details> : null}
{evidence.length ? <details className="cad-event-details"><summary> ({evidence.length})</summary><ul>{evidence.map((item, index) => <li key={`${item}-${index}`}>{item}</li>)}</ul></details> : null}
</div>
);
}
function withoutRequirementsFilename(markdown: string) {
return markdown
.replace(/^\s*#{1,6}\s*`?requirements\.md`?\s*\n+/i, "")
.replace(/^\s*`?requirements\.md`?\s*\n+/i, "")
.trimStart();
}
export function CadResultPart({ data }: { data: CadResult }) {
const downloads: Array<[string, string]> = data.checkpoint ? [] : [
["STEP", data.stepPath],
+57
View File
@@ -0,0 +1,57 @@
"use client";
import type { ComponentPropsWithoutRef, ReactNode } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { JsonView, collapseAllNested } from "react-json-view-lite";
import "react-json-view-lite/dist/index.css";
import { PrismAsync as SyntaxHighlighter } from "react-syntax-highlighter";
import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism";
export function MarkdownDocument({ children }: { children: string }) {
return (
<div className="markdown-document">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
a: ({ children: label, ...props }) => <a {...props} target="_blank" rel="noreferrer">{label}</a>,
code: MarkdownCode,
pre: ({ children }) => <>{children}</>,
}}
>
{children}
</ReactMarkdown>
</div>
);
}
function MarkdownCode({ className, children, ...props }: ComponentPropsWithoutRef<"code"> & { children?: ReactNode }) {
const language = /language-([\w-]+)/.exec(className || "")?.[1];
const value = String(children || "").replace(/\n$/, "");
if (!language && !value.includes("\n")) return <code className="inline-code" {...props}>{children}</code>;
return <CodeViewer code={value} language={language || "text"} />;
}
export function CodeViewer({ code, language = "text" }: { code: string; language?: string }) {
return (
<div className="code-viewer">
<span className="code-language">{language}</span>
<SyntaxHighlighter language={language} style={oneDark} wrapLongLines customStyle={{ margin: 0, background: "transparent", padding: "12px" }}>
{code}
</SyntaxHighlighter>
</div>
);
}
export function JsonTree({ data }: { data: unknown }) {
const value = isJsonContainer(data) ? data : { value: data };
return (
<div className="json-tree">
<JsonView data={value} shouldExpandNode={(level) => level < 1 || collapseAllNested(level)} clickToExpandNode />
</div>
);
}
function isJsonContainer(value: unknown): value is Record<string, unknown> | unknown[] {
return Boolean(value && typeof value === "object");
}
+50
View File
@@ -42,6 +42,56 @@ test("maps a rejected independent candidate review into a blocking progress stat
});
});
test("maps a modeling plan revision request into a blocking progress state", () => {
const chunk = backendEventToUiChunk({
event: "modeling_plan_review",
data: { taskId: "cad_abc", review: { verdict: "revise", issues: [{ message: "split unrelated finish" }] } },
}, "text_1");
assert.equal(chunk?.type, "data-cad-progress");
assert.equal("data" in chunk! ? (chunk.data as { label?: string }).label : null, "计划独立复核");
assert.equal("data" in chunk! ? (chunk.data as { status?: string }).status : null, "error");
});
test("keeps a server-verified plan skip visible in the timeline", () => {
const chunk = backendEventToUiChunk({
event: "plan_step_skipped",
data: {
taskId: "cad_abc",
planStepId: "step_6",
nextPlanStepId: "",
evidenceRef: "completion_ledger:rev_008",
status: "success",
message: "The current revision already satisfies this plan step.",
},
}, "text_1");
assert.equal(chunk?.type, "data-cad-progress");
assert.equal("data" in chunk! ? (chunk.data as { label?: string }).label : null, "计划步骤已满足");
assert.equal("data" in chunk! ? (chunk.data as { status?: string }).status : null, "success");
});
test("gives repeated tool events unique ordered parts", () => {
const first = backendEventToUiChunk({ event: "tool_call", data: { taskId: "cad_abc", tool: "inspect_model", status: "running" } }, "text_1", 4);
const second = backendEventToUiChunk({ event: "tool_call", data: { taskId: "cad_abc", tool: "inspect_model", status: "success" } }, "text_1", 5);
assert.notEqual(first?.id, second?.id);
assert.equal("data" in first! ? (first.data as { sequence?: number }).sequence : null, 4);
assert.equal("data" in second! ? (second.data as { sequence?: number }).sequence : null, 5);
});
test("reuses an invocation id so tool completion updates its running card", () => {
const running = backendEventToUiChunk({ event: "tool_call", data: { taskId: "cad_abc", eventId: "call_1", invocationId: "call_1", tool: "submit_cdsl_fragment", status: "running" } }, "text_1", 4);
const complete = backendEventToUiChunk({ event: "tool_call", data: { taskId: "cad_abc", eventId: "call_1", invocationId: "call_1", tool: "submit_cdsl_fragment", status: "success" } }, "text_1", 5);
assert.equal(running?.id, complete?.id);
});
test("keeps frozen requirement markdown visible in the timeline", () => {
const chunk = backendEventToUiChunk({
event: "requirements_document",
data: { taskId: "cad_abc", status: "frozen", markdown: "# 冻结需求\n\n- 创建底座" },
}, "text_1", 2);
assert.equal(chunk?.type, "data-cad-progress");
assert.equal("data" in chunk! ? (chunk.data as { markdown?: string }).markdown : null, "# 冻结需求\n\n- 创建底座");
});
test("restores the latest successful task revision for the viewer", () => {
const result = latestSuccessfulResult({
task_id: "cad_abc",
+30 -10
View File
@@ -8,38 +8,58 @@ export type BackendSseEvent = {
export function backendEventToUiChunk(
item: BackendSseEvent,
textId: string,
): UIMessageChunk | null {
sequence = 0,
): (UIMessageChunk & { id?: string }) | null {
if (item.event === "text_delta") {
return { type: "text-delta", id: textId, delta: String(item.data.text || "") };
}
if (item.event === "progress") {
return {
type: "data-cad-progress",
id: `progress_${String(item.data.step || Date.now())}`,
data: item.data,
id: `progress_${String(item.data.taskId || "task")}_${sequence}`,
data: { ...item.data, sequence },
};
}
if (["requirements_document", "completion_checklist", "completion_audit", "agent_thinking", "tool_call", "candidate_result", "candidate_review", "geometry_diagnostic", "geometry_conclusion", "step_review", "checkpoint", "rollback", "final_review", "task_terminal"].includes(item.event)) {
if (["requirements_document", "completion_checklist", "completion_audit", "modeling_plan", "modeling_plan_review", "plan_step_skipped", "agent_thinking", "tool_call", "candidate_result", "candidate_review", "geometry_diagnostic", "geometry_conclusion", "step_review", "checkpoint", "rollback", "final_review", "task_terminal"].includes(item.event)) {
const review = item.data.review && typeof item.data.review === "object"
? item.data.review as Record<string, unknown>
: null;
const status = item.event === "task_terminal"
? (String(item.data.lifecycle || "") === "failed" ? "error" : "success")
: (item.event === "candidate_review" && String(review?.verdict || "") === "reject")
: (item.event === "modeling_plan_review" && String(review?.verdict || "") === "revise")
|| (item.event === "candidate_review" && String(review?.verdict || "") === "reject")
|| (item.event === "final_review" && String(review?.verdict || "") === "repair" && Number(review?.confidence || 0) >= 0.85)
? "error"
: String(item.data.status || "running");
const taskId = String(item.data.taskId || "task");
const eventId = String(item.data.eventId || `${taskId}_${sequence}_${item.event}`);
const metadata = sequence > 0 || item.data.eventId
? {
eventId,
sequence,
...(review ? { review } : {}),
}
: {};
return {
type: "data-cad-progress",
id: `${item.event}_${String(item.data.taskId || Date.now())}_${String(item.data.nodeId || "")}`,
id: `event_${eventId}`,
data: { step: item.event, label: ({
requirements_document: "冻结需求", completion_checklist: "完成清单", completion_audit: "完成审计", agent_thinking: "建模判断", tool_call: "建模工具", candidate_result: "候选构建", candidate_review: "候选独立复核", geometry_diagnostic: "几何诊断", geometry_conclusion: "几何结论", step_review: "步骤审查", checkpoint: "构建检查点", rollback: "回滚检查点", final_review: "最终视觉复核", task_terminal: "生成任务",
} as Record<string, string>)[item.event], status, message: String(
item.data.message || item.data.reason || (review?.evidence instanceof Array ? review.evidence.join("") : ""),
requirements_document: "冻结需求", completion_checklist: "完成清单", completion_audit: "完成审计", modeling_plan: "建模计划", modeling_plan_review: "计划独立复核", plan_step_skipped: "计划步骤已满足", agent_thinking: "建模判断", tool_call: "建模工具", candidate_result: "候选构建", candidate_review: "候选独立复核", geometry_diagnostic: "几何诊断", geometry_conclusion: "几何结论", step_review: "步骤审查", checkpoint: "构建检查点", rollback: "回滚检查点", final_review: "最终视觉复核", task_terminal: "生成任务",
} as Record<string, string>)[item.event], status, ...metadata, message: String(
item.data.message || item.data.reason
|| (review?.evidence instanceof Array ? review.evidence.join("") : "")
|| (review?.issues instanceof Array ? review.issues.map((issue) => typeof issue === "object" && issue ? String((issue as Record<string, unknown>).message || "") : String(issue)).filter(Boolean).join("") : ""),
),
...(item.data.taskId ? { taskId: String(item.data.taskId) } : {}),
...(item.data.taskId ? { taskId } : {}),
...(item.data.nodeId ? { nodeId: String(item.data.nodeId) } : {}),
...(item.data.lifecycle ? { lifecycle: String(item.data.lifecycle) } : {}),
...(item.data.timestamp ? { timestamp: String(item.data.timestamp) } : {}),
...(item.data.markdown ? { markdown: String(item.data.markdown) } : {}),
...(item.data.tool ? { tool: String(item.data.tool) } : {}),
...(item.data.invocationId ? { invocationId: String(item.data.invocationId) } : {}),
...(item.data.arguments && typeof item.data.arguments === "object" ? { arguments: item.data.arguments as Record<string, unknown> } : {}),
...(item.data.result !== undefined ? { result: item.data.result } : {}),
...(Array.isArray(item.data.evidence) ? { evidence: item.data.evidence.map(String) } : {}),
},
};
}
+18
View File
@@ -4,9 +4,19 @@ export type CadProgress = {
step: string;
label: string;
status: "running" | "success" | "error" | string;
eventId?: string;
sequence?: number;
timestamp?: string;
message?: string;
markdown?: string;
taskId?: string;
nodeId?: string;
tool?: string;
invocationId?: string;
arguments?: Record<string, unknown>;
result?: unknown;
evidence?: string[];
review?: Record<string, unknown>;
lifecycle?: "running" | "completed" | "failed" | string;
attempt?: number;
maxAttempts?: number;
@@ -96,12 +106,20 @@ export type TaskRecord = {
requirements_markdown?: string | null;
completion_checklist_path?: string;
completion_checklist_markdown?: string | null;
modeling_plan_path?: string;
modeling_plan_review_path?: string;
modeling_plan_version?: number;
modeling_plan_markdown?: string | null;
modeling_plan_review?: Record<string, unknown> | null;
agent_state?: {
no_progress?: number;
cycle_tool_calls?: number;
last_diagnostic?: string;
last_review?: { candidate_id?: string; path?: string; decision?: string; recorded_at?: string };
last_candidate_review?: { verdict?: "accept" | "reject" | string; batch_goal?: string; batch_goal_status?: string; evidence?: string[]; recorded_at?: string };
modeling_plan_status?: "missing" | "pending_review" | "revise" | "approved" | "stale" | string;
active_plan_step_id?: string;
plan_step_status?: Record<string, "pending" | "complete" | string>;
completion_ledger?: {
verified_revision?: string;
items?: Array<{ item?: string; status?: "complete" | "missing" | "uncertain" | string; evidence?: string }>;