Files
socaichat/langgraph/src/main.tsx
T
gongzhiyongandClaude Sonnet 4.6 73a7175051
Deploy LangGraph Server to Azure Web App / build-and-deploy (push) Failing after 23s
Deploy LangGraph UI to Azure Static Web Apps / build-and-deploy (push) Failing after 53s
feat: cancelling status, ThreadSidebar lastActive time, MessageBubble smart summary
- 点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>
2026-04-12 15:44:58 +08:00

1202 lines
51 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>,
);