优化
This commit is contained in:
Generated
+1644
-2
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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) } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 }>;
|
||||
|
||||
Reference in New Issue
Block a user