- ToolCallStatus 组件增强 - main.tsx P0 交互改进(textarea/光标/编辑消息) - 更新测试报告:9/12 通过,3 个外部服务问题 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
687 lines
28 KiB
TypeScript
687 lines
28 KiB
TypeScript
import { createRoot } from "react-dom/client";
|
|
import { useStream } from "@langchain/langgraph-sdk/react";
|
|
import { LoadExternalComponent } from "@langchain/langgraph-sdk/react-ui";
|
|
import { Client } from "@langchain/langgraph-sdk";
|
|
import type { Message } from "@langchain/langgraph-sdk";
|
|
|
|
// UIMessage is not exported directly — use a local shape
|
|
type UIMsgLocal = { id: string; type: string; name: string; props: Record<string, unknown>; metadata?: { message_id?: string } };
|
|
import { useState, useRef, useEffect, useCallback } from "react";
|
|
import ComponentMap from "./agent-uis/index.tsx";
|
|
import "./index.css";
|
|
import { BookOpen, Search, Terminal, Ticket, Zap, Cpu, Bot, Menu, Copy, Check, RefreshCw, Square, Pencil, ChevronDown } from "lucide-react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { cn } from "@/lib/utils";
|
|
import { ThreadSidebar, type ThreadItem } from "@/components/ThreadSidebar.tsx";
|
|
import { ThemeProvider } from "next-themes";
|
|
import ThemeToggle from "@/components/ThemeToggle.tsx";
|
|
import MessageBubble from "@/components/MessageBubble.tsx";
|
|
import CanvasPanel, { type CanvasDoc } from "@/components/CanvasPanel.tsx";
|
|
import FileUploadButton, { type SelectedFile } from "@/components/FileUploadButton.tsx";
|
|
import FileAttachmentPreview from "@/components/FileAttachmentPreview.tsx";
|
|
import ToolCallStatus from "@/components/ToolCallStatus.tsx";
|
|
|
|
const LANGGRAPH_URL =
|
|
import.meta.env.VITE_LANGGRAPH_URL ?? "http://localhost:2024";
|
|
|
|
// ─── Tool groups ────────────────────────────────────────────────────────────
|
|
const TOOL_GROUPS = [
|
|
{ key: "knowledge", label: "知识库", icon: BookOpen, tools: ["kb_search"] },
|
|
{ key: "tickets", label: "工单", icon: Ticket, tools: ["ticket_list", "ticket_detail"] },
|
|
{ key: "search", label: "搜索", icon: Search, tools: ["web_search", "google_search"] },
|
|
{ key: "sandbox", label: "代码", icon: Terminal, tools: ["sandbox_run"] },
|
|
] as const;
|
|
|
|
type ToolKey = (typeof TOOL_GROUPS)[number]["key"];
|
|
type ModelMode = "flash" | "auto" | "pro";
|
|
|
|
const MODEL_OPTIONS: { value: ModelMode; label: string; icon: React.FC<{ className?: string }> }[] = [
|
|
{ value: "flash", label: "Flash", icon: Zap },
|
|
{ value: "auto", label: "Auto", icon: Bot },
|
|
{ value: "pro", label: "Pro", icon: Cpu },
|
|
];
|
|
|
|
// ─── Quick prompts ───────────────────────────────────────────────────────────
|
|
const QUICK_PROMPTS = [
|
|
{ icon: BookOpen, label: "搜索知识库", prompt: "帮我搜索知识库中关于" },
|
|
{ icon: Ticket, label: "查看最新工单", prompt: "查看最新的工单列表" },
|
|
{ icon: Search, label: "搜索网络", prompt: "帮我搜索" },
|
|
{ icon: Terminal, label: "运行代码", prompt: "用 Python 写一段代码" },
|
|
];
|
|
|
|
// ─── LangGraph Client (for thread management) ───────────────────────────────
|
|
const client = new Client({ apiUrl: LANGGRAPH_URL });
|
|
|
|
// ─── Copy button for AI messages ────────────────────────────────────────────
|
|
function CopyButton({ text }: { text: string }) {
|
|
const [copied, setCopied] = useState(false);
|
|
const handleCopy = () => {
|
|
navigator.clipboard.writeText(text).then(() => {
|
|
setCopied(true);
|
|
setTimeout(() => setCopied(false), 2000);
|
|
});
|
|
};
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={handleCopy}
|
|
className="opacity-0 group-hover:opacity-100 transition-opacity inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground px-1.5 py-0.5 rounded hover:bg-accent"
|
|
title="复制"
|
|
>
|
|
{copied ? (
|
|
<Check className="size-3.5 text-green-500" />
|
|
) : (
|
|
<Copy className="size-3.5" />
|
|
)}
|
|
{copied ? "已复制" : "复制"}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
function App() {
|
|
const [input, setInput] = useState("");
|
|
const bottomRef = useRef<HTMLDivElement>(null);
|
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
|
const [showScrollBtn, setShowScrollBtn] = useState(false);
|
|
|
|
// Tool & model state
|
|
const [activeTools, setActiveTools] = useState<Set<ToolKey>>(new Set());
|
|
const [modelMode, setModelMode] = useState<ModelMode>("auto");
|
|
|
|
// Thread sidebar state
|
|
const [threads, setThreads] = useState<ThreadItem[]>([]);
|
|
const [currentThreadId, setCurrentThreadId] = useState<string | null>(null);
|
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
|
|
|
// Canvas panel state
|
|
const [canvasDoc, setCanvasDoc] = useState<CanvasDoc | null>(null);
|
|
|
|
// File attachment state
|
|
const [attachedFile, setAttachedFile] = useState<SelectedFile | null>(null);
|
|
|
|
const thread = useStream<{ messages: Message[]; ui: UIMsgLocal[] }>({
|
|
apiUrl: LANGGRAPH_URL,
|
|
assistantId: "agent",
|
|
messagesKey: "messages",
|
|
threadId: currentThreadId ?? undefined,
|
|
});
|
|
|
|
// Auto-scroll to bottom only when user is near the bottom
|
|
useEffect(() => {
|
|
const el = scrollContainerRef.current;
|
|
if (!el) return;
|
|
const isNearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 200;
|
|
if (isNearBottom) {
|
|
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
|
|
}
|
|
}, [thread.messages]);
|
|
|
|
// Listen for open-canvas events from Gen-UI cards
|
|
useEffect(() => {
|
|
const handler = (e: Event) => setCanvasDoc((e as CustomEvent<CanvasDoc>).detail);
|
|
window.addEventListener("open-canvas", handler);
|
|
return () => window.removeEventListener("open-canvas", handler);
|
|
}, []);
|
|
|
|
// Load threads on mount
|
|
useEffect(() => {
|
|
client.threads
|
|
.search({ limit: 50 })
|
|
.then((list: any) => setThreads(list as ThreadItem[]))
|
|
.catch(() => {/* graceful degradation — sidebar stays empty */});
|
|
}, []);
|
|
|
|
// ── Thread actions ──────────────────────────────────────────────────────
|
|
const handleNewThread = useCallback(async () => {
|
|
try {
|
|
const t = await client.threads.create();
|
|
setThreads((prev) => [t as ThreadItem, ...prev]);
|
|
setCurrentThreadId(t.thread_id);
|
|
} catch {
|
|
// If create fails, just clear threadId so useStream creates one implicitly
|
|
setCurrentThreadId(null);
|
|
}
|
|
setSidebarOpen(false);
|
|
}, []);
|
|
|
|
const handleSelectThread = useCallback((threadId: string) => {
|
|
setCurrentThreadId(threadId);
|
|
setSidebarOpen(false);
|
|
}, []);
|
|
|
|
const handleDeleteThread = useCallback(async (threadId: string) => {
|
|
try {
|
|
await client.threads.delete(threadId);
|
|
} catch {
|
|
// ignore errors
|
|
}
|
|
setThreads((prev) => prev.filter((t) => t.thread_id !== threadId));
|
|
if (currentThreadId === threadId) {
|
|
setCurrentThreadId(null);
|
|
}
|
|
}, [currentThreadId]);
|
|
|
|
// Auto-resize textarea
|
|
useEffect(() => {
|
|
const el = textareaRef.current;
|
|
if (!el) return;
|
|
el.style.height = "auto";
|
|
el.style.height = Math.min(el.scrollHeight, 200) + "px";
|
|
}, [input]);
|
|
|
|
// ── Tool toggle ─────────────────────────────────────────────────────────
|
|
function toggleTool(key: ToolKey) {
|
|
setActiveTools((prev) => {
|
|
const next = new Set(prev);
|
|
if (next.has(key)) {
|
|
next.delete(key);
|
|
} else {
|
|
next.add(key);
|
|
}
|
|
return next;
|
|
});
|
|
}
|
|
|
|
// ── Submit ──────────────────────────────────────────────────────────────
|
|
async function handleSubmit(e: React.FormEvent) {
|
|
e.preventDefault();
|
|
const text = input.trim();
|
|
if ((!text && !attachedFile) || thread.isLoading) return;
|
|
setInput("");
|
|
|
|
const enabledTools =
|
|
activeTools.size > 0
|
|
? TOOL_GROUPS.filter((g) => activeTools.has(g.key)).flatMap((g) => [...g.tools])
|
|
: [];
|
|
|
|
const file = attachedFile;
|
|
setAttachedFile(null);
|
|
|
|
const IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"];
|
|
let messageContent: unknown;
|
|
|
|
if (file && IMAGE_TYPES.includes(file.mimeType)) {
|
|
messageContent = [
|
|
{ type: "image_url", image_url: { url: `data:${file.mimeType};base64,${file.base64}` } },
|
|
{ type: "text", text: text || "请分析这张图片" },
|
|
];
|
|
} else if (file) {
|
|
messageContent = [
|
|
{ type: "text", text: `[附件: ${file.name}]\n\n${text || "请分析这个文件"}` },
|
|
];
|
|
} else {
|
|
messageContent = text;
|
|
}
|
|
|
|
// Auto-name thread on first message
|
|
const isFirstMessage = thread.messages.length === 0;
|
|
|
|
thread.submit(
|
|
{ messages: [{ type: "human", content: messageContent }] },
|
|
{
|
|
config: {
|
|
configurable: {
|
|
enabledTools,
|
|
modelMode,
|
|
...(file ? { attachedFile: { name: file.name, mimeType: file.mimeType, base64: file.base64, size: file.size } } : {}),
|
|
},
|
|
},
|
|
},
|
|
);
|
|
|
|
if (isFirstMessage && currentThreadId) {
|
|
client.threads.update(currentThreadId, { metadata: { title: text.slice(0, 30) } }).catch(() => {});
|
|
// Optimistically update local thread title
|
|
setThreads((prev) =>
|
|
prev.map((t) =>
|
|
t.thread_id === currentThreadId
|
|
? { ...t, metadata: { ...t.metadata, title: text.slice(0, 30) } }
|
|
: t,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// ── Regenerate last AI message ───────────────────────────────────────────
|
|
function handleRegenerate() {
|
|
// Find last human message
|
|
const humanMsgs = thread.messages.filter((m) => m.type === "human");
|
|
const lastHuman = humanMsgs[humanMsgs.length - 1];
|
|
if (!lastHuman || thread.isLoading) return;
|
|
|
|
const enabledTools =
|
|
activeTools.size > 0
|
|
? TOOL_GROUPS.filter((g) => activeTools.has(g.key)).flatMap((g) => [...g.tools])
|
|
: [];
|
|
|
|
thread.submit(
|
|
{ messages: [{ type: "human", content: lastHuman.content }] },
|
|
{
|
|
config: {
|
|
configurable: { enabledTools, modelMode },
|
|
},
|
|
},
|
|
);
|
|
}
|
|
|
|
// ── Drag and drop ────────────────────────────────────────────────────────
|
|
function handleDragOver(e: React.DragEvent) {
|
|
e.preventDefault();
|
|
}
|
|
|
|
function handleDrop(e: React.DragEvent) {
|
|
e.preventDefault();
|
|
const file = e.dataTransfer.files?.[0];
|
|
if (!file) return;
|
|
const reader = new FileReader();
|
|
reader.onload = () => {
|
|
const dataUrl = reader.result as string;
|
|
const base64 = dataUrl.split(",")[1];
|
|
setAttachedFile({ name: file.name, mimeType: file.type, base64, size: file.size });
|
|
};
|
|
reader.readAsDataURL(file);
|
|
}
|
|
|
|
// ── Paste image ──────────────────────────────────────────────────────────
|
|
function handlePaste(e: React.ClipboardEvent) {
|
|
const items = Array.from(e.clipboardData.items);
|
|
const imageItem = items.find((item) => item.type.startsWith("image/"));
|
|
if (!imageItem) return;
|
|
const file = imageItem.getAsFile();
|
|
if (!file) return;
|
|
const reader = new FileReader();
|
|
reader.onload = () => {
|
|
const dataUrl = reader.result as string;
|
|
const base64 = dataUrl.split(",")[1];
|
|
setAttachedFile({ name: file.name || "pasted-image.png", mimeType: file.type, base64, size: file.size });
|
|
};
|
|
reader.readAsDataURL(file);
|
|
}
|
|
|
|
// ── Collect completed tool call IDs ─────────────────────────────────────
|
|
const completedToolIds = new Set(
|
|
thread.messages
|
|
.filter((m) => m.type === "tool")
|
|
.map((m) => (m as any).tool_call_id as string)
|
|
.filter(Boolean),
|
|
);
|
|
|
|
// ── Find last AI message index ──────────────────────────────────────────
|
|
const lastAiIdx = thread.messages.reduce((last, m, i) => (m.type === "ai" ? i : last), -1);
|
|
|
|
return (
|
|
<div className="h-screen flex flex-row bg-background text-foreground overflow-hidden">
|
|
{/* ── Mobile sidebar overlay backdrop ── */}
|
|
{sidebarOpen && (
|
|
<div
|
|
className="fixed inset-0 z-20 bg-black/40 md:hidden"
|
|
onClick={() => setSidebarOpen(false)}
|
|
/>
|
|
)}
|
|
|
|
{/* ── Left sidebar ── */}
|
|
<div
|
|
className={cn(
|
|
"z-30 md:relative md:flex md:w-64",
|
|
sidebarOpen
|
|
? "fixed inset-y-0 left-0 flex w-64"
|
|
: "hidden md:flex",
|
|
)}
|
|
>
|
|
<ThreadSidebar
|
|
threads={threads}
|
|
currentThreadId={currentThreadId}
|
|
onNewThread={handleNewThread}
|
|
onSelectThread={handleSelectThread}
|
|
onDeleteThread={handleDeleteThread}
|
|
/>
|
|
</div>
|
|
|
|
{/* ── Right main area ── */}
|
|
<div className="flex-1 flex flex-row min-w-0 overflow-hidden">
|
|
<div className="flex-1 flex flex-col min-w-0 relative" onDragOver={handleDragOver} onDrop={handleDrop}>
|
|
{/* Header */}
|
|
<header className="shrink-0 border-b border-border px-4 py-3 flex items-center gap-3">
|
|
{/* Hamburger for mobile */}
|
|
<button
|
|
type="button"
|
|
className="md:hidden p-1 rounded text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
|
|
onClick={() => setSidebarOpen((o) => !o)}
|
|
aria-label="打开侧栏"
|
|
>
|
|
<Menu className="size-5" />
|
|
</button>
|
|
|
|
<span className="font-semibold text-foreground">运营大脑</span>
|
|
{thread.isLoading && (
|
|
<span className="text-xs text-muted-foreground animate-pulse">
|
|
思考中…
|
|
</span>
|
|
)}
|
|
<div className="ml-auto">
|
|
<ThemeToggle />
|
|
</div>
|
|
</header>
|
|
|
|
{/* Messages */}
|
|
<div
|
|
ref={scrollContainerRef}
|
|
className="flex-1 overflow-y-auto px-4 py-6 space-y-6 relative"
|
|
onScroll={() => {
|
|
const el = scrollContainerRef.current;
|
|
if (!el) return;
|
|
setShowScrollBtn(el.scrollHeight - el.scrollTop - el.clientHeight > 200);
|
|
}}
|
|
>
|
|
{thread.messages.length === 0 && (
|
|
<div className="flex flex-col items-center justify-center h-full gap-4 text-muted-foreground select-none">
|
|
<div className="flex flex-col items-center gap-2">
|
|
<p className="text-lg font-medium">你好,有什么可以帮你的?</p>
|
|
<p className="text-sm">可以查询知识库、工单、搜索网络或执行代码。</p>
|
|
</div>
|
|
{/* Quick prompts */}
|
|
<div className="grid grid-cols-2 gap-2 w-full max-w-sm mt-2">
|
|
{QUICK_PROMPTS.map(({ icon: Icon, label, prompt }) => (
|
|
<button
|
|
key={label}
|
|
type="button"
|
|
onClick={() => setInput(prompt)}
|
|
className="border border-border rounded-lg p-3 hover:bg-accent cursor-pointer text-left transition-colors flex items-start gap-2"
|
|
>
|
|
<Icon className="size-4 shrink-0 mt-0.5 text-muted-foreground" />
|
|
<span className="text-xs font-medium text-foreground">{label}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{thread.messages.map((message, idx) => {
|
|
// Render UI cards attached to this message
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const uiItems = ((thread.values as any)?.ui ?? []).filter(
|
|
(ui: UIMsgLocal) => ui.metadata?.message_id === message.id,
|
|
) as UIMsgLocal[];
|
|
|
|
if (message.type === "human") {
|
|
const humanText = typeof message.content === "string"
|
|
? message.content
|
|
: Array.isArray(message.content)
|
|
? (message.content as any[]).filter((p) => p.type === "text").map((p) => p.text).join("")
|
|
: "";
|
|
return (
|
|
<div key={message.id ?? idx} className="group flex justify-end items-end gap-2">
|
|
{/* Edit button — appears on hover, refills input */}
|
|
{humanText && !thread.isLoading && (
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setInput(humanText);
|
|
setTimeout(() => textareaRef.current?.focus(), 0);
|
|
}}
|
|
className="opacity-0 group-hover:opacity-100 transition-opacity mb-1 p-1 rounded text-muted-foreground hover:text-foreground hover:bg-accent"
|
|
title="编辑消息"
|
|
>
|
|
<Pencil className="size-3.5" />
|
|
</button>
|
|
)}
|
|
<div className="max-w-[75%] rounded-2xl rounded-br-sm bg-primary text-primary-foreground px-4 py-2.5 text-sm whitespace-pre-wrap">
|
|
{Array.isArray(message.content) ? (
|
|
<div className="flex flex-col gap-2">
|
|
{(message.content as any[]).map((part, pi) => {
|
|
if (part.type === "image_url") {
|
|
const url = part.image_url?.url ?? part.image_url;
|
|
return (
|
|
<img
|
|
key={pi}
|
|
src={url}
|
|
alt="附件图片"
|
|
className="max-h-48 rounded-lg object-contain"
|
|
/>
|
|
);
|
|
}
|
|
if (part.type === "text") {
|
|
return <span key={pi}>{part.text}</span>;
|
|
}
|
|
return null;
|
|
})}
|
|
</div>
|
|
) : (
|
|
typeof message.content === "string"
|
|
? message.content
|
|
: JSON.stringify(message.content)
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (message.type === "ai") {
|
|
const textContent =
|
|
typeof message.content === "string"
|
|
? message.content
|
|
: Array.isArray(message.content)
|
|
? message.content
|
|
.filter((c) => c.type === "text")
|
|
.map((c) => ("text" in c ? c.text : ""))
|
|
.join("")
|
|
: "";
|
|
|
|
const toolCalls: { name?: string; id?: string }[] = (message as any).tool_calls ?? [];
|
|
const isLastAi = idx === lastAiIdx;
|
|
|
|
return (
|
|
<div key={message.id ?? idx} className="flex flex-col gap-3">
|
|
{/* Tool call status (with inline expand/collapse for UI cards) */}
|
|
{toolCalls.length > 0 && (
|
|
<ToolCallStatus
|
|
toolCalls={toolCalls}
|
|
isLoading={thread.isLoading}
|
|
completedToolIds={completedToolIds}
|
|
uiItems={uiItems}
|
|
stream={thread}
|
|
components={ComponentMap as any}
|
|
/>
|
|
)}
|
|
|
|
{/* UI cards not matched to any tool call (standalone) */}
|
|
{uiItems
|
|
.filter((ui) => !toolCalls.some((tc) => {
|
|
const nameMap: Record<string, string> = {
|
|
kb_search: "knowledge-result",
|
|
ticket_list: "ticket-summary",
|
|
ticket_detail: "ticket-detail",
|
|
web_search: "search-result",
|
|
google_search: "search-result",
|
|
sandbox_run: "sandbox-result",
|
|
};
|
|
return tc.name && ui.name === nameMap[tc.name];
|
|
}))
|
|
.map((ui: UIMsgLocal) => (
|
|
<LoadExternalComponent
|
|
key={ui.id}
|
|
stream={thread}
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
message={ui as any}
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
components={ComponentMap as any}
|
|
/>
|
|
))}
|
|
|
|
{/* Text reply */}
|
|
{textContent && (
|
|
<div className="group relative max-w-[85%] rounded-2xl rounded-bl-sm bg-muted text-foreground px-4 py-2.5 text-sm">
|
|
<MessageBubble content={textContent} role="ai" />
|
|
{thread.isLoading && isLastAi && (
|
|
<span className="typing-cursor" aria-hidden="true" />
|
|
)}
|
|
{/* Copy button */}
|
|
<div className="flex justify-end mt-1">
|
|
<CopyButton text={textContent} />
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Regenerate button — only on last AI message, only when not loading */}
|
|
{isLastAi && !thread.isLoading && (
|
|
<div className="flex">
|
|
<button
|
|
type="button"
|
|
onClick={handleRegenerate}
|
|
className="inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors px-2 py-1 rounded hover:bg-accent"
|
|
>
|
|
<RefreshCw className="size-3.5" />
|
|
重新生成
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return null;
|
|
})}
|
|
|
|
{/* Streaming UI cards not yet attached to a completed message */}
|
|
{thread.isLoading &&
|
|
(thread.values?.ui ?? [])
|
|
.filter((ui: UIMsgLocal) => {
|
|
const attachedToExisting = thread.messages.some(
|
|
(m) => m.id === ui.metadata?.message_id,
|
|
);
|
|
return !attachedToExisting;
|
|
})
|
|
.map((ui: UIMsgLocal) => (
|
|
<LoadExternalComponent
|
|
key={ui.id}
|
|
stream={thread}
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
message={ui as any}
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
components={ComponentMap as any}
|
|
/>
|
|
))}
|
|
|
|
<div ref={bottomRef} />
|
|
</div>
|
|
|
|
{/* Scroll-to-bottom floating button */}
|
|
{showScrollBtn && (
|
|
<div className="absolute bottom-32 left-1/2 -translate-x-1/2 z-10">
|
|
<button
|
|
type="button"
|
|
onClick={() => bottomRef.current?.scrollIntoView({ behavior: "smooth" })}
|
|
className="rounded-full bg-background border border-border shadow-md p-2 text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
|
|
title="滚动到底部"
|
|
>
|
|
<ChevronDown className="size-4" />
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Control bar: tools (left) + model selector (right) */}
|
|
<div className="shrink-0 border-t border-border px-4 pt-3 pb-0 flex items-center justify-between max-w-3xl mx-auto w-full">
|
|
{/* Tool toggles */}
|
|
<div className="flex items-center gap-1.5">
|
|
{TOOL_GROUPS.map(({ key, label, icon: Icon }) => {
|
|
const isOn = activeTools.has(key);
|
|
return (
|
|
<button
|
|
key={key}
|
|
type="button"
|
|
onClick={() => toggleTool(key)}
|
|
className={cn(
|
|
"inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-medium transition-colors",
|
|
isOn
|
|
? "bg-primary text-primary-foreground"
|
|
: "bg-muted text-muted-foreground hover:bg-accent hover:text-foreground",
|
|
)}
|
|
>
|
|
<Icon className="size-3.5" />
|
|
{label}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* Model selector */}
|
|
<div className="flex items-center gap-0.5">
|
|
{MODEL_OPTIONS.map(({ value, label, icon: Icon }) => (
|
|
<Button
|
|
key={value}
|
|
type="button"
|
|
variant={modelMode === value ? "default" : "ghost"}
|
|
size="sm"
|
|
className="h-7 px-2.5 text-xs gap-1"
|
|
onClick={() => setModelMode(value)}
|
|
>
|
|
<Icon className="size-3.5" />
|
|
{label}
|
|
</Button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Input */}
|
|
<div className="shrink-0 px-4 py-3">
|
|
<div className="flex flex-col gap-2 max-w-3xl mx-auto">
|
|
{attachedFile && (
|
|
<FileAttachmentPreview file={attachedFile} onRemove={() => setAttachedFile(null)} />
|
|
)}
|
|
<form onSubmit={handleSubmit} className="flex gap-2">
|
|
<FileUploadButton onFileSelect={setAttachedFile} disabled={thread.isLoading} />
|
|
<textarea
|
|
ref={textareaRef}
|
|
className="flex-1 rounded-xl border border-input bg-background px-4 py-2.5 text-sm outline-none focus:ring-2 focus:ring-ring placeholder:text-muted-foreground disabled:opacity-50 resize-none overflow-y-auto"
|
|
style={{ minHeight: "42px", maxHeight: "200px" }}
|
|
placeholder="输入消息…"
|
|
value={input}
|
|
rows={1}
|
|
onChange={(e) => setInput(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter" && !e.shiftKey) {
|
|
e.preventDefault();
|
|
handleSubmit(e as unknown as React.FormEvent);
|
|
}
|
|
}}
|
|
onPaste={handlePaste}
|
|
disabled={thread.isLoading}
|
|
autoFocus
|
|
/>
|
|
{thread.isLoading ? (
|
|
<button
|
|
type="button"
|
|
onClick={() => thread.stop()}
|
|
className="rounded-xl bg-destructive text-destructive-foreground px-4 py-2.5 text-sm font-medium hover:opacity-90 transition-opacity flex items-center gap-1.5"
|
|
>
|
|
<Square className="size-4" />
|
|
停止
|
|
</button>
|
|
) : (
|
|
<button
|
|
type="submit"
|
|
disabled={!input.trim() && !attachedFile}
|
|
className="rounded-xl bg-primary text-primary-foreground px-4 py-2.5 text-sm font-medium disabled:opacity-50 hover:opacity-90 transition-opacity"
|
|
>
|
|
发送
|
|
</button>
|
|
)}
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* ── Canvas panel (right drawer) ── */}
|
|
<CanvasPanel doc={canvasDoc} onClose={() => setCanvasDoc(null)} />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
createRoot(document.getElementById("root")!).render(
|
|
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
|
<App />
|
|
</ThemeProvider>,
|
|
);
|