142 lines
7.1 KiB
TypeScript
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>
|
|
);
|
|
}
|