- 点5: cancelling 独立状态 UI,用轮询替换 300ms setTimeout,StreamStatusBar 加橙色视觉 - 点6: ThreadSidebar formatTime 支持毫秒时间戳,时间展示改用 lastActive - 点7: MessageBubble 智能摘要(关键词优先/标题次优/跳开场白),折叠阈值提升至25行/800字,展开后可收起 Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
1202 lines
51 KiB
TypeScript
1202 lines
51 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, Sparkles, Loader2, Clock } from "lucide-react";
|
||
import { cn } from "@/lib/utils";
|
||
import { ThreadSidebar, type ThreadItem, updateThreadLastActive } 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";
|
||
import { ExecutionLogPanel } from "@/components/ExecutionLogPanel.tsx";
|
||
|
||
const LANGGRAPH_URL =
|
||
import.meta.env.VITE_LANGGRAPH_URL ?? "http://localhost:2024";
|
||
|
||
// ─── Card deduplication by card_id ──────────────────────────────────────────
|
||
// When the backend pushes loading then complete cards with the same card_id,
|
||
// keep only the last (most recent) card per card_id. Cards without card_id pass through.
|
||
function deduplicateUiItems(items: UIMsgLocal[]): UIMsgLocal[] {
|
||
const seen = new Map<string, number>();
|
||
// Walk forward to find the last index for each card_id
|
||
items.forEach((item, idx) => {
|
||
const cardId = item.props?.card_id as string | undefined;
|
||
if (cardId) seen.set(cardId, idx);
|
||
});
|
||
return items.filter((item, idx) => {
|
||
const cardId = item.props?.card_id as string | undefined;
|
||
if (!cardId) return true;
|
||
return seen.get(cardId) === idx;
|
||
});
|
||
}
|
||
|
||
// ─── 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);
|
||
const isComposingRef = useRef(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);
|
||
|
||
// Historical messages fetched when switching to an existing thread
|
||
const [historicalMessages, setHistoricalMessages] = useState<Message[]>([]);
|
||
const [historicalUi, setHistoricalUi] = useState<UIMsgLocal[]>([]);
|
||
|
||
// Canvas panel state
|
||
const [canvasDoc, setCanvasDoc] = useState<CanvasDoc | null>(null);
|
||
|
||
// File attachment state
|
||
const [attachedFile, setAttachedFile] = useState<SelectedFile | null>(null);
|
||
|
||
// Source label from card action buttons (e.g. "来自 知识库检索")
|
||
const [sourceLabel, setSourceLabel] = useState<string | null>(null);
|
||
|
||
// Config panel open/close state
|
||
const [configOpen, setConfigOpen] = useState(false);
|
||
const configPanelRef = useRef<HTMLDivElement>(null);
|
||
|
||
// Pending retry tool name — set by soc:retry-tool, consumed on next submit
|
||
const pendingRetryToolRef = useRef<string | null>(null);
|
||
|
||
// ── Concurrent submit state ───────────────────────────────────────────────
|
||
type ConcurrentStatus = "idle" | "generating" | "queued" | "stopping" | "cancelling" | "auto-sending";
|
||
|
||
// 完整 payload 快照类型
|
||
interface SubmitPayload {
|
||
text: string;
|
||
attachedFile: { name: string; mimeType: string; base64: string; size: number } | null;
|
||
activeTools: Set<ToolKey>;
|
||
modelMode: ModelMode;
|
||
}
|
||
|
||
const [showConcurrentDialog, setShowConcurrentDialog] = useState(false);
|
||
const [concurrentStatus, setConcurrentStatus] = useState<ConcurrentStatus>("idle");
|
||
const pendingQueueRef = useRef<Array<() => void>>([]);
|
||
const isPollingRef = useRef(false);
|
||
const wasStoppedRef = useRef(false);
|
||
const [pendingQueueCount, setPendingQueueCount] = useState(0);
|
||
|
||
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);
|
||
}, []);
|
||
|
||
// Sync concurrent status with thread loading state
|
||
useEffect(() => {
|
||
if (!thread.isLoading && concurrentStatus !== "cancelling" && concurrentStatus !== "queued") {
|
||
setConcurrentStatus("idle");
|
||
}
|
||
}, [thread.isLoading, concurrentStatus]);
|
||
|
||
// Listen for retry-tool events from error-result cards
|
||
useEffect(() => {
|
||
const handler = (e: Event) => {
|
||
const ce = e as CustomEvent<{ toolName: string }>;
|
||
if (ce.detail.toolName) {
|
||
pendingRetryToolRef.current = ce.detail.toolName;
|
||
}
|
||
};
|
||
window.addEventListener("soc:retry-tool", handler);
|
||
return () => window.removeEventListener("soc:retry-tool", handler);
|
||
}, []);
|
||
|
||
// Cleanup queue on unmount
|
||
useEffect(() => {
|
||
return () => {
|
||
pendingQueueRef.current = [];
|
||
isPollingRef.current = false;
|
||
};
|
||
}, []);
|
||
|
||
// Listen for prefill-input events from ActionBar
|
||
useEffect(() => {
|
||
const handler = (e: Event) => {
|
||
const ce = e as CustomEvent<{ text?: string; prefix?: string; sourceLabel?: string; taskType?: string; sourceCardId?: string }>;
|
||
if (ce.detail.prefix) {
|
||
// Prefix mode: prepend context label to current input
|
||
setInput((prev) => {
|
||
const base = prev.trim();
|
||
return base ? `${ce.detail.prefix}${base}` : ce.detail.prefix!;
|
||
});
|
||
} else if (ce.detail.text !== undefined) {
|
||
setInput(ce.detail.text);
|
||
}
|
||
// Show source label tag above textarea if provided
|
||
if (ce.detail.sourceLabel) {
|
||
setSourceLabel(ce.detail.sourceLabel);
|
||
}
|
||
setTimeout(() => textareaRef.current?.focus(), 50);
|
||
};
|
||
window.addEventListener("soc:prefill-input", handler);
|
||
return () => window.removeEventListener("soc:prefill-input", handler);
|
||
}, []);
|
||
|
||
// Close config panel when clicking outside or pressing Escape
|
||
useEffect(() => {
|
||
if (!configOpen) return;
|
||
function handleMouseDown(e: MouseEvent) {
|
||
if (configPanelRef.current && !configPanelRef.current.contains(e.target as Node)) {
|
||
setConfigOpen(false);
|
||
}
|
||
}
|
||
function handleKeyDown(e: KeyboardEvent) {
|
||
if (e.key === "Escape") setConfigOpen(false);
|
||
}
|
||
document.addEventListener("mousedown", handleMouseDown);
|
||
document.addEventListener("keydown", handleKeyDown);
|
||
return () => {
|
||
document.removeEventListener("mousedown", handleMouseDown);
|
||
document.removeEventListener("keydown", handleKeyDown);
|
||
};
|
||
}, [configOpen]);
|
||
|
||
// 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 () => {
|
||
// Save draft for current thread before switching
|
||
if (currentThreadId) {
|
||
try { localStorage.setItem(`draft_${currentThreadId}`, input); } catch { /* ignore */ }
|
||
}
|
||
setInput("");
|
||
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);
|
||
}, [currentThreadId, input]);
|
||
|
||
const handleSelectThread = useCallback(async (threadId: string) => {
|
||
// Save draft for current thread
|
||
if (currentThreadId) {
|
||
try { localStorage.setItem(`draft_${currentThreadId}`, input); } catch { /* ignore */ }
|
||
}
|
||
// Restore draft for the new thread
|
||
try {
|
||
const saved = localStorage.getItem(`draft_${threadId}`) ?? "";
|
||
setInput(saved);
|
||
} catch {
|
||
setInput("");
|
||
}
|
||
// Clear historical messages before switching
|
||
setHistoricalMessages([]);
|
||
setHistoricalUi([]);
|
||
setCurrentThreadId(threadId);
|
||
setSidebarOpen(false);
|
||
// Fetch existing thread state so history is visible immediately
|
||
try {
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
const state = await (client.threads as any).getState(threadId);
|
||
if (state?.values?.messages) {
|
||
setHistoricalMessages(state.values.messages as Message[]);
|
||
}
|
||
if (state?.values?.ui) {
|
||
setHistoricalUi(state.values.ui as UIMsgLocal[]);
|
||
}
|
||
} catch { /* graceful degradation */ }
|
||
}, [currentThreadId, input]);
|
||
|
||
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) return;
|
||
|
||
if (thread.isLoading) {
|
||
// 立即 snapshot
|
||
const snapshot: SubmitPayload = {
|
||
text,
|
||
attachedFile: attachedFile
|
||
? { name: attachedFile.name, mimeType: attachedFile.mimeType, base64: attachedFile.base64, size: attachedFile.size }
|
||
: null,
|
||
activeTools: new Set(activeTools),
|
||
modelMode,
|
||
};
|
||
setInput("");
|
||
setAttachedFile(null);
|
||
pendingQueueRef.current.push(() => doSubmit(snapshot));
|
||
setPendingQueueCount(pendingQueueRef.current.length);
|
||
setShowConcurrentDialog(true);
|
||
return;
|
||
}
|
||
|
||
const payload: SubmitPayload = {
|
||
text,
|
||
attachedFile: attachedFile
|
||
? { name: attachedFile.name, mimeType: attachedFile.mimeType, base64: attachedFile.base64, size: attachedFile.size }
|
||
: null,
|
||
activeTools: new Set(activeTools),
|
||
modelMode,
|
||
};
|
||
setConcurrentStatus("generating");
|
||
doSubmit(payload);
|
||
}
|
||
|
||
async function doSubmit(payload: SubmitPayload) {
|
||
const { text, attachedFile: file, activeTools: tools, modelMode: mode } = payload;
|
||
setInput("");
|
||
setSourceLabel(null);
|
||
if (currentThreadId) {
|
||
try { localStorage.removeItem(`draft_${currentThreadId}`); } catch { /* ignore */ }
|
||
updateThreadLastActive(currentThreadId);
|
||
}
|
||
|
||
const enabledTools =
|
||
tools.size > 0
|
||
? TOOL_GROUPS.filter((g) => tools.has(g.key)).flatMap((g) => [...g.tools])
|
||
: [];
|
||
|
||
setAttachedFile(null);
|
||
|
||
const IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"];
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
let messageContent: any;
|
||
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;
|
||
}
|
||
|
||
const isFirstMessage = activeMessages.length === 0;
|
||
const retryTool = pendingRetryToolRef.current;
|
||
pendingRetryToolRef.current = null;
|
||
|
||
thread.submit(
|
||
{ messages: [{ type: "human", content: messageContent }] },
|
||
{
|
||
config: {
|
||
configurable: {
|
||
enabledTools,
|
||
modelMode: mode,
|
||
...(retryTool ? { retryTool } : {}),
|
||
...(file ? { attachedFile: { name: file.name, mimeType: file.mimeType, base64: file.base64, size: file.size } } : {}),
|
||
},
|
||
},
|
||
},
|
||
);
|
||
|
||
if (isFirstMessage && currentThreadId) {
|
||
const titleText = text.slice(0, 20);
|
||
client.threads.update(currentThreadId, { metadata: { title: titleText } }).catch(() => {});
|
||
try { localStorage.setItem(`title_${currentThreadId}`, titleText); } catch { /* ignore */ }
|
||
setThreads((prev) =>
|
||
prev.map((t) =>
|
||
t.thread_id === currentThreadId
|
||
? { ...t, metadata: { ...t.metadata, title: titleText } }
|
||
: t,
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
// ── Concurrent dialog handlers ────────────────────────────────────────────
|
||
function handleConcurrentInterrupt() {
|
||
setShowConcurrentDialog(false);
|
||
setConcurrentStatus("stopping");
|
||
wasStoppedRef.current = true;
|
||
thread.stop();
|
||
setTimeout(() => {
|
||
setConcurrentStatus("cancelling");
|
||
const waitForStop = () => {
|
||
if (!thread.isLoading) {
|
||
wasStoppedRef.current = false;
|
||
const next = pendingQueueRef.current.shift();
|
||
if (next) {
|
||
setPendingQueueCount(pendingQueueRef.current.length);
|
||
setConcurrentStatus("generating");
|
||
next();
|
||
} else {
|
||
setConcurrentStatus("idle");
|
||
}
|
||
} else {
|
||
setTimeout(waitForStop, 200);
|
||
}
|
||
};
|
||
waitForStop();
|
||
}, 150);
|
||
}
|
||
|
||
function handleConcurrentQueue() {
|
||
setShowConcurrentDialog(false);
|
||
setConcurrentStatus("queued");
|
||
if (isPollingRef.current) return;
|
||
isPollingRef.current = true;
|
||
const poll = () => {
|
||
if (pendingQueueRef.current.length === 0) {
|
||
isPollingRef.current = false;
|
||
setPendingQueueCount(0);
|
||
setConcurrentStatus("idle");
|
||
return;
|
||
}
|
||
if (thread.isLoading) {
|
||
setTimeout(poll, 500);
|
||
return;
|
||
}
|
||
const next = pendingQueueRef.current.shift()!;
|
||
setPendingQueueCount(pendingQueueRef.current.length);
|
||
setConcurrentStatus("auto-sending");
|
||
next();
|
||
setTimeout(poll, 500);
|
||
};
|
||
setTimeout(poll, 500);
|
||
}
|
||
|
||
function handleConcurrentCancel() {
|
||
setShowConcurrentDialog(false);
|
||
setConcurrentStatus(thread.isLoading ? "generating" : "idle");
|
||
pendingQueueRef.current = [];
|
||
setPendingQueueCount(0);
|
||
isPollingRef.current = false;
|
||
}
|
||
|
||
// ── Regenerate last AI message ───────────────────────────────────────────
|
||
function handleRegenerate() {
|
||
// Find last human message
|
||
const humanMsgs = activeMessages.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);
|
||
}
|
||
|
||
// ── Active messages: prefer streaming messages, fall back to historical ──
|
||
const activeMessages: Message[] = thread.messages.length > 0 ? thread.messages : historicalMessages;
|
||
|
||
// ── Collect completed tool call IDs ─────────────────────────────────────
|
||
const completedToolIds = new Set(
|
||
activeMessages
|
||
.filter((m) => m.type === "tool")
|
||
.map((m) => (m as any).tool_call_id as string)
|
||
.filter(Boolean),
|
||
);
|
||
|
||
// ── Collect failed tool call IDs ─────────────────────────────────────────
|
||
const failedToolIds = new Set(
|
||
activeMessages
|
||
.filter((m) => m.type === "tool" && (m as any).status === "error")
|
||
.map((m) => (m as any).tool_call_id as string)
|
||
.filter(Boolean),
|
||
);
|
||
|
||
// ── Read execution_log for partial/fallback status ────────────────────────
|
||
const executionLog = ((thread.values as any)?.execution_log ?? []) as Array<{
|
||
tool: string;
|
||
status: string;
|
||
durationMs?: number;
|
||
retryCount?: number;
|
||
fallbackFrom?: string;
|
||
resultCount?: number;
|
||
summary?: string;
|
||
inputSummary?: string;
|
||
}>;
|
||
|
||
// ── Find last AI message index ──────────────────────────────────────────
|
||
const lastAiIdx = activeMessages.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);
|
||
}}
|
||
>
|
||
{activeMessages.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>
|
||
)}
|
||
|
||
{activeMessages.map((message, idx) => {
|
||
// Render UI cards attached to this message
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
const allUi: UIMsgLocal[] = thread.messages.length > 0
|
||
? ((thread.values as any)?.ui ?? [])
|
||
: historicalUi;
|
||
const uiItemsRaw = deduplicateUiItems(
|
||
allUi.filter(
|
||
(ui: UIMsgLocal) => ui.metadata?.message_id === message.id,
|
||
) as UIMsgLocal[]
|
||
);
|
||
// Sort by card type priority first, then by sort_key for deterministic order
|
||
const CARD_TYPE_PRIORITY: Record<string, number> = {
|
||
"error-result": 100,
|
||
"chart-result": 200,
|
||
"knowledge-result": 300,
|
||
"ticket-summary": 400,
|
||
"ticket-detail": 450,
|
||
"search-result": 500,
|
||
"sandbox-result": 600,
|
||
"canvas-doc": 700,
|
||
"reply-draft": 750,
|
||
"next-actions": 900,
|
||
};
|
||
const uiItems = [...uiItemsRaw].sort((a, b) => {
|
||
const priorityA = CARD_TYPE_PRIORITY[a.name] ?? 500;
|
||
const priorityB = CARD_TYPE_PRIORITY[b.name] ?? 500;
|
||
if (priorityA !== priorityB) return priorityA - priorityB;
|
||
const sa = (a.props?.sort_key as number) ?? 0;
|
||
const sb = (b.props?.sort_key as number) ?? 0;
|
||
return sa - sb;
|
||
});
|
||
|
||
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">
|
||
{/* Text reply — rendered first so user sees conclusion before evidence */}
|
||
{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>
|
||
)}
|
||
|
||
{/* Tool call status (with inline expand/collapse for UI cards) */}
|
||
{toolCalls.length > 0 && (
|
||
<ToolCallStatus
|
||
toolCalls={toolCalls}
|
||
isLoading={thread.isLoading}
|
||
completedToolIds={completedToolIds}
|
||
failedToolIds={failedToolIds}
|
||
uiItems={uiItems}
|
||
stream={thread}
|
||
components={ComponentMap as any}
|
||
executionLog={executionLog}
|
||
/>
|
||
)}
|
||
|
||
{/* UI cards not matched to any tool call (standalone) */}
|
||
{(() => {
|
||
const standaloneUiItems = 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];
|
||
}));
|
||
if (standaloneUiItems.length === 0) return null;
|
||
const cards = standaloneUiItems.map((ui: UIMsgLocal) => (
|
||
<div key={ui.id} className="card-enter">
|
||
<LoadExternalComponent
|
||
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>
|
||
));
|
||
if (standaloneUiItems.length >= 2) {
|
||
return (
|
||
<div className="flex flex-col gap-2 border-l-2 border-primary/20 pl-3 mt-2">
|
||
<span className="text-xs text-muted-foreground font-medium">综合分析 · {standaloneUiItems.length} 项结果</span>
|
||
{cards}
|
||
</div>
|
||
);
|
||
}
|
||
return <>{cards}</>;
|
||
})()}
|
||
|
||
{/* Execution log panel */}
|
||
{!thread.isLoading && (
|
||
<ExecutionLogPanel executionLog={executionLog} />
|
||
)}
|
||
|
||
{/* 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;
|
||
})}
|
||
|
||
{/* Loading placeholder: show 3-dot bounce when waiting for first AI tokens after a human message */}
|
||
{(() => {
|
||
const lastMsg = activeMessages[activeMessages.length - 1];
|
||
const showLoadingDots = thread.isLoading && lastMsg?.type === "human";
|
||
return showLoadingDots ? (
|
||
<div className="flex items-center gap-1.5 px-4 py-3 rounded-2xl rounded-bl-sm bg-muted text-muted-foreground w-fit">
|
||
<span className="dot-bounce" />
|
||
<span className="dot-bounce" />
|
||
<span className="dot-bounce" />
|
||
</div>
|
||
) : null;
|
||
})()}
|
||
|
||
{/* Streaming UI cards not yet attached to a completed message */}
|
||
{thread.isLoading &&
|
||
deduplicateUiItems(
|
||
(thread.values?.ui ?? []).filter((ui: UIMsgLocal) => {
|
||
const attachedToExisting = activeMessages.some(
|
||
(m) => m.id === ui.metadata?.message_id,
|
||
);
|
||
return !attachedToExisting;
|
||
})
|
||
)
|
||
.map((ui: UIMsgLocal) => (
|
||
<div key={ui.id} className="card-enter">
|
||
<LoadExternalComponent
|
||
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>
|
||
))}
|
||
|
||
<div ref={bottomRef} />
|
||
|
||
{/* Scroll-to-bottom floating button */}
|
||
{showScrollBtn && (
|
||
<div className="sticky bottom-4 flex justify-end pr-4 pointer-events-none">
|
||
<button
|
||
type="button"
|
||
onClick={() => bottomRef.current?.scrollIntoView({ behavior: "smooth" })}
|
||
className="pointer-events-auto rounded-full bg-background/80 backdrop-blur 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>
|
||
)}
|
||
</div>
|
||
|
||
{/* Config summary bar + expandable panel */}
|
||
{(() => {
|
||
const modelLabel = modelMode === "flash" ? "Flash" : modelMode === "pro" ? "Pro" : "Auto";
|
||
const activeToolLabels = TOOL_GROUPS
|
||
.filter((g) => activeTools.has(g.key))
|
||
.map((g) => g.label);
|
||
const summary =
|
||
activeToolLabels.length === 0
|
||
? modelLabel
|
||
: activeToolLabels.length <= 2
|
||
? `${modelLabel} · ${activeToolLabels.join(" + ")}`
|
||
: `${modelLabel} · ${activeToolLabels.length} 个工具`;
|
||
return (
|
||
<div className="shrink-0 border-t border-border px-4 pt-2 pb-0 max-w-3xl mx-auto w-full">
|
||
<div ref={configPanelRef} className="relative">
|
||
{/* Summary bar */}
|
||
<button
|
||
type="button"
|
||
onClick={() => setConfigOpen((o) => !o)}
|
||
className={cn(
|
||
"inline-flex items-center gap-1.5 text-xs transition-colors py-1 px-2 rounded-md",
|
||
configOpen
|
||
? "text-foreground bg-accent"
|
||
: "text-muted-foreground hover:text-foreground hover:bg-accent/50"
|
||
)}
|
||
>
|
||
<ChevronDown className={cn("size-3.5 transition-transform duration-200", configOpen && "rotate-180")} />
|
||
<span>{summary}</span>
|
||
{activeTools.size > 0 && (
|
||
<span className="inline-flex items-center justify-center size-4 rounded-full bg-primary text-primary-foreground text-[10px] font-medium leading-none">
|
||
{activeTools.size}
|
||
</span>
|
||
)}
|
||
</button>
|
||
|
||
{/* Expanded panel — opens upward */}
|
||
{configOpen && (
|
||
<div className="absolute bottom-full mb-1 left-0 z-20 border border-border rounded-xl bg-background shadow-sm p-3 flex flex-col gap-3 min-w-[320px] animate-in fade-in slide-in-from-bottom-2 duration-150">
|
||
{/* Model mode row */}
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-xs text-muted-foreground w-14 shrink-0">模型模式</span>
|
||
<div className="flex items-center gap-1">
|
||
{MODEL_OPTIONS.map(({ value, label, icon: Icon }) => (
|
||
<button
|
||
key={value}
|
||
type="button"
|
||
onClick={() => setModelMode(value)}
|
||
className={cn(
|
||
"inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-medium transition-colors",
|
||
modelMode === value
|
||
? "bg-primary text-primary-foreground"
|
||
: "bg-muted text-muted-foreground hover:bg-accent hover:text-foreground",
|
||
)}
|
||
>
|
||
<Icon className="size-3" />
|
||
{label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Tool toggles row */}
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-xs text-muted-foreground w-14 shrink-0">工具</span>
|
||
<div className="flex items-center gap-1 flex-wrap">
|
||
{/* Auto chip */}
|
||
<button
|
||
type="button"
|
||
onClick={() => setActiveTools(new Set())}
|
||
title="自动模式:由 AI 决定使用哪些工具"
|
||
className={cn(
|
||
"inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-medium transition-colors",
|
||
activeTools.size === 0
|
||
? "bg-primary text-primary-foreground"
|
||
: "bg-muted text-muted-foreground hover:bg-accent hover:text-foreground",
|
||
)}
|
||
>
|
||
<Sparkles className="size-3" />
|
||
自动
|
||
</button>
|
||
{TOOL_GROUPS.map(({ key, label, icon: Icon }) => {
|
||
const isOn = activeTools.has(key);
|
||
return (
|
||
<button
|
||
key={key}
|
||
type="button"
|
||
onClick={() => toggleTool(key)}
|
||
title={isOn ? `${label}:已启用` : `${label}:已禁用`}
|
||
className={cn(
|
||
"inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 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" />
|
||
{label}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
})()}
|
||
|
||
{/* Stream status bar */}
|
||
{concurrentStatus !== "idle" && (
|
||
<div className="max-w-3xl mx-auto w-full px-5 mb-1">
|
||
<div key={concurrentStatus} className={cn(
|
||
"flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs animate-in fade-in duration-200",
|
||
concurrentStatus === "generating" && "bg-blue-50/80 dark:bg-blue-950/30 text-blue-600 dark:text-blue-400",
|
||
concurrentStatus === "queued" && "bg-amber-50/80 dark:bg-amber-950/30 text-amber-600 dark:text-amber-400",
|
||
concurrentStatus === "stopping" && "bg-red-50/80 dark:bg-red-950/30 text-red-600 dark:text-red-400",
|
||
concurrentStatus === "cancelling" && "bg-orange-50/80 dark:bg-orange-950/30 text-orange-600 dark:text-orange-400",
|
||
concurrentStatus === "auto-sending" && "bg-green-50/80 dark:bg-green-950/30 text-green-600 dark:text-green-400",
|
||
)}>
|
||
{(concurrentStatus === "generating" || concurrentStatus === "auto-sending") && <Loader2 className="size-3 animate-spin shrink-0" />}
|
||
{concurrentStatus === "queued" && <Clock className="size-3 shrink-0" />}
|
||
{concurrentStatus === "stopping" && <Square className="size-3 shrink-0 fill-current" />}
|
||
{concurrentStatus === "cancelling" && <Loader2 className="size-3 animate-spin shrink-0" />}
|
||
{concurrentStatus === "queued" ? (
|
||
<>
|
||
<span>
|
||
{pendingQueueCount > 1
|
||
? `${pendingQueueCount} 条消息已排队,等待当前回复完成`
|
||
: "下一条消息已排队,等待当前回复完成"}
|
||
</span>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
pendingQueueRef.current = [];
|
||
setPendingQueueCount(0);
|
||
isPollingRef.current = false;
|
||
setConcurrentStatus(thread.isLoading ? "generating" : "idle");
|
||
}}
|
||
className="ml-auto shrink-0 text-[11px] underline underline-offset-2 opacity-70 hover:opacity-100 transition-opacity"
|
||
>
|
||
取消排队
|
||
</button>
|
||
</>
|
||
) : (
|
||
<span>
|
||
{concurrentStatus === "generating" && "正在生成回复..."}
|
||
{concurrentStatus === "stopping" && "正在停止上一轮..."}
|
||
{concurrentStatus === "cancelling" && "等待当前任务停止..."}
|
||
{concurrentStatus === "auto-sending" && "正在自动发送下一条..."}
|
||
</span>
|
||
)}
|
||
</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)} />
|
||
)}
|
||
{/* Source label: shown when user clicks a card action button */}
|
||
{sourceLabel && (
|
||
<div className="flex items-center gap-1.5">
|
||
<span className="inline-flex items-center gap-1 text-[11px] px-2 py-0.5 rounded-full bg-blue-50 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400 border border-blue-200 dark:border-blue-800">
|
||
来自 {sourceLabel}
|
||
</span>
|
||
<button
|
||
type="button"
|
||
onClick={() => setSourceLabel(null)}
|
||
className="text-[10px] text-muted-foreground hover:text-foreground transition-colors"
|
||
title="清除来源标签"
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
)}
|
||
<form onSubmit={handleSubmit} className="flex gap-2">
|
||
<FileUploadButton onFileSelect={setAttachedFile} />
|
||
<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)}
|
||
onCompositionStart={() => { isComposingRef.current = true; }}
|
||
onCompositionEnd={() => { isComposingRef.current = false; }}
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Enter" && !e.shiftKey && !isComposingRef.current) {
|
||
e.preventDefault();
|
||
handleSubmit(e as unknown as React.FormEvent);
|
||
}
|
||
}}
|
||
onPaste={handlePaste}
|
||
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"
|
||
title="点击停止生成"
|
||
>
|
||
<Square className="size-4 fill-current" />
|
||
停止
|
||
</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>
|
||
{/* Input hint: dynamically changes based on active tools */}
|
||
<p className="text-[10px] text-muted-foreground/70 pl-1">
|
||
{activeTools.size === 0
|
||
? "可分析工单、查知识库、搜索网络"
|
||
: activeTools.size === 1 && activeTools.has("knowledge")
|
||
? "将在内部知识库中检索"
|
||
: activeTools.size === 1 && activeTools.has("tickets")
|
||
? "将查询工单系统"
|
||
: activeTools.size === 1 && activeTools.has("search")
|
||
? "将使用网络搜索"
|
||
: activeTools.size === 1 && activeTools.has("sandbox")
|
||
? "将执行代码沙盒"
|
||
: `已启用 ${activeTools.size} 个工具`}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* ── Concurrent submit dialog ── */}
|
||
{showConcurrentDialog && (
|
||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
|
||
<div className="bg-background border border-border rounded-2xl shadow-xl p-6 w-80 flex flex-col gap-4">
|
||
<div className="flex flex-col gap-1">
|
||
<p className="font-semibold text-sm">AI 正在处理上一条消息</p>
|
||
<p className="text-xs text-muted-foreground">
|
||
{pendingQueueCount > 0
|
||
? `队列中还有 ${pendingQueueCount} 条待发送`
|
||
: "请选择如何处理新消息:"}
|
||
</p>
|
||
</div>
|
||
<div className="flex flex-col gap-2">
|
||
<button
|
||
type="button"
|
||
onClick={handleConcurrentInterrupt}
|
||
className="w-full 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-2"
|
||
>
|
||
<Square className="size-4 fill-current" />
|
||
中断上一条,处理这条
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={handleConcurrentQueue}
|
||
className="w-full rounded-xl bg-primary text-primary-foreground px-4 py-2.5 text-sm font-medium hover:opacity-90 transition-opacity"
|
||
>
|
||
加入队列,等上一条完成
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={handleConcurrentCancel}
|
||
className="w-full rounded-xl border border-border px-4 py-2.5 text-sm font-medium hover:bg-accent transition-colors"
|
||
>
|
||
取消
|
||
</button>
|
||
</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>,
|
||
);
|