"use client"; import React, { useEffect, useMemo, useRef, useState } from "react"; import { ChevronLeft, History, Network, Plus, Settings, Settings2, Send, Sparkles, User, Wrench, X } from "lucide-react"; import type { Json } from "@/types/supabase"; import { useEditorBridgeStore } from "@/store/editor-bridge"; import { useAiAgentUiStore } from "@/store/ai-agent-ui"; import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Textarea } from "@/components/ui/textarea"; import { Sheet, SheetContent, SheetHeader, SheetTitle, } from "@/components/ui/sheet"; type AgentMessage = { role: "user" | "assistant"; content: string }; const DEFAULT_SESSION_MESSAGES: AgentMessage[] = [ { role: "assistant", content: "你好,我是页面 AI Agent。\n- 我可以查找/插入/改写页面内容(直接落入页面)。\n- 我也可以:LightRAG 检索、跨页面搜索/读取、读取图片 OCR、执行斜杠命令(创建/改名)。\n- 建议先说你的目标(例如:把某段改写更简洁,或在某个标题后新增一段总结)。", }, ]; type ToolName = | "search_web" | "rag_lightrag_query" | "docs_search" | "docs_read" | "image_read" | "slash_run" | "doc_get" | "doc_find" | "doc_insert_blocks" | "doc_replace_range"; const TOOL_LABEL: Record = { search_web: "联网检索(SearxNG)", rag_lightrag_query: "LightRAG 检索", docs_search: "文档搜索(跨页)", docs_read: "文档读取(跨页)", image_read: "图片读取(OCR)", slash_run: "斜杠命令(写入)", doc_get: "读页面(摘要)", doc_find: "查找(按块)", doc_insert_blocks: "插入块(写入)", doc_replace_range: "替换块文本(写入)", }; const DEFAULT_TOOLS: ToolName[] = [ "doc_get", "doc_find", "doc_insert_blocks", "doc_replace_range", "docs_search", "docs_read", "image_read", "rag_lightrag_query", "search_web", ]; type ToolLog = | { type: "tool_call"; id: string; tool: string; args: Record } | { type: "tool_result"; id: string; tool: string; ok: boolean; ms: number; result: unknown } | { type: "error"; message: string }; type ChatSession = { id: string; title: string; createdAt: number; updatedAt: number; messages: AgentMessage[]; toolLogs: ToolLog[]; }; type PanelPage = "chat" | "tools" | "history" | "account" | "settings"; const clamp = (n: number, min: number, max: number) => Math.max(min, Math.min(max, n)); const generateId = () => { if (typeof crypto !== "undefined" && "randomUUID" in crypto) return crypto.randomUUID(); return `sess_${Math.random().toString(16).slice(2, 10)}`; }; const normalizeSessions = (sessions: ChatSession[]) => { const maxSessions = 20; const maxMessages = 40; const maxToolLogs = 80; return sessions .slice(0, maxSessions) .map((s) => ({ ...s, messages: Array.isArray(s.messages) ? s.messages.slice(-maxMessages) : [], toolLogs: Array.isArray(s.toolLogs) ? s.toolLogs.slice(-maxToolLogs) : [], })) .sort((a, b) => b.updatedAt - a.updatedAt); }; const parseSseChunks = async ( res: Response, onEvent: (event: string, dataText: string) => void, ) => { if (!res.body) throw new Error("响应不支持流式读取"); const reader = res.body.getReader(); const decoder = new TextDecoder("utf-8"); let buffer = ""; while (true) { const { value, done } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); while (true) { const sep = buffer.indexOf("\n\n"); if (sep === -1) break; const raw = buffer.slice(0, sep); buffer = buffer.slice(sep + 2); // 注释/心跳:以 ":" 开头 if (raw.trimStart().startsWith(":")) continue; const lines = raw.split(/\r?\n/); let event = "message"; const dataLines: string[] = []; for (const line of lines) { if (line.startsWith("event:")) event = line.slice("event:".length).trim() || "message"; if (line.startsWith("data:")) dataLines.push(line.slice("data:".length).trimStart()); } onEvent(event, dataLines.join("\n")); } } }; const safeJsonStringify = (value: unknown) => { try { return JSON.stringify(value); } catch { return ""; } }; export function DocumentAiAgentPanel({ documentId, getLatestBlocks, }: { documentId: string; getLatestBlocks: () => Json | null; }) { const editorBridge = useEditorBridgeStore((s) => s.bridge); const open = useAiAgentUiStore((s) => s.documentAgentOpen); const setOpen = useAiAgentUiStore((s) => s.setDocumentAgentOpen); const setAvailable = useAiAgentUiStore((s) => s.setDocumentAgentAvailable); const [messages, setMessages] = useState(() => DEFAULT_SESSION_MESSAGES); const [input, setInput] = useState(""); const [loading, setLoading] = useState(false); const [networkOn, setNetworkOn] = useState(true); const [toolAuto, setToolAuto] = useState(true); const [toolPickerOpen, setToolPickerOpen] = useState(false); const [selectedTools, setSelectedTools] = useState(DEFAULT_TOOLS); const [maxSteps, setMaxSteps] = useState(10); const [aiProvider, setAiProvider] = useState<"online" | "local">("online"); const [aiModel, setAiModel] = useState(""); const [page, setPage] = useState("chat"); const [toolLogs, setToolLogs] = useState([]); const [sessions, setSessions] = useState([]); const [activeSessionId, setActiveSessionId] = useState(""); // 兼容旧实现(已改为侧边栏内切换页面,不再弹出居中对话框) const [toolsDialogOpen, setToolsDialogOpen] = useState(false); const [historyDialogOpen, setHistoryDialogOpen] = useState(false); const [accountDialogOpen, setAccountDialogOpen] = useState(false); const [settingsDialogOpen, setSettingsDialogOpen] = useState(false); const abortRef = useRef(null); const syncTimerRef = useRef(null); useEffect(() => { setAvailable(true); return () => { setAvailable(false); setOpen(false); }; }, [setAvailable, setOpen]); useEffect(() => { try { const stepsRaw = window.localStorage.getItem("doc_ai_max_steps") || ""; const p = (window.localStorage.getItem("doc_ai_provider") || "").trim(); const m = window.localStorage.getItem("doc_ai_model") || ""; const parsed = Number(stepsRaw); if (Number.isFinite(parsed) && parsed >= 1) { setMaxSteps(Math.max(1, Math.min(24, Math.floor(parsed)))); } if (p === "local" || p === "online") setAiProvider(p); if (typeof m === "string") setAiModel(m); } catch { // ignore } }, []); useEffect(() => { try { window.localStorage.setItem("doc_ai_max_steps", String(maxSteps)); window.localStorage.setItem("doc_ai_provider", aiProvider); window.localStorage.setItem("doc_ai_model", aiModel); } catch { // ignore } }, [aiModel, aiProvider, maxSteps]); useEffect(() => { // 关闭面板时,回到对话页,避免下次打开还停留在设置/历史等子页 if (!open) setPage("chat"); }, [open]); // 会话/历史:按 documentId 隔离持久化 useEffect(() => { try { const key = `doc_ai_sessions:${documentId}`; const raw = window.localStorage.getItem(key); if (!raw) { const id = generateId(); const now = Date.now(); const session: ChatSession = { id, title: "新会话", createdAt: now, updatedAt: now, messages: DEFAULT_SESSION_MESSAGES, toolLogs: [], }; setSessions([session]); setActiveSessionId(id); setMessages(session.messages); setToolLogs([]); return; } const parsed = JSON.parse(raw) as unknown; const list = typeof parsed === "object" && parsed && "sessions" in parsed ? (parsed as any).sessions : null; const active = typeof parsed === "object" && parsed && "activeSessionId" in parsed ? String((parsed as any).activeSessionId ?? "") : ""; if (!Array.isArray(list) || list.length === 0) return; const loaded = normalizeSessions( list .map((x) => { const id = String((x as any)?.id ?? "").trim() || generateId(); const createdAt = Number((x as any)?.createdAt ?? Date.now()); const updatedAt = Number((x as any)?.updatedAt ?? createdAt); const title = String((x as any)?.title ?? "").trim() || "历史会话"; const messages = Array.isArray((x as any)?.messages) ? (x as any).messages : DEFAULT_SESSION_MESSAGES; const toolLogs = Array.isArray((x as any)?.toolLogs) ? (x as any).toolLogs : []; return { id, title, createdAt, updatedAt, messages, toolLogs } as ChatSession; }) .filter((s) => s.id), ); setSessions(loaded); const picked = active && loaded.some((s) => s.id === active) ? active : loaded[0]!.id; setActiveSessionId(picked); const cur = loaded.find((s) => s.id === picked) ?? loaded[0]!; setMessages(cur.messages?.length ? cur.messages : DEFAULT_SESSION_MESSAGES); setToolLogs(cur.toolLogs ?? []); } catch { // ignore } // 只在 documentId 变化时读取一次 // eslint-disable-next-line react-hooks/exhaustive-deps }, [documentId]); useEffect(() => { if (!activeSessionId) return; if (syncTimerRef.current) window.clearTimeout(syncTimerRef.current); syncTimerRef.current = window.setTimeout(() => { setSessions((prev) => { const now = Date.now(); const next = prev.some((s) => s.id === activeSessionId) ? prev.map((s) => s.id === activeSessionId ? { ...s, messages, toolLogs, updatedAt: now } : s, ) : [ { id: activeSessionId, title: "新会话", createdAt: now, updatedAt: now, messages, toolLogs, }, ...prev, ]; return normalizeSessions(next); }); }, 200); return () => { if (syncTimerRef.current) window.clearTimeout(syncTimerRef.current); }; }, [activeSessionId, messages, toolLogs]); useEffect(() => { if (!documentId) return; try { const key = `doc_ai_sessions:${documentId}`; const payload = JSON.stringify({ activeSessionId, sessions: normalizeSessions(sessions) }); if (payload.length <= 900_000) window.localStorage.setItem(key, payload); } catch { // ignore } }, [activeSessionId, documentId, sessions]); const currentSession = useMemo(() => sessions.find((s) => s.id === activeSessionId) ?? null, [activeSessionId, sessions]); const currentSessionTitle = currentSession?.title || "新会话"; const pageTitle = useMemo(() => { switch (page) { case "tools": return "工具(代替 MCP)"; case "history": return "历史会话"; case "account": return "账户 / 模型"; case "settings": return "设置"; default: return "页面 AI Agent"; } }, [page]); const startNewSession = () => { if (loading) return; const id = generateId(); const now = Date.now(); const next: ChatSession = { id, title: "新会话", createdAt: now, updatedAt: now, messages: DEFAULT_SESSION_MESSAGES, toolLogs: [], }; setSessions((prev) => normalizeSessions([next, ...prev])); setActiveSessionId(id); setMessages(next.messages); setToolLogs([]); setInput(""); }; const clearHistory = () => { if (loading) return; const base: ChatSession = currentSession ?? ({ id: activeSessionId || generateId(), title: "当前会话", createdAt: Date.now(), updatedAt: Date.now(), messages, toolLogs, } as ChatSession); setSessions([ { ...base, title: base.title || "当前会话", updatedAt: Date.now(), messages, toolLogs, }, ]); setActiveSessionId(base.id); }; const switchSession = (id: string) => { if (loading) return; const target = sessions.find((s) => s.id === id); if (!target) return; setActiveSessionId(target.id); setMessages(target.messages?.length ? target.messages : DEFAULT_SESSION_MESSAGES); setToolLogs(target.toolLogs ?? []); setInput(""); }; const resetCurrentSession = () => { if (loading) return; setMessages(DEFAULT_SESSION_MESSAGES); setToolLogs([]); setInput(""); if (activeSessionId) { setSessions((prev) => normalizeSessions( prev.map((s) => s.id === activeSessionId ? { ...s, messages: DEFAULT_SESSION_MESSAGES, toolLogs: [], updatedAt: Date.now(), title: s.title || "当前会话" } : s, ), ), ); } }; const deleteSession = (id: string) => { if (loading) return; setSessions((prev) => { const next = prev.filter((s) => s.id !== id); return normalizeSessions(next); }); if (id === activeSessionId) { const fallback = sessions.filter((s) => s.id !== id)[0]; if (fallback) { setActiveSessionId(fallback.id); setMessages(fallback.messages?.length ? fallback.messages : DEFAULT_SESSION_MESSAGES); setToolLogs(fallback.toolLogs ?? []); } else { startNewSession(); } } }; const canSend = useMemo(() => input.trim().length > 0 && !loading, [input, loading]); const stop = () => { abortRef.current?.abort(); abortRef.current = null; setLoading(false); }; const send = async () => { const content = input.trim(); if (!content) return; setToolLogs([]); if (activeSessionId && currentSessionTitle === "新会话") { const title = content.length > 18 ? `${content.slice(0, 18)}…` : content; setSessions((prev) => normalizeSessions( prev.map((s) => (s.id === activeSessionId ? { ...s, title, updatedAt: Date.now() } : s)), ), ); } const nextMessages: AgentMessage[] = [...messages, { role: "user", content }]; setMessages(nextMessages); setInput(""); setLoading(true); abortRef.current?.abort(); const controller = new AbortController(); abortRef.current = controller; // 不把面板的“欢迎语”当作对话历史发送给服务端,避免影响任务执行 const payloadMessages = nextMessages.filter( (m, idx) => !(idx === 0 && m.role === "assistant" && /页面 AI Agent/.test(m.content)), ); const blocks = getLatestBlocks(); const blocksJson = blocks ? safeJsonStringify(blocks) : ""; const shouldSendBlocks = blocksJson && blocksJson.length <= 500_000; try { const res = await fetch("/api/ai-agent/run", { method: "POST", headers: { "Content-Type": "application/json" }, signal: controller.signal, body: JSON.stringify({ stream: true, maxSteps, scope: "document", messages: payloadMessages.slice(-24), toolChoice: toolAuto ? { mode: "auto", toolSets: [ "toolset.readonly", "toolset.rag_read", "toolset.docs_read", "toolset.media_read", "toolset.doc_read", "toolset.doc_write", "toolset.slash_write", ], } : { mode: "manual", tools: selectedTools }, context: { documentId, documentBlocks: shouldSendBlocks ? blocks : null }, options: { searxng: networkOn, ai: { provider: aiProvider, model: aiModel.trim() || undefined } }, }), }); if (!res.ok) { const j = (await res.json().catch(() => null)) as unknown; const err = typeof j === "object" && j && "error" in j ? String((j as Record).error ?? "") : ""; throw new Error(err || `请求失败:${res.status}`); } await parseSseChunks(res, (event, dataText) => { if (event === "tool_call") { try { const data = JSON.parse(dataText || "null") as unknown; const obj = (typeof data === "object" && data ? (data as Record) : {}) as Record; setToolLogs((prev) => [ ...prev, { type: "tool_call", id: String(obj.id ?? ""), tool: String(obj.tool ?? ""), args: (typeof obj.args === "object" && obj.args ? (obj.args as Record) : {}) as Record< string, unknown >, }, ]); } catch { // ignore } return; } if (event === "tool_result") { try { const data = JSON.parse(dataText || "null") as unknown; const obj = (typeof data === "object" && data ? (data as Record) : {}) as Record; const tool = String(obj.tool ?? ""); const result = "result" in obj ? obj.result : null; setToolLogs((prev) => [ ...prev, { type: "tool_result", id: String(obj.id ?? ""), tool, ok: Boolean(obj.ok), ms: Number(obj.ms ?? 0), result }, ]); // doc 写工具返回 data=blocks 时,立即落入编辑器 if ((tool === "doc_insert_blocks" || tool === "doc_replace_range") && obj.ok) { const r = result as unknown; const dataNode = typeof r === "object" && r && "data" in (r as Record) ? (r as Record).data : null; if (dataNode && editorBridge?.replaceWithSnapshot) { try { editorBridge.replaceWithSnapshot(dataNode as Json); } catch { // ignore } } } } catch { // ignore } return; } if (event === "assistant_message") { try { const data = JSON.parse(dataText || "null") as unknown; const assistantText = typeof data === "object" && data && "text" in data ? String((data as Record).text ?? "") : ""; setMessages((prev) => [...prev, { role: "assistant", content: assistantText.trim() ? assistantText.trim() : "(无输出)" }]); } catch { setMessages((prev) => [...prev, { role: "assistant", content: "(无输出)" }]); } return; } if (event === "error") { try { const data = JSON.parse(dataText || "null") as unknown; const message = typeof data === "object" && data && "message" in data ? String((data as Record).message ?? "") : ""; setToolLogs((prev) => [...prev, { type: "error", message: message || "未知错误" }]); } catch { setToolLogs((prev) => [...prev, { type: "error", message: "未知错误" }]); } return; } }); } catch (e) { const msg = e instanceof Error ? e.message : String(e); setToolLogs((prev) => [...prev, { type: "error", message: msg }]); } finally { abortRef.current = null; setLoading(false); } }; return ( {page === "chat" ? ( ) : ( )} {pageTitle}
{!toolAuto && ( )}
setAiModel(e.target.value)} placeholder="model(可选)" disabled={loading} />
{false && (
允许使用的工具
{(Object.keys(TOOL_LABEL) as ToolName[]).map((t) => { const on = selectedTools.includes(t); return ( ); })}
)}
对话
{messages.map((m, idx) => (
{m.role === "user" ? "用户" : "AI"}
{m.content}
))}
工具日志
{toolLogs.length === 0 ?
暂无工具日志
: null} {toolLogs.map((l, idx) => { if (l.type === "error") { return (
错误:{l.message}
); } if (l.type === "tool_call") { return (
tool_call · {l.tool} · {l.id}
{JSON.stringify(l.args, null, 2)}
); } return (
tool_result · {l.tool} · {l.id} · {l.ok ? "ok" : "fail"} · {l.ms}ms
{JSON.stringify(l.result, null, 2)}
); })}