Initial commit: GovAI 政务AI平台
This commit is contained in:
@@ -0,0 +1,620 @@
|
||||
"use client";
|
||||
|
||||
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 [messages, setMessages] = useState<Message[]>([]);
|
||||
const [input, setInput] = useState("");
|
||||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
const [conversationId, setConversationId] = useState<string | undefined>();
|
||||
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(() => {
|
||||
if (app.welcome_message && messages.length === 0 && !conversationId) {
|
||||
setMessages([{ id: "welcome", role: "assistant", content: app.welcome_message }]);
|
||||
}
|
||||
}, [app.welcome_message, messages.length, conversationId]);
|
||||
|
||||
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 CategoryIcon = getCategoryIcon(app.category_slug);
|
||||
const categoryColor = getCategoryColor(app.category_slug);
|
||||
|
||||
const suggestedPrompts = useRef(
|
||||
(() => {
|
||||
try {
|
||||
if (typeof app.suggested_prompts === "string") return JSON.parse(app.suggested_prompts) as string[];
|
||||
return (app.suggested_prompts as string[]) || [];
|
||||
} catch { return []; }
|
||||
})()
|
||||
).current;
|
||||
|
||||
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`}>
|
||||
<CategoryIcon className="h-4.5 w-4.5" />
|
||||
</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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user