"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 (
);
}
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) => (
))}
)}
);
}