优化agent

This commit is contained in:
2026-08-25 11:01:59 +08:00
parent 9f8a55ff07
commit 51575161dc
34 changed files with 2687 additions and 456 deletions
@@ -3,9 +3,10 @@ import { backendFetch, readBackendError } from "@/lib/backend";
export const runtime = "nodejs";
export async function POST(request: NextRequest) {
export async function POST(request: NextRequest, context: { params: Promise<{ conversationId: string }> }) {
const { conversationId } = await context.params;
const body = await request.formData();
const response = await backendFetch("/v1/uploads", { method: "POST", body });
const response = await backendFetch(`/v1/conversations/${encodeURIComponent(conversationId)}/attachments`, { method: "POST", body });
if (!response.ok) return NextResponse.json({ error: await readBackendError(response) }, { status: response.status });
return NextResponse.json(await response.json());
}
@@ -18,7 +18,6 @@ export async function PATCH(request: NextRequest, context: { params: Promise<{ c
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
current_task_id: body.currentTaskId || null,
attachments: Array.isArray(body.attachments) ? body.attachments : undefined,
}),
});
if (!response.ok) return NextResponse.json({ error: await readBackendError(response) }, { status: response.status });
+49 -14
View File
@@ -75,7 +75,6 @@
--ui-loading-overlay: rgb(233 238 242 / 70%);
--ui-loading-overlay-strong: rgb(245 247 248 / 88%);
--ui-drag-overlay: rgb(255 255 255 / 88%);
--ui-drag-shadow: 0 0 0 999px rgb(15 25 30 / 18%);
--ui-shadow-soft: 0 12px 30px rgb(16 24 32 / 12%);
--ui-shadow-panel: 0 22px 60px rgb(16 24 32 / 14%);
--ui-shadow-popover: 0 18px 46px rgb(16 24 32 / 16%);
@@ -161,7 +160,6 @@
--ui-loading-overlay: rgb(13 15 17 / 35%);
--ui-loading-overlay-strong: rgb(13 15 17 / 76%);
--ui-drag-overlay: rgb(16 19 21 / 88%);
--ui-drag-shadow: 0 0 0 999px rgb(17 19 21 / 42%);
--ui-shadow-soft: 0 10px 24px rgb(0 0 0 / 20%);
--ui-shadow-panel: 0 24px 60px rgb(0 0 0 / 26%);
--ui-shadow-popover: 0 18px 48px rgb(0 0 0 / 28%);
@@ -375,6 +373,7 @@ button:disabled {
@keyframes generation-edge-soft-out { to { opacity: 0; } }
@media (prefers-reduced-motion: reduce) {
.spin { animation: none; }
.generation-edge-glow,
.generation-edge-glow *,
.generation-edge-glow *::before {
@@ -397,28 +396,64 @@ button:disabled {
.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 { min-width: 0; min-height: 0; flex: 1; background: var(--ui-viewer-bg); }
.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; }
@keyframes ui-spin { to { transform: rotate(360deg); } }
.agent-pane-title { display: flex; height: 40px; flex: 0 0 auto; align-items: center; gap: 8px; border-bottom: 1px solid var(--ui-border); color: var(--ui-text-strong); font-size: 12px; font-weight: 700; padding: 0 12px; }
.agent-pane-title svg { color: var(--ui-accent); }
.thread-viewport { min-height: 0; flex: 1; overflow-y: auto; padding: 12px; }
.message-list { display: grid; }
.message-row { display: flex; border-bottom: 1px solid var(--ui-border-muted); padding: 10px 2px; }
.user-row { justify-content: flex-end; }
.assistant-row { justify-content: flex-start; }
.message-bubble { max-width: 100%; min-width: 0; }
.user-bubble { max-width: 90%; border-radius: 5px; background: var(--ui-accent); color: var(--ui-accent-contrast); padding: 8px 10px; }
.assistant-bubble { width: 100%; color: var(--ui-text); }
.message-row { display: grid; gap: 6px; border-bottom: 1px solid var(--ui-border-muted); padding: 12px 2px; }
.user-row { padding-left: 32px; }
.message-role { display: flex; align-items: center; gap: 7px; color: var(--ui-text-muted); font-size: 11px; font-weight: 700; }
.message-role svg { color: var(--ui-text-muted); }
.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; }
.thread-empty { display: grid; gap: 7px; border: 1px dashed var(--ui-border-strong); border-radius: 5px; color: var(--ui-text-muted); font-size: 12px; line-height: 1.55; padding: 14px; }
.thread-empty strong { color: var(--ui-text-strong); }
.attachment-list { display: grid; gap: 4px; margin-bottom: 10px; border-bottom: 1px solid var(--ui-border-muted); padding-bottom: 10px; }
.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; }
.attachment-list { display: grid; gap: 4px; margin-bottom: 10px; border-bottom: 1px solid var(--ui-border-muted); padding: 0 1px 10px; }
.attachment-card { display: flex; min-width: 0; align-items: center; gap: 7px; color: var(--ui-text-muted); font-size: 11px; }
.attachment-card svg { flex: 0 0 auto; color: var(--ui-accent); }.attachment-card span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.attachment-card small { margin-left: auto; color: var(--ui-text-subtle); font-size: 10px; white-space: nowrap; }
.upload-error { display: flex; align-items: flex-start; gap: 7px; margin: 0 1px 10px; border-bottom: 1px solid var(--ui-error-border); color: var(--ui-error-text); font-size: 11px; line-height: 1.45; padding: 0 0 10px; }
.upload-error svg { flex: 0 0 auto; margin-top: 1px; }
.composer-shell { flex: 0 0 auto; border-top: 1px solid var(--ui-border); padding: 12px; }
.composer-file-input { position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; }
.composer-root { display: grid; gap: 8px; }
.composer-input { min-height: 96px; max-height: 176px; width: 100%; resize: none; border: 1px solid var(--ui-border); border-radius: 4px; background: var(--ui-control-bg); color: var(--ui-text); font-size: 12px; line-height: 1.55; outline: none; padding: 9px; }
.composer-input:focus { border-color: var(--ui-accent); }.composer-footer { display: flex; align-items: center; justify-content: space-between; gap: 8px; color: var(--ui-text-muted); font-size: 11px; }.composer-footer > span, .composer-footer > div { display: flex; align-items: center; gap: 7px; }.composer-action, .composer-send { display: grid; width: 30px; height: 30px; place-items: center; border: 1px solid var(--ui-border); border-radius: 4px; background: var(--ui-control-bg); color: var(--ui-text-muted); }.composer-send { border-color: var(--ui-accent); background: var(--ui-accent); color: var(--ui-accent-contrast); }
.cad-card { display: flex; gap: 9px; margin: 6px 0; border: 1px solid var(--ui-border); border-radius: 5px; background: var(--ui-panel-muted); padding: 9px; }.cad-card-icon { display: grid; flex: 0 0 auto; width: 25px; height: 25px; place-items: center; border-radius: 4px; background: var(--ui-accent-soft); color: var(--ui-accent); }.cad-card-body { min-width: 0; }.cad-card-title { color: var(--ui-text-strong); font-size: 12px; font-weight: 700; }.cad-card-copy, .cad-result-meta { color: var(--ui-text-muted); font-size: 11px; line-height: 1.45; overflow-wrap: anywhere; }.download-row { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 7px; }.download-link { border-bottom: 1px solid currentColor; color: var(--ui-link); font-size: 11px; text-decoration: none; }.cad-error-card { border-color: var(--ui-error-border); background: var(--ui-error-bg); }.cad-error-card .cad-card-icon { background: transparent; color: var(--ui-error-text); }
.composer-input:focus-visible { border-color: var(--ui-accent); box-shadow: 0 0 0 3px var(--ui-focus-ring); }
.composer-footer { display: flex; align-items: center; justify-content: space-between; gap: 8px; color: var(--ui-text-muted); font-size: 11px; }
.composer-footer > span, .composer-actions { display: flex; align-items: center; gap: 7px; }
.composer-actions { flex: 0 0 auto; }
.composer-command { display: inline-flex; height: 32px; align-items: center; justify-content: center; gap: 5px; border: 1px solid var(--ui-border); border-radius: 4px; background: var(--ui-control-bg); color: var(--ui-text-muted); font-size: 11px; font-weight: 600; padding: 0 9px; }
.composer-command:hover:not(:disabled) { background: var(--ui-control-hover); color: var(--ui-text); }
.composer-command:focus-visible, .download-link:focus-visible { outline: 2px solid var(--ui-focus-ring); outline-offset: 2px; }
.composer-send { border-color: var(--ui-accent); background: var(--ui-accent); color: var(--ui-accent-contrast); }
.composer-send:hover:not(:disabled) { background: var(--ui-accent-hover); color: var(--ui-accent-contrast); }
.composer-cancel { border-color: var(--ui-accent-border); background: var(--ui-accent-soft); color: var(--ui-accent-text); }
.file-drop-overlay { position: absolute; z-index: 20; inset: 8px; display: grid; place-content: center; justify-items: center; gap: 8px; border: 1px dashed var(--ui-accent); border-radius: 5px; background: var(--ui-drag-overlay); color: var(--ui-accent); font-size: 12px; font-weight: 700; pointer-events: none; }
.cad-message { display: grid; gap: 3px; margin: 9px 0 0; min-width: 0; padding-left: 2px; }
.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-message-copy { margin-left: 21px; color: var(--ui-text-muted); font-size: 11px; line-height: 1.45; overflow-wrap: anywhere; }
.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; }
.cad-result-meta { display: flex; flex-wrap: wrap; gap: 3px 10px; margin-left: 21px; color: var(--ui-text-muted); font-size: 10px; line-height: 1.4; overflow-wrap: anywhere; }
.download-row { display: flex; flex-wrap: wrap; gap: 9px; margin: 2px 0 0 21px; }
.download-link { display: inline-flex; align-items: center; gap: 4px; color: var(--ui-link); font-size: 11px; text-decoration: none; text-underline-offset: 2px; }
.download-link:hover { color: var(--ui-link-hover); text-decoration: underline; }
.cad-image-part-type { margin-left: 21px; color: var(--ui-text); font-size: 11px; font-weight: 600; line-height: 1.45; }
.image-analysis-section { display: grid; gap: 2px; margin: 4px 0 0 21px; }
.image-analysis-section > span { color: var(--ui-text-subtle); font-size: 10px; font-weight: 700; }
.image-analysis-section p { margin: 0; color: var(--ui-text-muted); font-size: 11px; line-height: 1.45; overflow-wrap: anywhere; }
.image-dimension-list { display: grid; gap: 5px; margin: 1px 0 0; padding: 0; list-style: none; }
.image-dimension-list li { display: grid; gap: 1px; color: var(--ui-text-muted); font-size: 11px; line-height: 1.4; }
.image-dimension-list strong { color: var(--ui-text); font-weight: 600; }
.image-dimension-list span { overflow-wrap: anywhere; }
.viewer-state { display: flex; height: 100%; min-height: 42vh; align-items: center; justify-content: center; gap: 9px; color: var(--ui-text-muted); font-size: 12px; }.viewer-state svg { color: var(--ui-accent); }.viewer-state-error { color: var(--ui-error-text); }.viewer-state-error svg { color: var(--ui-error-text); }
.viewer-loading { position: absolute; z-index: 40; left: 50%; top: 50%; display: flex; align-items: center; gap: 8px; transform: translate(-50%, -50%); border: 1px solid var(--ui-border); border-radius: 5px; background: var(--ui-glass-popover); color: var(--ui-text-muted); font-size: 12px; padding: 9px 12px; box-shadow: var(--ui-shadow-soft); }
.cad-viewer-dark { background: var(--ui-viewer-bg); }
@media (max-width: 767px) { .studio-app { height: auto; min-height: 100vh; overflow: visible; }.app-header { height: auto; min-height: 48px; flex-wrap: wrap; padding: 8px 12px; }.task-badge { display: none; }.app-controls { width: 100%; }.app-controls select { flex: 1; }.studio-main { min-height: 0; flex-direction: column; }.preview-pane { order: -1; min-height: 46vh; }.agent-pane { width: 100%; min-height: 560px; border-top: 1px solid var(--ui-border); border-right: 0; }.thread-viewport { max-height: 480px; }.composer-footer > span { display: none; } }
@media (max-width: 767px) { .studio-app { height: auto; min-height: 100vh; overflow: visible; }.app-header { height: auto; min-height: 48px; flex-wrap: wrap; padding: 8px 12px; }.task-badge { display: none; }.app-controls { width: 100%; }.app-controls select { flex: 1; }.studio-main { min-height: 0; flex-direction: column; }.preview-pane { min-height: 46vh; }.agent-pane { width: 100%; min-height: 560px; border-right: 0; border-bottom: 1px solid var(--ui-border); }.thread-viewport { max-height: 480px; }.composer-footer > span { display: none; } }
+15 -24
View File
@@ -34,6 +34,7 @@ export function AgentStudio() {
const [lastError, setLastError] = useState("");
const [attachments, setAttachments] = useState<CadAttachment[]>([]);
const [uploading, setUploading] = useState(false);
const [uploadError, setUploadError] = useState("");
const [providerId, setProviderId] = useState("");
const [modelId, setModelId] = useState("");
const [theme, setTheme] = useState<"light" | "dark">("light");
@@ -141,43 +142,30 @@ export function AgentStudio() {
}, []);
const handleUpload = useCallback(async (files: FileList | null) => {
if (!files?.length) return;
const selectedModel = config?.providers
.find((provider) => provider.id === providerId)
?.models.find((model) => model.id === modelId);
const includesImage = Array.from(files).some((file) => file.type.startsWith("image/") || /\.(png|jpe?g|webp)$/i.test(file.name));
if (includesImage && !selectedModel?.vision) {
setLastError("当前模型不支持图片。请选择标记为 Vision 的 OpenAI 或 Kimi 模型后再上传图片。");
return;
}
const selectedFiles = Array.from(files || []);
if (!selectedFiles.length) return;
setUploadError("");
setUploading(true);
try {
const uploaded: CadAttachment[] = [];
for (const file of Array.from(files)) {
for (const file of selectedFiles) {
const form = new FormData();
form.set("file", file);
if (selectedTaskId) form.set("task_id", selectedTaskId);
const response = await fetch("/api/uploads", { method: "POST", body: form });
const response = await fetch(`/api/conversations/${encodeURIComponent(conversationId)}/attachments`, { method: "POST", body: form });
const payload = await response.json() as CadAttachment & { error?: string };
if (!response.ok) throw new Error(payload.error || `${file.name} 上传失败`);
uploaded.push(payload);
if (!selectedTaskId) setSelectedTaskId(payload.task_id);
}
setAttachments((current) => {
const next = [...current, ...uploaded];
if (conversationId) void fetch(`/api/conversations/${encodeURIComponent(conversationId)}`, {
method: "PATCH", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ currentTaskId: selectedTaskId || uploaded[0]?.task_id || null, attachments: next }),
});
return next;
});
setAttachments((current) => [...current, ...uploaded]);
setLastError("");
} catch (error) {
setLastError(error instanceof Error ? error.message : "附件上传失败");
const message = error instanceof Error ? error.message : "附件上传失败";
setUploadError(message);
setLastError(message);
} finally {
setUploading(false);
}
}, [config, conversationId, modelId, providerId, selectedTaskId]);
}, [conversationId]);
if (loadState === "loading") {
return <StudioLoading />;
@@ -204,6 +192,7 @@ export function AgentStudio() {
lastError={lastError}
attachments={attachments}
uploading={uploading}
uploadError={uploadError}
onUpload={handleUpload}
theme={theme}
onToggleTheme={toggleTheme}
@@ -425,6 +414,7 @@ function StudioShell({
lastError,
attachments,
uploading,
uploadError,
onUpload,
theme,
onToggleTheme,
@@ -441,6 +431,7 @@ function StudioShell({
lastError: string;
attachments: CadAttachment[];
uploading: boolean;
uploadError: string;
onUpload: (files: FileList | null) => void;
theme: "light" | "dark";
onToggleTheme: () => void;
@@ -473,7 +464,7 @@ function StudioShell({
</header>
{!config?.configured ? <div className="config-warning"><AlertCircle size={16} /><span></span></div> : null}
<div className="studio-main">
<aside className="agent-pane"><AgentThread attachments={attachments} uploading={uploading} onUpload={onUpload} /></aside>
<aside className="agent-pane"><AgentThread attachments={attachments} uploading={uploading} uploadError={uploadError} onUpload={onUpload} /></aside>
<section className="preview-pane"><CadViewerPreview result={cadResult} isGenerating={running} lastError={lastError} theme={theme} onResult={onCadResult} onError={handleViewerError} onSelectionChange={onSelectionChange} /></section>
</div>
</main>
+84 -11
View File
@@ -1,59 +1,132 @@
"use client";
import { Check, FileImage, FileText, Loader2, MessageSquare, Paperclip, Send, Square } from "lucide-react";
import { useRef } from "react";
import { Bot, Check, CircleAlert, FileImage, FileText, Loader2, MessageSquare, Paperclip, Send, Sparkles, Square, Upload } from "lucide-react";
import { useRef, useState, type ChangeEvent, type DragEvent } from "react";
import { ComposerPrimitive, MessagePrimitive, ThreadPrimitive, useAuiState } from "@assistant-ui/react";
import type { CadAttachment } from "@/lib/cad-types";
import { CadErrorPart, CadProgressPart, CadResultPart, TextPart } from "./cad-message-parts";
import { CadErrorPart, CadImageAnalysisPart, CadProgressPart, CadResultPart, TextPart } from "./cad-message-parts";
export function AgentThread({ attachments, uploading, onUpload }: {
export function AgentThread({ attachments, uploading, uploadError, onUpload }: {
attachments: CadAttachment[];
uploading: boolean;
uploadError: string;
onUpload: (files: FileList | null) => void;
}) {
const fileInput = useRef<HTMLInputElement>(null);
const dragDepth = useRef(0);
const running = useAuiState((state) => state.thread.isRunning);
const [isDraggingFiles, setIsDraggingFiles] = useState(false);
const canUpload = !uploading && !running;
const onDragEnter = (event: DragEvent<HTMLDivElement>) => {
event.preventDefault();
event.stopPropagation();
dragDepth.current += 1;
if (canUpload && isFileDrag(event)) setIsDraggingFiles(true);
};
const onDragOver = (event: DragEvent<HTMLDivElement>) => {
event.preventDefault();
event.stopPropagation();
event.dataTransfer.dropEffect = canUpload ? "copy" : "none";
};
const onDragLeave = (event: DragEvent<HTMLDivElement>) => {
event.preventDefault();
event.stopPropagation();
dragDepth.current = Math.max(0, dragDepth.current - 1);
if (dragDepth.current === 0) setIsDraggingFiles(false);
};
const onDrop = (event: DragEvent<HTMLDivElement>) => {
event.preventDefault();
event.stopPropagation();
dragDepth.current = 0;
setIsDraggingFiles(false);
if (canUpload && event.dataTransfer.files.length) onUpload(event.dataTransfer.files);
};
return (
<div className="agent-thread-shell">
<div
className="agent-thread-shell"
onDragEnter={onDragEnter}
onDragOver={onDragOver}
onDragLeave={onDragLeave}
onDrop={onDrop}
>
<div className="agent-pane-title"><MessageSquare size={16} /><span>Agent</span></div>
<ThreadPrimitive.Root className="thread-root">
<ThreadPrimitive.Viewport className="thread-viewport scrollbar-thin" autoScroll>
{attachments.length ? <div className="attachment-list">{attachments.map((attachment) => <AttachmentCard key={attachment.id} attachment={attachment} />)}</div> : null}
{uploadError ? <div className="upload-error" role="alert"><CircleAlert size={14} aria-hidden="true" />{uploadError}</div> : null}
<ThreadPrimitive.Empty>
<div className="thread-empty"><strong> CAD </strong><span>Agent CDSL CDSL </span></div>
<div className="thread-empty"> CAD </div>
</ThreadPrimitive.Empty>
<div className="message-list"><ThreadPrimitive.Messages components={{ UserMessage, AssistantMessage }} /></div>
</ThreadPrimitive.Viewport>
<Composer fileInput={fileInput} uploading={uploading} onUpload={onUpload} />
</ThreadPrimitive.Root>
{isDraggingFiles ? <div className="file-drop-overlay" role="status" aria-live="polite"><Upload size={24} /><span></span></div> : null}
</div>
);
}
function isFileDrag(event: DragEvent<HTMLDivElement>) {
return event.dataTransfer.files.length > 0 || Array.from(event.dataTransfer.types).includes("Files");
}
function AttachmentCard({ attachment }: { attachment: CadAttachment }) {
const Icon = attachment.kind === "image" ? FileImage : FileText;
return <div className="attachment-card"><Icon size={14} /><span>{attachment.name}</span><small>{attachment.kind === "image" ? "视觉参考" : "文本参考"}</small></div>;
}
function UserMessage() {
return <MessagePrimitive.Root className="message-row user-row"><div className="message-bubble user-bubble"><MessagePrimitive.Parts components={{ Text: TextPart }} /></div></MessagePrimitive.Root>;
return (
<MessagePrimitive.Root className="message-row user-row">
<div className="message-role"><Sparkles size={14} aria-hidden="true" /><span>You</span></div>
<div className="message-content"><MessagePrimitive.Parts components={{ Text: TextPart }} /></div>
</MessagePrimitive.Root>
);
}
function AssistantMessage() {
return (
<MessagePrimitive.Root className="message-row assistant-row">
<div className="message-bubble assistant-bubble"><MessagePrimitive.Parts components={{ Text: TextPart, data: { by_name: { "cad-progress": CadProgressPart, "cad-result": CadResultPart, "cad-error": CadErrorPart } } }} /></div>
<div className="message-role"><Bot size={14} aria-hidden="true" /><span>Agent</span></div>
<div className="message-content">
<MessagePrimitive.Parts components={{ Text: TextPart, data: { by_name: { "cad-progress": CadProgressPart, "cad-result": CadResultPart, "cad-error": CadErrorPart, "cad-image-analysis": CadImageAnalysisPart } } }} />
<MessagePrimitive.Error>
<div className="message-request-error" role="alert"><CircleAlert size={14} aria-hidden="true" /> Agent </div>
</MessagePrimitive.Error>
</div>
</MessagePrimitive.Root>
);
}
function Composer({ fileInput, uploading, onUpload }: { fileInput: React.RefObject<HTMLInputElement | null>; uploading: boolean; onUpload: (files: FileList | null) => void }) {
const running = useAuiState((state) => state.thread.isRunning);
const handleFileChange = (event: ChangeEvent<HTMLInputElement>) => {
if (event.currentTarget.files?.length) onUpload(event.currentTarget.files);
event.currentTarget.value = "";
};
return (
<div className="composer-shell">
<input ref={fileInput} className="hidden" type="file" accept=".png,.jpg,.jpeg,.webp,.txt,.md,.csv,.json" multiple onChange={(event) => onUpload(event.target.files)} />
<input ref={fileInput} className="composer-file-input" type="file" accept=".png,.jpg,.jpeg,.webp,.txt,.md,.csv,.json" multiple tabIndex={-1} aria-hidden="true" onChange={handleFileChange} />
<ComposerPrimitive.Root className="composer-root">
<ComposerPrimitive.Input className="composer-input" placeholder="描述要生成或修改的 CAD 模型..." submitMode="enter" rows={4} />
<div className="composer-footer"><span><Check size={14} /> Enter Shift + Enter </span><div>{running ? <ComposerPrimitive.Cancel className="composer-action" title="停止生成"><Square size={15} /></ComposerPrimitive.Cancel> : <button type="button" className="composer-action" title="上传图片或文档" disabled={uploading} onClick={() => fileInput.current?.click()}>{uploading ? <Loader2 className="spin" size={15} /> : <Paperclip size={15} />}</button>}<ComposerPrimitive.Send className="composer-send" title="发送"><Send size={16} /></ComposerPrimitive.Send></div></div>
<ComposerPrimitive.Input aria-label="CAD 请求" className="composer-input" placeholder="描述要生成或修改的 CAD 模型..." submitMode="enter" rows={4} />
<div className="composer-footer">
<span><Check size={14} aria-hidden="true" /> Enter Shift + Enter </span>
<div className="composer-actions">
{running ? (
<ComposerPrimitive.Cancel className="composer-command composer-cancel" title="停止生成" aria-label="停止生成"><Square size={14} fill="currentColor" aria-hidden="true" /></ComposerPrimitive.Cancel>
) : (
<>
<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>
<ComposerPrimitive.Send className="composer-command composer-send" title="发送" aria-label="发送"><Send size={14} aria-hidden="true" /></ComposerPrimitive.Send>
</>
)}
</div>
</div>
</ComposerPrimitive.Root>
</div>
);
+67 -37
View File
@@ -1,8 +1,8 @@
"use client";
import { AlertTriangle, CheckCircle2, Download, Loader2 } from "lucide-react";
import { AlertTriangle, Box, Check, Download, Loader2, Ruler } from "lucide-react";
import { encodeArtifactUrl } from "@/lib/cad-artifacts";
import type { CadError, CadProgress, CadResult } from "@/lib/cad-types";
import type { CadError, CadImageAnalysis, CadProgress, CadResult } from "@/lib/cad-types";
export function TextPart({ text }: { text: string }) {
if (!text.trim()) return null;
@@ -10,16 +10,19 @@ export function TextPart({ text }: { text: string }) {
}
export function CadProgressPart({ data }: { data: CadProgress }) {
const running = data.status === "running";
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;
return (
<div className="cad-card cad-progress-card">
<div className="cad-card-icon" data-status={data.status}>
{running ? <Loader2 className="spin" size={16} /> : <CheckCircle2 size={16} />}
</div>
<div className="cad-card-body">
<div className="cad-card-title">{data.label || data.step}</div>
{data.message ? <div className="cad-card-copy">{data.message}</div> : null}
<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" />}
<span>{data.label || data.step}</span>
<span className="cad-status-label">{statusLabel}</span>
</div>
{data.message ? <div className="cad-message-copy">{data.message}</div> : null}
</div>
);
}
@@ -29,44 +32,71 @@ export function CadResultPart({ data }: { data: CadResult }) {
["STEP", data.stepPath],
["CDSL", data.cdslPath],
["GLB", data.glbPath],
["REPORT", data.reportPath],
["报告", data.reportPath],
];
if (data.designIntentPath) downloads.push(["DESIGN INTENT", data.designIntentPath]);
return (
<div className="cad-card cad-result-card">
<div className="cad-card-icon success">
<CheckCircle2 size={16} />
<section className="cad-message cad-result" aria-label="CAD 结果">
<div className="cad-message-heading">
<Box size={14} aria-hidden="true" />
<span>CAD </span>
</div>
<div className="cad-card-body">
<div className="cad-card-title">{data.summary || "生成完成"}</div>
<div className="cad-result-meta">
<div className="cad-result-title">{data.summary || "生成完成"}</div>
<div className="cad-result-meta">
<span>{data.engine}</span>
<span>{data.revisionId}</span>
<span>{data.referenceIds.length} references</span>
</div>
<div className="download-row">
{downloads.map(([label, path]) => (
<a key={label} className="download-link" href={encodeArtifactUrl(data.taskId, path)} download>
<Download size={14} />
{label}
</a>
))}
</div>
<span>{data.referenceIds.length} </span>
</div>
</div>
<div className="download-row">
{downloads.map(([label, path]) => (
<a key={label} className="download-link" href={encodeArtifactUrl(data.taskId, path)} download aria-label={`下载 ${label}`}>
<Download size={13} aria-hidden="true" />
{label}
</a>
))}
</div>
</section>
);
}
export function CadImageAnalysisPart({ data }: { data: CadImageAnalysis }) {
const dimensionCandidates = data.dimensionCandidates ?? data.requiredDimensions ?? [];
return (
<section className="cad-message cad-image-analysis" aria-label="图像分析">
<div className="cad-message-heading"><Ruler size={14} aria-hidden="true" /><span></span></div>
<div className="cad-image-part-type">{data.partType}</div>
<div className="image-analysis-section">
<span></span>
<p>{data.visibleFeatures.join("") || "未识别到可确认特征"}</p>
</div>
{data.uncertainFeatures.length ? <div className="image-analysis-section">
<span></span>
<p>{data.uncertainFeatures.join("")}</p>
</div> : null}
{dimensionCandidates.length ? <div className="image-analysis-section">
<span></span>
<ul className="image-dimension-list">
{dimensionCandidates.map((dimension) => <li key={dimension.id}>
<strong>{dimension.label}</strong>
<span>{dimension.reason}</span>
</li>)}
</ul>
</div> : null}
</section>
);
}
export function CadErrorPart({ data }: { data: CadError }) {
const stageLabels: Record<string, string> = {
agent: "Agent",
attachment: "附件",
chat: "对话",
viewer: "预览",
};
const stage = stageLabels[data.stage] || data.stage || "CAD 操作";
return (
<div className="cad-card cad-error-card">
<div className="cad-card-icon error">
<AlertTriangle size={16} />
</div>
<div className="cad-card-body">
<div className="cad-card-title">{data.stage || "生成失败"}</div>
<div className="cad-card-copy">{data.message}</div>
</div>
<div className="cad-message cad-error" role="alert">
<div className="cad-message-heading"><AlertTriangle size={14} aria-hidden="true" /><span>{stage}</span></div>
<div className="cad-message-copy">{data.message}</div>
</div>
);
}
@@ -51,8 +51,6 @@ function resultFromBackend(payload: Record<string, unknown>): CadResult {
parametersPath: typeof payload.parameters_path === "string" ? payload.parameters_path : undefined,
selectorPath: typeof payload.selector_path === "string" ? payload.selector_path : undefined,
edgesPath: typeof payload.edges_path === "string" ? payload.edges_path : undefined,
designIntentId: typeof payload.design_intent_id === "string" ? payload.design_intent_id : undefined,
designIntentPath: typeof payload.design_intent_path === "string" ? payload.design_intent_path : undefined,
summary: String(payload.summary || "Updated CDSL model"),
referenceIds: Array.isArray(payload.reference_ids) ? payload.reference_ids.map(String) : [],
engine: String(payload.engine || "cdsl_only"),
-2
View File
@@ -31,8 +31,6 @@ export function latestSuccessfulResult(task: TaskRecord | null): CadResult | nul
parametersPath: current.parameters_path,
selectorPath: current.selector_path,
edgesPath: current.edges_path,
designIntentId: current.design_intent_id,
designIntentPath: current.design_intent_path,
summary: current.summary || "CDSL CAD model",
referenceIds: current.reference_ids || [],
engine: current.engine || "cdsl_only",
+4 -1
View File
@@ -1,4 +1,4 @@
import type { CadError, CadProgress, CadResult, CadUIMessage } from "./cad-types";
import type { CadError, CadImageAnalysis, CadProgress, CadResult, CadUIMessage } from "./cad-types";
type AnyPart = { type?: unknown; text?: unknown; data?: unknown; id?: unknown };
type AnyMessage = { id?: unknown; role?: unknown; parts?: unknown };
@@ -24,6 +24,9 @@ export function normalizeCadMessages(input: unknown): CadUIMessage[] {
if (item.type === "data-cad-error") {
return [{ type: "data-cad-error", id: stringId(item.id), data: item.data as CadError }];
}
if (item.type === "data-cad-image-analysis") {
return [{ type: "data-cad-image-analysis", id: stringId(item.id), data: item.data as CadImageAnalysis }];
}
return [];
});
return [{
+14
View File
@@ -14,6 +14,20 @@ test("maps backend cad_result SSE into an AI SDK data part", () => {
assert.deepEqual("data" in chunk! ? chunk.data : null, { taskId: "cad_abc", revisionId: "rev_001" });
});
test("maps structured image analysis SSE into an AI SDK data part", () => {
const data = {
attachmentIds: ["upload_flange"],
partType: "四孔法兰套筒",
visibleFeatures: ["中空圆筒"],
uncertainFeatures: [],
dimensionCandidates: [{ id: "bore_diameter", label: "中心孔直径", reason: "图片未标注" }],
};
const chunk = backendEventToUiChunk({ event: "image_analysis", data }, "text_1");
assert.equal(chunk?.type, "data-cad-image-analysis");
assert.deepEqual("data" in chunk! ? chunk.data : null, data);
});
test("restores the latest successful task revision for the viewer", () => {
const result = latestSuccessfulResult({
task_id: "cad_abc",
+7
View File
@@ -33,5 +33,12 @@ export function backendEventToUiChunk(
data: item.data,
};
}
if (item.event === "image_analysis") {
return {
type: "data-cad-image-analysis",
id: `image_analysis_${Date.now()}`,
data: item.data,
};
}
return null;
}
+18 -6
View File
@@ -17,8 +17,6 @@ export type CadResult = {
parametersPath?: string;
selectorPath?: string;
edgesPath?: string;
designIntentId?: string;
designIntentPath?: string;
summary: string;
referenceIds: string[];
engine: string;
@@ -29,10 +27,27 @@ export type CadError = {
message: string;
};
export type CadImageDimension = {
id: string;
label: string;
reason: string;
};
export type CadImageAnalysis = {
attachmentIds: string[];
partType: string;
visibleFeatures: string[];
uncertainFeatures: string[];
dimensionCandidates?: CadImageDimension[];
// Legacy conversations stored this field before dimensions became optional.
requiredDimensions?: CadImageDimension[];
};
export type CadDataParts = {
"cad-progress": CadProgress;
"cad-result": CadResult;
"cad-error": CadError;
"cad-image-analysis": CadImageAnalysis;
};
export type CadUIMessage = UIMessage<unknown, CadDataParts>;
@@ -46,7 +61,7 @@ export type ConversationRecord = {
export type CadAttachment = {
id: string;
task_id: string;
conversation_id: string;
name: string;
kind: "image" | "document";
path: string;
@@ -66,8 +81,6 @@ export type TaskRevision = {
parameters_path?: string;
selector_path?: string;
edges_path?: string;
design_intent_id?: string;
design_intent_path?: string;
summary?: string;
reference_ids?: string[];
engine?: string;
@@ -77,7 +90,6 @@ export type TaskRevision = {
export type TaskRecord = {
task_id: string;
current_revision: string;
current_design_intent_id?: string;
revisions: TaskRevision[];
};