Files
cdsl-cad/frontend/src/components/agent-thread.tsx
T
2026-08-31 14:16:08 +08:00

142 lines
7.1 KiB
TypeScript

"use client";
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, 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, 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);
const running = useAuiState((state) => state.thread.isRunning) || taskRunning;
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"
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">描述需要生成或修改的 CAD 模型。</div>
</ThreadPrimitive.Empty>
<div className="message-list"><ThreadPrimitive.Messages components={{ UserMessage, AssistantMessage }} /></div>
</ThreadPrimitive.Viewport>
<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>
);
}
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-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-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 } } }} />
<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, 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 = "";
};
return (
<div className="composer-shell">
<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 aria-label="CAD 请求" className="composer-input" placeholder={running ? "CAD 正在生成,任务结束后可继续对话" : "描述要生成或修改的 CAD 模型..."} submitMode="enter" rows={4} disabled={running} />
<div className="composer-footer">
<span><Check size={14} aria-hidden="true" /> Enter 发送,Shift + Enter 换行</span>
<div className="composer-actions">
{running ? (
<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>
<ComposerPrimitive.Send className="composer-command composer-send" title="发送" aria-label="发送"><Send size={14} aria-hidden="true" />发送</ComposerPrimitive.Send>
</>
)}
</div>
</div>
</ComposerPrimitive.Root>
</div>
);
}