1210 lines
47 KiB
TypeScript
1210 lines
47 KiB
TypeScript
"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>
|
|||
|
|
);
|
|||
|
|
}
|