Files
mnote/wolai-frontend/src/components/editor/DocumentAiAgentPanel.tsx
T
2026-01-10 23:08:56 +08:00

1210 lines
47 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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<ToolName, string> = {
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<string, unknown> }
| { 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<AgentMessage[]>(() => 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<ToolName[]>(DEFAULT_TOOLS);
const [maxSteps, setMaxSteps] = useState<number>(10);
const [aiProvider, setAiProvider] = useState<"online" | "local">("online");
const [aiModel, setAiModel] = useState<string>("");
const [page, setPage] = useState<PanelPage>("chat");
const [toolLogs, setToolLogs] = useState<ToolLog[]>([]);
const [sessions, setSessions] = useState<ChatSession[]>([]);
const [activeSessionId, setActiveSessionId] = useState<string>("");
// 兼容旧实现(已改为侧边栏内切换页面,不再弹出居中对话框)
const [toolsDialogOpen, setToolsDialogOpen] = useState(false);
const [historyDialogOpen, setHistoryDialogOpen] = useState(false);
const [accountDialogOpen, setAccountDialogOpen] = useState(false);
const [settingsDialogOpen, setSettingsDialogOpen] = useState(false);
const abortRef = useRef<AbortController | null>(null);
const syncTimerRef = useRef<number | null>(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<string, unknown>).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<string, unknown>) : {}) as Record<string, unknown>;
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<string, unknown>) : {}) 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<string, unknown>) : {}) as Record<string, unknown>;
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<string, unknown>) ? (r as Record<string, unknown>).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<string, unknown>).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<string, unknown>).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 (
<Sheet open={open} onOpenChange={setOpen}>
<SheetContent side="right" className="p-0">
<SheetHeader className="border-b">
<SheetTitle className="flex items-center justify-between gap-2">
<span className="flex items-center gap-2">
{page === "chat" ? (
<Sparkles className="h-4 w-4" />
) : (
<Button
variant="ghost"
size="icon"
onClick={() => setPage("chat")}
disabled={loading}
title="返回对话"
>
<ChevronLeft className="h-4 w-4" />
</Button>
)}
{pageTitle}
</span>
<div className="flex items-center gap-1">
<Button variant="ghost" size="icon" onClick={startNewSession} disabled={loading} title="新建会话">
<Plus className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => setPage("tools")}
disabled={loading}
title="工具(代替 MCP"
>
<Wrench className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => setPage("history")}
disabled={loading}
title="历史"
>
<History className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => setPage("account")}
disabled={loading}
title="账户/模型"
>
<User className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => setPage("settings")}
disabled={loading}
title="设置"
>
<Settings className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" onClick={() => setOpen(false)} title="关闭">
<X className="h-4 w-4" />
</Button>
</div>
</SheetTitle>
</SheetHeader>
<div className="flex flex-1 flex-col overflow-hidden">
<div className={page === "chat" ? "" : "hidden"}>
<div className="hidden">
<div className="flex items-center gap-2">
<button
type="button"
className={`inline-flex items-center gap-1 rounded border px-2 py-1 ${networkOn ? "bg-white" : "bg-muted"}`}
onClick={() => setNetworkOn((v) => !v)}
disabled={loading}
title="联网检索"
>
<Network className="h-3.5 w-3.5" />
{networkOn ? "联网" : "离线"}
</button>
<button
type="button"
className={`inline-flex items-center gap-1 rounded border px-2 py-1 ${toolAuto ? "bg-white" : "bg-muted"}`}
onClick={() => setToolAuto((v) => !v)}
disabled={loading}
title="工具自动/手动"
>
<Settings2 className="h-3.5 w-3.5" />
{toolAuto ? "自动工具" : "手动工具"}
</button>
{!toolAuto && (
<button
type="button"
className="inline-flex items-center gap-1 rounded border bg-white px-2 py-1"
onClick={() => setToolPickerOpen((v) => !v)}
disabled={loading}
>
<Settings2 className="h-3.5 w-3.5" />
选择工具
</button>
)}
</div>
<div className="flex items-center gap-2">
<label className="flex items-center gap-2">
步数
<input
className="w-[72px] rounded border px-2 py-1 text-xs"
type="number"
min={1}
max={24}
step={1}
value={maxSteps}
onChange={(e) => {
const v = Number(e.target.value);
if (!Number.isFinite(v)) return;
setMaxSteps(Math.max(1, Math.min(24, Math.floor(v))));
}}
disabled={loading}
/>
</label>
<select
className="h-8 rounded border bg-white px-2 text-xs"
value={aiProvider}
onChange={(e) => setAiProvider(e.target.value === "local" ? "local" : "online")}
disabled={loading}
>
<option value="online">在线</option>
<option value="local">本地</option>
</select>
<input
className="h-8 w-[180px] rounded border px-2 text-xs"
value={aiModel}
onChange={(e) => setAiModel(e.target.value)}
placeholder="model(可选)"
disabled={loading}
/>
</div>
</div>
{false && (
<div className="border-b p-3 text-sm">
<div className="mb-2 text-xs text-muted-foreground">允许使用的工具</div>
<div className="flex flex-wrap gap-2">
{(Object.keys(TOOL_LABEL) as ToolName[]).map((t) => {
const on = selectedTools.includes(t);
return (
<button
key={t}
type="button"
className={`rounded border px-2 py-1 text-xs ${on ? "bg-[#111827] text-white" : "bg-white"}`}
onClick={() =>
setSelectedTools((prev) =>
prev.includes(t) ? prev.filter((x) => x !== t) : [...prev, t],
)
}
disabled={loading}
>
{TOOL_LABEL[t]}
</button>
);
})}
</div>
</div>
)}
<div className="flex min-h-0 flex-1 flex-col">
<div className="grid min-h-0 flex-1 grid-cols-1 gap-0">
<div className="min-h-0 border-b">
<div className="px-3 py-2 text-sm font-medium">对话</div>
<ScrollArea className="h-[38vh] border-t">
<div className="space-y-3 p-3 text-sm">
{messages.map((m, idx) => (
<div key={idx} className="space-y-1">
<div className="text-xs text-muted-foreground">{m.role === "user" ? "用户" : "AI"}</div>
<div className="whitespace-pre-wrap">{m.content}</div>
</div>
))}
</div>
</ScrollArea>
</div>
<div className="min-h-0">
<div className="px-3 py-2 text-sm font-medium">工具日志</div>
<ScrollArea className="h-[30vh] border-t">
<div className="space-y-3 p-3 text-sm">
{toolLogs.length === 0 ? <div className="text-muted-foreground">暂无工具日志</div> : null}
{toolLogs.map((l, idx) => {
if (l.type === "error") {
return (
<div key={idx} className="rounded border border-red-200 bg-red-50 p-2 text-red-700">
错误:{l.message}
</div>
);
}
if (l.type === "tool_call") {
return (
<details key={idx} className="rounded border p-2">
<summary className="cursor-pointer select-none text-xs text-muted-foreground">
tool_call · {l.tool} · {l.id}
</summary>
<pre className="mt-2 whitespace-pre-wrap text-xs">{JSON.stringify(l.args, null, 2)}</pre>
</details>
);
}
return (
<details key={idx} className="rounded border p-2">
<summary className="cursor-pointer select-none text-xs text-muted-foreground">
tool_result · {l.tool} · {l.id} · {l.ok ? "ok" : "fail"} · {l.ms}ms
</summary>
<pre className="mt-2 whitespace-pre-wrap text-xs">{JSON.stringify(l.result, null, 2)}</pre>
</details>
);
})}
</div>
</ScrollArea>
</div>
</div>
</div>
<div className="border-t p-3">
<div className="flex gap-2">
<Textarea
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="输入你的需求(Enter 发送,Shift+Enter 换行)"
className="min-h-[72px] flex-1"
disabled={loading}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
if (canSend) void send();
}
}}
/>
<div className="flex flex-col gap-2">
<Button disabled={!canSend} onClick={() => void send()}>
<Send className="mr-2 h-4 w-4" />
发送
</Button>
<Button variant="secondary" disabled={!loading} onClick={stop}>
<X className="mr-2 h-4 w-4" />
停止
</Button>
</div>
</div>
{!editorBridge ? (
<div className="mt-2 text-xs text-muted-foreground">
提示:编辑器尚未准备好,写入工具可能无法立即落入页面(稍等或刷新)。
</div>
) : null}
</div>
</div>
<div className={page === "chat" ? "hidden" : "min-h-0 flex-1 overflow-hidden"}>
{page === "tools" ? (
<ScrollArea className="h-full">
<div className="space-y-3 p-3 text-sm">
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
className={`inline-flex items-center gap-1 rounded border px-2 py-1 ${toolAuto ? "bg-white" : "bg-muted"}`}
onClick={() => setToolAuto((v) => !v)}
disabled={loading}
title="工具自动/手动"
>
<Settings2 className="h-3.5 w-3.5" />
{toolAuto ? "自动工具" : "手动工具"}
</button>
</div>
{!toolAuto ? (
<div>
<div className="mb-2 text-xs text-muted-foreground">允许使用的工具(手动模式)</div>
<div className="flex flex-wrap gap-2">
{(Object.keys(TOOL_LABEL) as ToolName[]).map((t) => {
const on = selectedTools.includes(t);
return (
<button
key={t}
type="button"
className={`rounded border px-2 py-1 text-xs ${on ? "bg-[#111827] text-white" : "bg-white"}`}
onClick={() =>
setSelectedTools((prev) =>
prev.includes(t) ? prev.filter((x) => x !== t) : [...prev, t],
)
}
disabled={loading}
>
{TOOL_LABEL[t]}
</button>
);
})}
</div>
</div>
) : (
<div className="text-xs text-muted-foreground">
自动模式下:AI 会在允许的 ToolSet 范围内自行选择工具。
</div>
)}
</div>
</ScrollArea>
) : null}
{page === "history" ? (
<ScrollArea className="h-full">
<div className="p-3 text-sm">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="text-xs text-muted-foreground">最多保留 20 个会话</div>
<div className="flex flex-wrap items-center gap-2">
<Button variant="outline" onClick={resetCurrentSession} disabled={loading}>
清空当前
</Button>
<Button variant="outline" onClick={startNewSession} disabled={loading}>
<Plus className="mr-2 h-4 w-4" />
新建会话
</Button>
<Button variant="destructive" onClick={clearHistory} disabled={loading}>
清空历史
</Button>
</div>
</div>
<div className="mt-3 space-y-2">
{sessions.map((s) => {
const active = s.id === activeSessionId;
const time = new Date(s.updatedAt || s.createdAt).toLocaleString();
return (
<div
key={s.id}
className={`flex items-center gap-2 rounded border px-3 py-2 ${active ? "border-[#111827]" : "border-border"}`}
>
<button
type="button"
className="min-w-0 flex-1 text-left"
onClick={() => {
switchSession(s.id);
setPage("chat");
}}
disabled={loading}
title={active ? "当前会话" : "切换到该会话"}
>
<div className="flex items-center justify-between gap-2">
<div className="truncate font-medium">{s.title || "未命名"}</div>
<div className="shrink-0 text-xs text-muted-foreground">{time}</div>
</div>
<div className="mt-1 text-xs text-muted-foreground">消息 {s.messages.length} · 日志 {s.toolLogs.length}</div>
</button>
<Button
variant="ghost"
size="sm"
onClick={() => deleteSession(s.id)}
disabled={loading || active}
title={active ? "不能删除当前会话" : "删除会话"}
>
删除
</Button>
</div>
);
})}
</div>
</div>
</ScrollArea>
) : null}
{page === "account" ? (
<ScrollArea className="h-full">
<div className="space-y-3 p-3 text-sm">
<div className="flex flex-wrap items-center gap-2">
<label className="text-xs text-muted-foreground">推理来源</label>
<select
className="h-9 rounded border bg-white px-2 text-sm"
value={aiProvider}
onChange={(e) => setAiProvider(e.target.value === "local" ? "local" : "online")}
disabled={loading}
>
<option value="online">在线</option>
<option value="local">本地</option>
</select>
<label className="ml-2 text-xs text-muted-foreground">模型(可选)</label>
<input
className="h-9 w-[260px] rounded border px-2 text-sm"
value={aiModel}
onChange={(e) => setAiModel(e.target.value)}
placeholder="例如 gemini-2.5-pro"
disabled={loading}
/>
</div>
<div className="text-xs text-muted-foreground">
说明:在线/本地的 BaseURL Key 仍由配置文件/环境变量控制;这里仅做 provider model 覆盖。
</div>
</div>
</ScrollArea>
) : null}
{page === "settings" ? (
<ScrollArea className="h-full">
<div className="space-y-3 p-3 text-sm">
<div className="flex flex-wrap items-center gap-3">
<button
type="button"
className={`inline-flex items-center gap-1 rounded border px-2 py-1 ${networkOn ? "bg-white" : "bg-muted"}`}
onClick={() => setNetworkOn((v) => !v)}
disabled={loading}
title="联网检索"
>
<Network className="h-3.5 w-3.5" />
{networkOn ? "联网" : "离线"}
</button>
<label className="flex items-center gap-2">
步数
<input
className="w-[92px] rounded border px-2 py-1 text-sm"
type="number"
min={1}
max={24}
step={1}
value={maxSteps}
onChange={(e) => {
const v = Number(e.target.value);
if (!Number.isFinite(v)) return;
setMaxSteps(clamp(Math.floor(v), 1, 24));
}}
disabled={loading}
/>
</label>
</div>
<div className="text-xs text-muted-foreground">说明:步数越大越“能做事”,但会更慢且更消耗推理额度。</div>
</div>
</ScrollArea>
) : null}
</div>
</div>
{/* 工具(代替 MCP */}
<Dialog open={toolsDialogOpen} onOpenChange={setToolsDialogOpen}>
<DialogContent showCloseButton={false} className="max-w-[720px]">
<DialogHeader>
<DialogTitle>工具(代替 MCP</DialogTitle>
</DialogHeader>
<div className="space-y-3 text-sm">
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
className={`inline-flex items-center gap-1 rounded border px-2 py-1 ${toolAuto ? "bg-white" : "bg-muted"}`}
onClick={() => setToolAuto((v) => !v)}
disabled={loading}
title="工具自动/手动"
>
<Settings2 className="h-3.5 w-3.5" />
{toolAuto ? "自动工具" : "手动工具"}
</button>
</div>
{!toolAuto ? (
<div>
<div className="mb-2 text-xs text-muted-foreground">允许使用的工具(手动模式)</div>
<div className="flex flex-wrap gap-2">
{(Object.keys(TOOL_LABEL) as ToolName[]).map((t) => {
const on = selectedTools.includes(t);
return (
<button
key={t}
type="button"
className={`rounded border px-2 py-1 text-xs ${on ? "bg-[#111827] text-white" : "bg-white"}`}
onClick={() =>
setSelectedTools((prev) =>
prev.includes(t) ? prev.filter((x) => x !== t) : [...prev, t],
)
}
disabled={loading}
>
{TOOL_LABEL[t]}
</button>
);
})}
</div>
</div>
) : (
<div className="text-xs text-muted-foreground">
自动模式下:AI 会在允许的 ToolSet 范围内自行选择工具。
</div>
)}
</div>
</DialogContent>
</Dialog>
{/* 历史 */}
<Dialog open={historyDialogOpen} onOpenChange={setHistoryDialogOpen}>
<DialogContent showCloseButton={false} className="max-w-[720px]">
<DialogHeader>
<DialogTitle>历史会话</DialogTitle>
</DialogHeader>
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="text-xs text-muted-foreground">最多保留 20 个会话</div>
<div className="flex flex-wrap items-center gap-2">
<Button variant="outline" onClick={resetCurrentSession} disabled={loading}>
清空当前
</Button>
<Button variant="outline" onClick={startNewSession} disabled={loading}>
<Plus className="mr-2 h-4 w-4" />
新建会话
</Button>
<Button variant="destructive" onClick={clearHistory} disabled={loading}>
清空历史
</Button>
</div>
</div>
<div className="mt-3 space-y-2">
{sessions.map((s) => {
const active = s.id === activeSessionId;
const time = new Date(s.updatedAt || s.createdAt).toLocaleString();
return (
<div key={s.id} className={`flex items-center gap-2 rounded border px-3 py-2 ${active ? "border-[#111827]" : "border-border"}`}>
<button
type="button"
className="min-w-0 flex-1 text-left"
onClick={() => {
switchSession(s.id);
setHistoryDialogOpen(false);
}}
disabled={loading}
title={active ? "当前会话" : "切换到该会话"}
>
<div className="flex items-center justify-between gap-2">
<div className="truncate font-medium">{s.title || "未命名"}</div>
<div className="shrink-0 text-xs text-muted-foreground">{time}</div>
</div>
<div className="mt-1 text-xs text-muted-foreground">
消息 {s.messages.length} · 日志 {s.toolLogs.length}
</div>
</button>
<Button
variant="ghost"
size="sm"
onClick={() => deleteSession(s.id)}
disabled={loading || active}
title={active ? "不能删除当前会话" : "删除会话"}
>
删除
</Button>
</div>
);
})}
</div>
</DialogContent>
</Dialog>
{/* 账户/模型 */}
<Dialog open={accountDialogOpen} onOpenChange={setAccountDialogOpen}>
<DialogContent showCloseButton={false} className="max-w-[720px]">
<DialogHeader>
<DialogTitle>账户 / 模型</DialogTitle>
</DialogHeader>
<div className="space-y-3 text-sm">
<div className="flex flex-wrap items-center gap-2">
<label className="text-xs text-muted-foreground">推理来源</label>
<select
className="h-9 rounded border bg-white px-2 text-sm"
value={aiProvider}
onChange={(e) => setAiProvider(e.target.value === "local" ? "local" : "online")}
disabled={loading}
>
<option value="online">在线</option>
<option value="local">本地</option>
</select>
<label className="ml-2 text-xs text-muted-foreground">模型(可选)</label>
<input
className="h-9 w-[260px] rounded border px-2 text-sm"
value={aiModel}
onChange={(e) => setAiModel(e.target.value)}
placeholder="例如 gemini-2.5-pro"
disabled={loading}
/>
</div>
<div className="text-xs text-muted-foreground">
说明:在线/本地的 BaseURL Key 仍由配置文件/环境变量控制;这里仅做 provider model 覆盖。
</div>
</div>
</DialogContent>
</Dialog>
{/* 设置 */}
<Dialog open={settingsDialogOpen} onOpenChange={setSettingsDialogOpen}>
<DialogContent showCloseButton={false} className="max-w-[720px]">
<DialogHeader>
<DialogTitle>设置</DialogTitle>
</DialogHeader>
<div className="space-y-3 text-sm">
<div className="flex flex-wrap items-center gap-3">
<button
type="button"
className={`inline-flex items-center gap-1 rounded border px-2 py-1 ${networkOn ? "bg-white" : "bg-muted"}`}
onClick={() => setNetworkOn((v) => !v)}
disabled={loading}
title="联网检索"
>
<Network className="h-3.5 w-3.5" />
{networkOn ? "联网" : "离线"}
</button>
<label className="flex items-center gap-2">
步数
<input
className="w-[92px] rounded border px-2 py-1 text-sm"
type="number"
min={1}
max={24}
step={1}
value={maxSteps}
onChange={(e) => {
const v = Number(e.target.value);
if (!Number.isFinite(v)) return;
setMaxSteps(clamp(Math.floor(v), 1, 24));
}}
disabled={loading}
/>
</label>
</div>
<div className="text-xs text-muted-foreground">说明:步数越大越“能做事”,但会更慢且更消耗推理额度。</div>
</div>
</DialogContent>
</Dialog>
</SheetContent>
</Sheet>
);
}