优化agent
This commit is contained in:
+3
-2
@@ -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 });
|
||||
|
||||
@@ -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; } }
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user