Files
GovAI/apps/web/src/components/app-ui/agent-ui.tsx
T
selfrelease 8959456eb7 feat: 优化追问功能 - LLM智能生成 + 显示位置修复
- 修复 AuthLoader 组件中 router.push 在渲染期间调用的 React 警告
- 追问问题改为只在最新 AI 回复下方显示(修复多处显示问题)
- 新增 LLM 生成追问 API:POST /api/v1/apps/{id}/suggestions
- 前端调用 LLM API 生成智能追问,失败时降级到规则生成
- 登录页填入默认测试账号 kj-admin@govai.gov.cn
2026-06-23 15:35:58 +08:00

705 lines
25 KiB
TypeScript

"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 (
<div className="flex justify-end group/msg">
<div className="max-w-[80%]">
<div className="rounded-2xl px-4 py-2.5 bg-primary text-primary-foreground rounded-br-md shadow-sm">
<p className="text-sm whitespace-pre-wrap">{msg.content}</p>
</div>
<div className="flex justify-end mt-1 opacity-0 group-hover/msg:opacity-100 transition-opacity">
<button
onClick={() => onCopy(msg.content)}
className="flex items-center gap-1 text-[11px] text-muted-foreground hover:text-foreground px-1.5 py-0.5 rounded"
>
<Copy className="h-3 w-3" />
</button>
</div>
</div>
</div>
);
}
const { cleanContent, tools: parsedTools } = parseToolCalls(msg.content);
return (
<div className="flex justify-start group/msg">
<div className="max-w-[85%] space-y-2">
{parsedTools.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{parsedTools.map((tool) => (
<div
key={tool.name}
className={`flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs ${
tool.status === "done"
? "bg-emerald-50 text-emerald-700"
: "bg-amber-50 text-amber-700"
}`}
>
{tool.status === "done" ? (
<CheckCircle2 className="h-3 w-3" />
) : (
<Loader2 className="h-3 w-3 animate-spin" />
)}
{tool.name}
</div>
))}
</div>
)}
<div className="rounded-2xl px-5 py-3 bg-white dark:bg-card border border-border/50 rounded-bl-md shadow-sm">
<GovMarkdown content={cleanContent} />
</div>
{cleanContent && (
<div className="flex mt-1 opacity-0 group-hover/msg:opacity-100 transition-opacity">
<button
onClick={() => onCopy(cleanContent)}
className="flex items-center gap-1 text-[11px] text-muted-foreground hover:text-foreground px-1.5 py-0.5 rounded"
>
<Copy className="h-3 w-3" />
</button>
</div>
)}
</div>
</div>
);
});
interface AgentUIProps {
app: App;
}
export default function AgentUI({ app }: AgentUIProps) {
const router = useRouter();
const queryClient = useQueryClient();
const [conversationId, setConversationId] = useState<string | undefined>();
const [messages, setMessages] = useState<Message[]>(
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<Set<string>>(new Set());
const [deleteTarget, setDeleteTarget] = useState<{
type: "single" | "batch";
id?: string;
name?: string;
} | null>(null);
const [editingConvId, setEditingConvId] = useState<string | null>(null);
const [editingName, setEditingName] = useState("");
const [sidebarOpen, setSidebarOpen] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const abortRef = useRef<AbortController | null>(null);
const renameInputRef = useRef<HTMLInputElement>(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 (
<div className="flex h-[calc(100vh-3.5rem)] overflow-hidden">
{/* 删除确认弹窗 */}
<AlertDialog open={!!deleteTarget} onOpenChange={(open) => !open && setDeleteTarget(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
{deleteTarget?.type === "single"
? `确定要删除对话"${deleteTarget.name || "新对话"}"吗?删除后无法恢复。`
: `确定要删除选中的 ${selectedIds.size} 个对话吗?删除后无法恢复。`}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={() => {
if (deleteTarget?.type === "single" && deleteTarget.id) {
confirmDeleteSingle(deleteTarget.id);
} else {
confirmBatchDelete();
}
}}
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* 手机端侧边栏遮罩层 */}
{sidebarOpen && (
<div
className="fixed inset-0 z-40 bg-black/40 md:hidden"
onClick={() => setSidebarOpen(false)}
/>
)}
{/* 侧边栏 */}
<div
className={`fixed inset-y-[3.5rem] left-0 z-50 w-64 border-r bg-background flex flex-col shrink-0 overflow-hidden transition-transform duration-200 md:static md:inset-y-0 md:translate-x-0 ${sidebarOpen ? "translate-x-0" : "-translate-x-full"}`}
>
<div className="p-3 border-b space-y-2 shrink-0">
<Button
variant="ghost"
size="sm"
className="w-full justify-start gap-1.5 text-muted-foreground"
onClick={() => router.push("/store")}
>
<ArrowLeft className="h-3.5 w-3.5" />
</Button>
<Button
onClick={startNewConversation}
className="w-full gap-1.5 bg-blue-900 hover:bg-blue-800 text-white"
size="sm"
>
<Plus className="h-3.5 w-3.5" />
</Button>
</div>
{tools.length > 0 && (
<div className="p-3 border-b shrink-0">
<p className="text-xs font-medium text-muted-foreground mb-2 flex items-center gap-1">
<Wrench className="h-3 w-3" />
</p>
<div className="flex flex-wrap gap-1.5">
{tools.map((tool) => (
<Badge key={tool} variant="outline" className="text-xs font-normal gap-1">
<Sparkles className="h-2.5 w-2.5" />
{tool}
</Badge>
))}
</div>
</div>
)}
{conversations.length > 0 && (
<div className="px-3 py-2 border-b flex items-center justify-between shrink-0">
<span className="text-xs text-muted-foreground">
{selectMode ? `已选 ${selectedIds.size}` : `${conversations.length} 个对话`}
</span>
<div className="flex items-center gap-1">
{selectMode ? (
<>
<Button
variant="ghost"
size="sm"
className="h-6 px-1.5 text-xs"
onClick={selectAll}
>
</Button>
<Button
variant="ghost"
size="sm"
className="h-6 px-1.5 text-xs text-destructive hover:text-destructive"
onClick={() => setDeleteTarget({ type: "batch" })}
disabled={selectedIds.size === 0}
>
<Trash2 className="h-3 w-3 mr-0.5" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-6 px-1.5 text-xs"
onClick={() => {
setSelectMode(false);
setSelectedIds(new Set());
}}
>
<X className="h-3 w-3" />
</Button>
</>
) : (
<Button
variant="ghost"
size="sm"
className="h-6 px-1.5 text-xs"
onClick={() => setSelectMode(true)}
>
<CheckSquare className="h-3 w-3 mr-0.5" />
</Button>
)}
</div>
</div>
)}
<div className="flex-1 overflow-y-auto min-h-0 p-2">
{conversations.length === 0 ? (
<p className="text-xs text-muted-foreground text-center py-4"></p>
) : (
<div className="space-y-0.5">
{conversations.map((conv) => (
<div key={conv.id} className="group flex items-center gap-1">
{selectMode && (
<button onClick={() => toggleSelect(conv.id)} className="shrink-0 p-0.5">
{selectedIds.has(conv.id) ? (
<CheckSquare className="h-3.5 w-3.5 text-primary" />
) : (
<Square className="h-3.5 w-3.5 text-muted-foreground" />
)}
</button>
)}
{editingConvId === conv.id ? (
<input
ref={renameInputRef}
value={editingName}
onChange={(e) => 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}
/>
) : (
<button
onClick={() => !selectMode && loadConversation(conv.id)}
onDoubleClick={() => !selectMode && startRename(conv.id, conv.name)}
className={`flex-1 text-left p-2 rounded-md text-sm truncate transition-colors ${
conversationId === conv.id ? "bg-muted font-medium" : "hover:bg-muted/60"
}`}
title={`${conv.name}\n双击重命名`}
>
<MessageSquare className="h-3.5 w-3.5 inline mr-1.5 opacity-50" />
{conv.name || "新对话"}
</button>
)}
{!selectMode && editingConvId !== conv.id && (
<div className="shrink-0 flex items-center opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={(e) => {
e.stopPropagation();
startRename(conv.id, conv.name);
}}
className="p-1 rounded hover:bg-muted"
title="重命名"
>
<Pencil className="h-3 w-3" />
</button>
<button
onClick={(e) => {
e.stopPropagation();
setDeleteTarget({ type: "single", id: conv.id, name: conv.name });
}}
className="p-1 rounded hover:bg-destructive/10 hover:text-destructive"
title="删除对话"
>
<Trash2 className="h-3 w-3" />
</button>
</div>
)}
</div>
))}
</div>
)}
</div>
</div>
{/* 主对话区域 */}
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
<div className="border-b px-3 md:px-5 py-3 flex items-center gap-2 md:gap-3 shrink-0">
<button
className="md:hidden p-1.5 rounded-md text-muted-foreground hover:bg-muted"
onClick={() => setSidebarOpen(true)}
>
<PanelLeftOpen className="h-4 w-4" />
</button>
<div
className={`flex h-9 w-9 items-center justify-center rounded-xl ${categoryColor} shrink-0`}
>
{CategoryIconComponent
? React.createElement(CategoryIconComponent, { className: "h-4.5 w-4.5" })
: null}
</div>
<div className="min-w-0">
<h1 className="font-semibold text-sm truncate">{app.name}</h1>
<p className="text-xs text-muted-foreground truncate max-w-[150px] md:max-w-none">
{app.description}
</p>
</div>
<div className="ml-auto flex items-center gap-1 md:gap-2">
{messages.length > 1 && (
<Button
variant="ghost"
size="sm"
className="h-7 gap-1.5 text-xs"
onClick={exportConversation}
>
<Download className="h-3 w-3" />
</Button>
)}
<div className="flex items-center gap-1.5 text-xs text-blue-800 bg-blue-100 px-2 py-1 rounded-full font-medium">
<Bot className="h-3 w-3" />
</div>
</div>
</div>
<div className="flex-1 overflow-y-auto min-h-0 p-4">
<div className="max-w-3xl mx-auto space-y-4">
{messages.map((msg) => (
<AgentMessage key={msg.id} msg={msg} onCopy={copyText} />
))}
{messages.length <= 1 && suggestedPrompts.length > 0 && (
<div className="flex flex-wrap gap-2 mt-4">
{suggestedPrompts.map((prompt: string, i: number) => (
<Button
key={i}
variant="outline"
size="sm"
className="text-xs"
onClick={() => {
setInput(prompt);
textareaRef.current?.focus();
}}
>
{prompt}
</Button>
))}
</div>
)}
<div ref={messagesEndRef} />
</div>
</div>
<div className="border-t p-2 md:p-4 shrink-0">
<div className="max-w-3xl mx-auto flex gap-2">
<Textarea
ref={textareaRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="输入消息... (Enter 发送,Shift+Enter 换行)"
className="resize-none min-h-[44px] max-h-32"
rows={1}
disabled={isStreaming}
/>
<Button
onClick={sendMessage}
disabled={!input.trim() || isStreaming}
className="shrink-0 gap-1.5"
>
{isStreaming ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Send className="h-4 w-4" />
)}
{isStreaming ? "思考中" : "发送"}
</Button>
</div>
</div>
</div>
</div>
);
}