"use client"; import React from "react"; import { useState, useRef, useEffect, useCallback, memo, useMemo } from "react"; import { useRouter } from "next/navigation"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import type { App, Conversation, Message, ToolCall } from "@/lib/types"; import api, { streamChat } from "@/lib/api"; import { Button } from "@/components/ui/button"; import { Textarea } from "@/components/ui/textarea"; import { Badge } from "@/components/ui/badge"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog"; import { ArrowLeft, Plus, Send, Loader2, MessageSquare, Bot, Wrench, CheckCircle2, Sparkles, Trash2, CheckSquare, Square, X, Copy, Download, Pencil, PanelLeftOpen, } from "lucide-react"; import { getCategoryIcon, getCategoryColor } from "@/lib/category-config"; import GovMarkdown from "@/components/ui/gov-markdown"; import { toast } from "sonner"; function parseToolCalls(content: string): { cleanContent: string; tools: ToolCall[] } { const tools: ToolCall[] = []; let cleanContent = content; const toolCallRegex = /\[工具调用:\s*(.+?)\]/g; const toolResultRegex = /\[工具结果:\s*(.+?)\]/g; let match; while ((match = toolCallRegex.exec(content)) !== null) { const name = match[1].trim(); if (!tools.find((t) => t.name === name)) tools.push({ name, status: "running" }); } while ((match = toolResultRegex.exec(content)) !== null) { const name = match[1].trim(); const tool = tools.find((t) => t.name === name); if (tool) tool.status = "done"; } cleanContent = cleanContent .replace(/\[工具调用:\s*.+?\]/g, "") .replace(/\[工具结果:\s*.+?\]/g, "") .trim(); return { cleanContent, tools }; } const AgentMessage = memo(function AgentMessage({ msg, onCopy, }: { msg: Message; onCopy: (text: string) => void; }) { if (msg.role === "user") { return (

{msg.content}

); } const { cleanContent, tools: parsedTools } = parseToolCalls(msg.content); return (
{parsedTools.length > 0 && (
{parsedTools.map((tool) => (
{tool.status === "done" ? ( ) : ( )} {tool.name}
))}
)}
{cleanContent && (
)}
); }); interface AgentUIProps { app: App; } export default function AgentUI({ app }: AgentUIProps) { const router = useRouter(); const queryClient = useQueryClient(); const [conversationId, setConversationId] = useState(); const [messages, setMessages] = useState( app.welcome_message && !conversationId ? [{ id: "welcome", role: "assistant", content: app.welcome_message }] : [], ); const [input, setInput] = useState(""); const [isStreaming, setIsStreaming] = useState(false); const [selectMode, setSelectMode] = useState(false); const [selectedIds, setSelectedIds] = useState>(new Set()); const [deleteTarget, setDeleteTarget] = useState<{ type: "single" | "batch"; id?: string; name?: string; } | null>(null); const [editingConvId, setEditingConvId] = useState(null); const [editingName, setEditingName] = useState(""); const [sidebarOpen, setSidebarOpen] = useState(false); const messagesEndRef = useRef(null); const textareaRef = useRef(null); const abortRef = useRef(null); const renameInputRef = useRef(null); const appConfig = useMemo(() => { try { if (typeof app.app_config === "string") return JSON.parse(app.app_config); return app.app_config || {}; } catch { return {}; } }, [app.app_config]); const tools: string[] = appConfig.tools || []; const { data: conversations = [] } = useQuery({ queryKey: ["conversations", app.id], queryFn: async () => { const data = await api.get<{ data: Conversation[] }>(`/api/v1/apps/${app.id}/conversations`); return data.data || []; }, staleTime: 10_000, }); const scrollToBottom = useCallback(() => { messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); }, []); useEffect(() => { scrollToBottom(); }, [messages, scrollToBottom]); useEffect(() => { return () => { abortRef.current?.abort(); }; }, []); const loadConversation = useCallback( async (convId: string) => { setConversationId(convId); try { const data = await api.get<{ data: Message[] }>( `/api/v1/apps/${app.id}/conversations/${convId}/messages`, ); setMessages(data.data || []); } catch { setMessages([]); } }, [app.id], ); const CategoryIconComponent = getCategoryIcon(app.category_slug); const categoryColor = getCategoryColor(app.category_slug); const suggestedPrompts = useMemo(() => { try { if (typeof app.suggested_prompts === "string") return JSON.parse(app.suggested_prompts) as string[]; return (app.suggested_prompts as string[]) || []; } catch { return []; } }, [app.suggested_prompts]); const copyText = useCallback((text: string) => { navigator.clipboard.writeText(text); toast.success("已复制到剪贴板"); }, []); const exportConversation = useCallback(() => { if (messages.length === 0) return; const lines = messages .filter((m) => m.id !== "welcome") .map((m) => { const role = m.role === "user" ? "【用户】" : "【AI助手】"; return `${role}\n${m.content}`; }); const text = `${app.name} - 对话记录\n导出时间:${new Date().toLocaleString("zh-CN")}\n${"=".repeat(40)}\n\n${lines.join("\n\n" + "-".repeat(40) + "\n\n")}`; const filename = `${app.name}-对话记录-${new Date().toISOString().slice(0, 10)}.txt`; const blob = new Blob([text], { type: "application/octet-stream" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = filename; a.style.display = "none"; document.body.appendChild(a); a.click(); setTimeout(() => { document.body.removeChild(a); URL.revokeObjectURL(url); }, 30000); toast.success("对话已导出"); }, [messages, app.name]); const sendMessage = useCallback(async () => { const text = input.trim(); if (!text || isStreaming) return; const userMsg: Message = { id: `u-${Date.now()}`, role: "user", content: text }; const assistantMsg: Message = { id: `a-${Date.now()}`, role: "assistant", content: "" }; setMessages((prev) => [...prev, userMsg, assistantMsg]); setInput(""); setIsStreaming(true); const controller = new AbortController(); abortRef.current = controller; try { const res = await streamChat(app.id, text, conversationId, controller.signal); if (!res.ok) throw new Error("请求失败"); const reader = res.body?.getReader(); const decoder = new TextDecoder(); if (!reader) throw new Error("无法获取响应流"); let buffer = ""; let accumulated = ""; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split("\n"); buffer = lines.pop() || ""; for (const line of lines) { if (!line.startsWith("data: ")) continue; const raw = line.slice(6); if (raw === "[DONE]") break; try { const event = JSON.parse(raw); if (event.conversation_id) setConversationId(event.conversation_id); if (event.answer) { accumulated += event.answer; const snap = accumulated; setMessages((prev) => prev.map((m, i) => i === prev.length - 1 && m.role === "assistant" ? { ...m, content: snap } : m, ), ); } } catch { /* skip */ } } } queryClient.invalidateQueries({ queryKey: ["conversations", app.id] }); } catch (err) { if ((err as Error).name === "AbortError") return; setMessages((prev) => prev.map((m, i) => i === prev.length - 1 && m.role === "assistant" && !m.content ? { ...m, content: "抱歉,系统处理异常,请稍后重试。" } : m, ), ); } finally { abortRef.current = null; setIsStreaming(false); } }, [input, isStreaming, app.id, conversationId, queryClient]); const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); sendMessage(); } }, [sendMessage], ); const startNewConversation = useCallback(() => { abortRef.current?.abort(); setMessages([]); setConversationId(undefined); setIsStreaming(false); }, []); const toggleSelect = useCallback((id: string) => { setSelectedIds((prev) => { const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); return next; }); }, []); const selectAll = useCallback(() => { setSelectedIds(new Set(conversations.map((c) => c.id))); }, [conversations]); const confirmDeleteSingle = useCallback( async (convId: string) => { try { await api.delete(`/api/v1/apps/${app.id}/conversations/${convId}`); if (conversationId === convId) startNewConversation(); queryClient.invalidateQueries({ queryKey: ["conversations", app.id] }); toast.success("对话已删除"); } catch { toast.error("删除失败"); } setDeleteTarget(null); }, [app.id, conversationId, startNewConversation, queryClient], ); const confirmBatchDelete = useCallback(async () => { if (selectedIds.size === 0) return; try { await api.post(`/api/v1/apps/${app.id}/conversations/batch-delete`, { conversation_ids: Array.from(selectedIds), }); if (conversationId && selectedIds.has(conversationId)) startNewConversation(); setSelectedIds(new Set()); setSelectMode(false); queryClient.invalidateQueries({ queryKey: ["conversations", app.id] }); toast.success(`已删除 ${selectedIds.size} 个对话`); } catch { toast.error("批量删除失败"); } setDeleteTarget(null); }, [selectedIds, app.id, conversationId, startNewConversation, queryClient]); const startRename = useCallback((convId: string, currentName: string) => { setEditingConvId(convId); setEditingName(currentName); setTimeout(() => renameInputRef.current?.focus(), 50); }, []); const saveRename = useCallback(async () => { if (!editingConvId || !editingName.trim()) { setEditingConvId(null); return; } try { await api.put(`/api/v1/apps/${app.id}/conversations/${editingConvId}/name`, { name: editingName.trim(), }); queryClient.invalidateQueries({ queryKey: ["conversations", app.id] }); toast.success("已重命名"); } catch { toast.error("重命名失败"); } setEditingConvId(null); }, [editingConvId, editingName, app.id, queryClient]); return (
{/* 删除确认弹窗 */} !open && setDeleteTarget(null)}> 确认删除 {deleteTarget?.type === "single" ? `确定要删除对话"${deleteTarget.name || "新对话"}"吗?删除后无法恢复。` : `确定要删除选中的 ${selectedIds.size} 个对话吗?删除后无法恢复。`} 取消 { if (deleteTarget?.type === "single" && deleteTarget.id) { confirmDeleteSingle(deleteTarget.id); } else { confirmBatchDelete(); } }} > 确认删除 {/* 手机端侧边栏遮罩层 */} {sidebarOpen && (
setSidebarOpen(false)} /> )} {/* 侧边栏 */}
{tools.length > 0 && (

可用工具

{tools.map((tool) => ( {tool} ))}
)} {conversations.length > 0 && (
{selectMode ? `已选 ${selectedIds.size} 个` : `${conversations.length} 个对话`}
{selectMode ? ( <> ) : ( )}
)}
{conversations.length === 0 ? (

暂无对话历史

) : (
{conversations.map((conv) => (
{selectMode && ( )} {editingConvId === conv.id ? ( setEditingName(e.target.value)} onBlur={saveRename} onKeyDown={(e) => { if (e.key === "Enter") saveRename(); if (e.key === "Escape") setEditingConvId(null); }} className="flex-1 px-2 py-1 text-sm border rounded-md bg-background focus:outline-none focus:ring-1 focus:ring-primary" maxLength={50} /> ) : ( )} {!selectMode && editingConvId !== conv.id && (
)}
))}
)}
{/* 主对话区域 */}
{CategoryIconComponent ? React.createElement(CategoryIconComponent, { className: "h-4.5 w-4.5" }) : null}

{app.name}

{app.description}

{messages.length > 1 && ( )}
智能体
{messages.map((msg) => ( ))} {messages.length <= 1 && suggestedPrompts.length > 0 && (
{suggestedPrompts.map((prompt: string, i: number) => ( ))}
)}