8959456eb7
- 修复 AuthLoader 组件中 router.push 在渲染期间调用的 React 警告
- 追问问题改为只在最新 AI 回复下方显示(修复多处显示问题)
- 新增 LLM 生成追问 API:POST /api/v1/apps/{id}/suggestions
- 前端调用 LLM API 生成智能追问,失败时降级到规则生成
- 登录页填入默认测试账号 kj-admin@govai.gov.cn
705 lines
25 KiB
TypeScript
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>
|
|
);
|
|
}
|