feat: 提交 task-045 至 task-058 收口产物
- 收口 rust final closure checklist,推进页面/块系统/Mindmap/CLI/AI tools 到最终 cutover 状态 - 按 ai-frontend-simplification-plan-v1 接入 Hermes bridge,合并 AI 面板并清理旧前端编排残留 - 补充 harness 任务与进度记录,加入 CLI smoke 夹具/脚本,并修正文档页 bridge SSR 自请求回退逻辑
This commit is contained in:
@@ -2,7 +2,6 @@
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
Bot,
|
||||
Command,
|
||||
DatabaseZap,
|
||||
FileSearch,
|
||||
@@ -12,16 +11,16 @@ import {
|
||||
RefreshCcw,
|
||||
Search,
|
||||
SendHorizontal,
|
||||
Sparkles,
|
||||
SquareStop,
|
||||
Trash2,
|
||||
X,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { AiBridgePanel } from "./AiBridgePanel";
|
||||
import { parseSseChunks, readAiPanelPrefs, type AiProvider, writeAiPanelPrefs } from "./panelShared";
|
||||
|
||||
type ChatMsg = { role: "user" | "assistant"; content: string };
|
||||
type ToolLog =
|
||||
@@ -44,6 +43,7 @@ type ToolSetChip = {
|
||||
const DEFAULT_PROMPT = "请给出 gemini-3 tokens 价格,并提供来源链接。";
|
||||
const MIN_PANEL_AGENT_STEPS = 1;
|
||||
const MAX_PANEL_AGENT_STEPS = 24;
|
||||
const DEFAULT_PREFS = { provider: "online" as AiProvider, model: "", maxSteps: 10 };
|
||||
|
||||
const TOOLSET_CHIPS: ToolSetChip[] = [
|
||||
{ id: "toolset.readonly", title: "联网检索", description: "SearxNG 可追溯来源" },
|
||||
@@ -54,62 +54,13 @@ const TOOLSET_CHIPS: ToolSetChip[] = [
|
||||
];
|
||||
|
||||
const CAPABILITY_ITEMS: CapabilityItem[] = [
|
||||
{
|
||||
title: "联网检索",
|
||||
description: "搜索公开网页并返回来源链接,适合查价格、规格、资料。",
|
||||
icon: Search,
|
||||
},
|
||||
{
|
||||
title: "LightRAG",
|
||||
description: "结合知识库做语义检索与生成,适合已有资料沉淀场景。",
|
||||
icon: DatabaseZap,
|
||||
},
|
||||
{
|
||||
title: "跨页面文档",
|
||||
description: "搜索并读取当前工作区中的文档内容,用于对比和归纳。",
|
||||
icon: FileSearch,
|
||||
},
|
||||
{
|
||||
title: "图片 OCR",
|
||||
description: "若当前请求已带图片或附件,可读取其中的 OCR 文字内容。",
|
||||
icon: Image,
|
||||
},
|
||||
{
|
||||
title: "斜杠命令",
|
||||
description: "执行受控写操作,例如创建文档或改名,需要明确确认。",
|
||||
icon: Command,
|
||||
},
|
||||
{ title: "联网检索", description: "搜索公开网页并返回来源链接,适合查价格、规格、资料。", icon: Search },
|
||||
{ title: "LightRAG", description: "结合知识库做语义检索与生成,适合已有资料沉淀场景。", icon: DatabaseZap },
|
||||
{ title: "跨页面文档", description: "搜索并读取当前工作区中的文档内容,用于对比和归纳。", icon: FileSearch },
|
||||
{ title: "图片 OCR", description: "若当前请求已带图片或附件,可读取其中的 OCR 文字内容。", icon: Image },
|
||||
{ title: "斜杠命令", description: "执行受控写操作,例如创建文档或改名,需要明确确认。", icon: Command },
|
||||
];
|
||||
|
||||
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);
|
||||
|
||||
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 formatJson = (value: unknown) => {
|
||||
try {
|
||||
return JSON.stringify(value, null, 2);
|
||||
@@ -125,14 +76,10 @@ const isAbortLikeError = (error: unknown) => {
|
||||
);
|
||||
};
|
||||
|
||||
const clampStep = (value: number) => {
|
||||
return Math.min(Math.max(value, MIN_PANEL_AGENT_STEPS), MAX_PANEL_AGENT_STEPS);
|
||||
};
|
||||
const clampStep = (value: number) => Math.min(Math.max(value, MIN_PANEL_AGENT_STEPS), MAX_PANEL_AGENT_STEPS);
|
||||
|
||||
const getLogToneClass = (log: ToolLog) => {
|
||||
if (log.type === "error") {
|
||||
return "border-red-500/30 bg-red-500/10";
|
||||
}
|
||||
if (log.type === "error") return "border-red-500/30 bg-red-500/10";
|
||||
if (log.type === "tool_result") {
|
||||
return log.ok ? "border-emerald-500/20 bg-emerald-500/10" : "border-amber-500/25 bg-amber-500/10";
|
||||
}
|
||||
@@ -145,13 +92,28 @@ const getLogLabel = (log: ToolLog) => {
|
||||
return "运行错误";
|
||||
};
|
||||
|
||||
const getProviderLabel = (provider: AiProvider) => {
|
||||
switch (provider) {
|
||||
case "local":
|
||||
return "本地";
|
||||
case "ollama":
|
||||
return "Ollama";
|
||||
case "codex":
|
||||
return "Codex";
|
||||
default:
|
||||
return "Hermes";
|
||||
}
|
||||
};
|
||||
|
||||
export function AiAgentPanel({ onClose }: { onClose?: () => void } = {}) {
|
||||
const [input, setInput] = useState(DEFAULT_PROMPT);
|
||||
const [messages, setMessages] = useState<ChatMsg[]>([]);
|
||||
const [logs, setLogs] = useState<ToolLog[]>([]);
|
||||
const [running, setRunning] = useState(false);
|
||||
const [maxSteps, setMaxSteps] = useState(10);
|
||||
const [showLogs, setShowLogs] = useState(true);
|
||||
const [aiProvider, setAiProvider] = useState<AiProvider>(DEFAULT_PREFS.provider);
|
||||
const [aiModel, setAiModel] = useState(DEFAULT_PREFS.model);
|
||||
const [maxSteps, setMaxSteps] = useState(DEFAULT_PREFS.maxSteps);
|
||||
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const messageEndRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -161,6 +123,17 @@ export function AiAgentPanel({ onClose }: { onClose?: () => void } = {}) {
|
||||
const assistantCount = messages.filter((message) => message.role === "assistant").length;
|
||||
const userCount = messages.length - assistantCount;
|
||||
|
||||
useEffect(() => {
|
||||
const prefs = readAiPanelPrefs("global_ai", DEFAULT_PREFS);
|
||||
setAiProvider(prefs.provider);
|
||||
setAiModel(prefs.model);
|
||||
setMaxSteps(clampStep(prefs.maxSteps));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
writeAiPanelPrefs("global_ai", { provider: aiProvider, model: aiModel, maxSteps: clampStep(maxSteps) });
|
||||
}, [aiModel, aiProvider, maxSteps]);
|
||||
|
||||
useEffect(() => {
|
||||
messageEndRef.current?.scrollIntoView({ block: "end" });
|
||||
}, [messages, logs, showLogs]);
|
||||
@@ -171,14 +144,10 @@ export function AiAgentPanel({ onClose }: { onClose?: () => void } = {}) {
|
||||
setRunning(false);
|
||||
};
|
||||
|
||||
const restoreDefaultPrompt = () => {
|
||||
setInput(DEFAULT_PROMPT);
|
||||
};
|
||||
const restoreDefaultPrompt = () => setInput(DEFAULT_PROMPT);
|
||||
|
||||
const clearConversation = () => {
|
||||
if (running) {
|
||||
stop();
|
||||
}
|
||||
if (running) stop();
|
||||
setMessages([]);
|
||||
setLogs([]);
|
||||
};
|
||||
@@ -210,13 +179,18 @@ export function AiAgentPanel({ onClose }: { onClose?: () => void } = {}) {
|
||||
mode: "auto",
|
||||
toolSets: TOOLSET_CHIPS.map((chip) => chip.id),
|
||||
},
|
||||
options: { searxng: true, ai: { provider: "online" } },
|
||||
options: {
|
||||
searxng: true,
|
||||
ai: {
|
||||
provider: aiProvider,
|
||||
...(aiProvider !== "codex" && aiModel.trim() ? { model: aiModel.trim() } : {}),
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
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 ?? "") : "";
|
||||
const err = typeof j === "object" && j && "error" in j ? String((j as Record<string, unknown>).error ?? "") : "";
|
||||
throw new Error(err || `HTTP ${res.status}`);
|
||||
}
|
||||
|
||||
@@ -282,9 +256,7 @@ export function AiAgentPanel({ onClose }: { onClose?: () => void } = {}) {
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted || isAbortLikeError(error)) {
|
||||
return;
|
||||
}
|
||||
if (controller.signal.aborted || isAbortLikeError(error)) return;
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setLogs((prev) => [...prev, { type: "error", message }]);
|
||||
} finally {
|
||||
@@ -293,340 +265,274 @@ export function AiAgentPanel({ onClose }: { onClose?: () => void } = {}) {
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="flex h-[calc(100vh-112px)] min-h-[720px] w-full overflow-hidden rounded-[28px] border border-white/10 bg-[#070b14] text-white shadow-[0_24px_80px_rgba(0,0,0,0.45)]">
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<header className="flex items-center gap-4 border-b border-white/8 px-5 py-4">
|
||||
<div className="inline-flex h-10 w-10 items-center justify-center rounded-2xl border border-sky-400/30 bg-sky-400/10 text-sky-100">
|
||||
<Bot className="h-5 w-5" />
|
||||
</div>
|
||||
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-semibold uppercase tracking-[0.24em] text-sky-200/70">MNOTE</span>
|
||||
<span className="h-1 w-1 rounded-full bg-white/25" />
|
||||
<span className="text-sm text-white/60">全局 AI</span>
|
||||
const logSidebar = showLogs ? (
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<div className="border-b border-white/8 px-4 py-4">
|
||||
<div className="text-sm font-medium text-white">活动轨迹</div>
|
||||
<div className="mt-1 text-xs text-white/45">前端已经退为桥接层,这里只显示回流事件。</div>
|
||||
</div>
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-3 px-4 py-4">
|
||||
{logs.length === 0 ? <div className="text-sm text-white/45">暂无工具活动</div> : null}
|
||||
{logs.map((log, index) => (
|
||||
<article key={`${log.type}-${index}`} className={`rounded-2xl border p-3 ${getLogToneClass(log)}`}>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-xs font-medium tracking-wide text-white/82">{getLogLabel(log)}</div>
|
||||
{log.type !== "error" ? <div className="text-[11px] text-white/40">{log.id}</div> : null}
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-2">
|
||||
<h1 className="text-lg font-semibold tracking-tight text-white">全局 AI</h1>
|
||||
<Badge className="border-sky-400/25 bg-sky-400/10 text-sky-100 hover:bg-sky-400/10" variant="outline">
|
||||
自动工具编排
|
||||
<div className="mt-2 text-sm font-medium text-white">{log.type === "error" ? log.message : log.tool}</div>
|
||||
{log.type === "tool_result" ? (
|
||||
<div className="mt-1 text-xs text-white/45">{log.ok ? "成功" : "失败"} · {log.ms}ms</div>
|
||||
) : null}
|
||||
{log.type === "tool_call" ? (
|
||||
<pre className="mt-3 whitespace-pre-wrap text-xs leading-6 text-white/70">{formatJson(log.args)}</pre>
|
||||
) : null}
|
||||
{log.type === "tool_result" ? (
|
||||
<pre className="mt-3 whitespace-pre-wrap text-xs leading-6 text-white/70">{formatJson(log.result)}</pre>
|
||||
) : null}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : undefined;
|
||||
|
||||
return (
|
||||
<AiBridgePanel
|
||||
title="全局 AI"
|
||||
subtitle="实验入口"
|
||||
status={`${running ? "运行中" : "待命"} · ${getProviderLabel(aiProvider)}`}
|
||||
onClose={onClose}
|
||||
secondaryActions={
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label="切换工具活动面板"
|
||||
title={showLogs ? "隐藏工具活动面板" : "显示工具活动面板"}
|
||||
className="rounded-xl border border-white/10 bg-white/[0.03] text-white/80 hover:bg-white/[0.08] hover:text-white"
|
||||
onClick={() => setShowLogs((prev) => !prev)}
|
||||
>
|
||||
{showLogs ? <PanelRightClose className="h-4 w-4" /> : <PanelRightOpen className="h-4 w-4" />}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label="恢复示例问题"
|
||||
title="恢复示例问题"
|
||||
className="rounded-xl border border-white/10 bg-white/[0.03] text-white/80 hover:bg-white/[0.08] hover:text-white"
|
||||
onClick={restoreDefaultPrompt}
|
||||
>
|
||||
<RefreshCcw className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label="清空对话"
|
||||
title="清空对话"
|
||||
disabled={!canClear}
|
||||
className="rounded-xl border border-white/10 bg-white/[0.03] text-white/80 hover:bg-white/[0.08] hover:text-white"
|
||||
onClick={clearConversation}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label="停止运行"
|
||||
title="停止本轮运行"
|
||||
disabled={!running}
|
||||
className="rounded-xl border border-white/10 bg-white/[0.03] text-white/80 hover:bg-white/[0.08] hover:text-white"
|
||||
onClick={stop}
|
||||
>
|
||||
<SquareStop className="h-4 w-4" />
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
sidebar={logSidebar}
|
||||
scrollBody={false}
|
||||
className="h-[calc(100vh-24px)] min-h-0 rounded-[24px] border-white/10 shadow-none"
|
||||
>
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<div className="border-b border-white/8 px-5 py-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-[0.22em] text-white/45">当前能力</div>
|
||||
<div className="mt-2 text-sm text-white/65">保留 MNOTE 当前真实工具,不再在前端继续扩张编排层。</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge className="border-white/10 bg-white/[0.04] text-white/70 hover:bg-white/[0.04]" variant="outline">
|
||||
用户 {userCount}
|
||||
</Badge>
|
||||
<Badge className="border-white/10 bg-white/[0.04] text-white/70 hover:bg-white/[0.04]" variant="outline">
|
||||
AI {assistantCount}
|
||||
</Badge>
|
||||
<Badge className="border-white/10 bg-white/[0.04] text-white/70 hover:bg-white/[0.04]" variant="outline">
|
||||
活动 {logs.length}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<Badge className="border-white/10 bg-white/[0.04] text-white/70 hover:bg-white/[0.04]" variant="outline">
|
||||
{running ? "运行中" : "待命"}
|
||||
</Badge>
|
||||
{onClose ? (
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{TOOLSET_CHIPS.map((chip) => (
|
||||
<Badge
|
||||
key={chip.id}
|
||||
variant="outline"
|
||||
className="rounded-full border-white/10 bg-white/[0.03] px-3 py-1.5 text-white/78 hover:bg-white/[0.03]"
|
||||
title={chip.description}
|
||||
>
|
||||
{chip.title}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1">
|
||||
<ScrollArea className="h-full">
|
||||
<div className="mx-auto flex max-w-4xl flex-col gap-4 px-5 py-5">
|
||||
{messages.length === 0 ? (
|
||||
<div className="max-w-3xl rounded-[24px] border border-white/10 bg-white/[0.04] p-5 shadow-[0_10px_40px_rgba(0,0,0,0.24)]">
|
||||
<div className="text-base font-semibold text-white">你好,我是 MNOTE 全局 AI。</div>
|
||||
<div className="mt-2 text-sm leading-7 text-white/72">我会基于当前已接入的工具完成可追溯的检索、阅读与受控写入任务:</div>
|
||||
<div className="mt-4 grid gap-3 sm:grid-cols-2">
|
||||
{CAPABILITY_ITEMS.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<div key={item.title} className="rounded-2xl border border-white/8 bg-black/15 p-3">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-white">
|
||||
<Icon className="h-4 w-4 text-sky-200/80" />
|
||||
{item.title}
|
||||
</div>
|
||||
<div className="mt-2 text-xs leading-6 text-white/58">{item.description}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-4 text-sm text-white/70">请输入你的目标,我会在允许的工具范围内完成分析,并把活动轨迹放到右侧。</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{messages.map((message, index) => {
|
||||
const isUser = message.role === "user";
|
||||
return (
|
||||
<article key={`${message.role}-${index}`} className={`flex gap-3 ${isUser ? "justify-end" : "justify-start"}`}>
|
||||
<div
|
||||
className={`max-w-[min(760px,92%)] rounded-[22px] border px-4 py-3 text-sm leading-7 shadow-[0_10px_30px_rgba(0,0,0,0.16)] ${
|
||||
isUser ? "border-sky-400/25 bg-sky-500/15 text-sky-50" : "border-white/10 bg-white/[0.04] text-white/90"
|
||||
}`}
|
||||
>
|
||||
<div className={`mb-2 text-xs ${isUser ? "text-sky-100/70" : "text-white/45"}`}>{isUser ? "我" : "AI"}</div>
|
||||
<div className="whitespace-pre-wrap break-words">{message.content}</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
<div ref={messageEndRef} />
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-white/8 px-5 py-4">
|
||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{TOOLSET_CHIPS.map((chip) => (
|
||||
<div key={`${chip.id}-summary`} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.03] px-3 py-1.5">
|
||||
<span className="text-xs font-medium text-white/82">{chip.title}</span>
|
||||
<span className="text-xs text-white/42">{chip.description}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<label className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.03] px-3 py-1.5 text-xs text-white/68">
|
||||
最大步数
|
||||
<input
|
||||
className="w-14 rounded-md border border-white/10 bg-black/20 px-2 py-1 text-right text-white outline-none"
|
||||
type="number"
|
||||
min={MIN_PANEL_AGENT_STEPS}
|
||||
max={MAX_PANEL_AGENT_STEPS}
|
||||
step={1}
|
||||
value={maxSteps}
|
||||
onChange={(e) => {
|
||||
const value = Number(e.target.value);
|
||||
if (!Number.isFinite(value)) return;
|
||||
setMaxSteps(clampStep(Math.floor(value)));
|
||||
}}
|
||||
disabled={running}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<select
|
||||
className="h-9 rounded-full border border-white/10 bg-white/[0.03] px-3 text-xs text-white outline-none"
|
||||
value={aiProvider}
|
||||
onChange={(e) => {
|
||||
const value = String(e.target.value || "").trim();
|
||||
if (value === "online" || value === "local" || value === "ollama" || value === "codex") {
|
||||
setAiProvider(value);
|
||||
return;
|
||||
}
|
||||
setAiProvider("online");
|
||||
}}
|
||||
disabled={running}
|
||||
>
|
||||
<option value="online">Hermes</option>
|
||||
<option value="local">本地</option>
|
||||
<option value="ollama">Ollama</option>
|
||||
<option value="codex">Codex</option>
|
||||
</select>
|
||||
|
||||
<input
|
||||
className="h-9 w-44 rounded-full border border-white/10 bg-white/[0.03] px-3 text-xs text-white outline-none placeholder:text-white/30"
|
||||
value={aiModel}
|
||||
onChange={(e) => setAiModel(e.target.value)}
|
||||
placeholder={aiProvider === "codex" ? "Codex 无需模型" : "模型(可选)"}
|
||||
disabled={running || aiProvider === "codex"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<Textarea
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder="输入问题,Enter 发送,Shift+Enter 换行"
|
||||
className="min-h-[140px] rounded-[24px] border-white/10 bg-white/[0.03] px-4 py-4 pb-16 pr-40 text-sm leading-7 text-white placeholder:text-white/30"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
if (canSend) void send();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="pointer-events-none absolute bottom-4 left-4 text-xs text-white/38">Enter 发送,Shift+Enter 换行</div>
|
||||
|
||||
{running ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label="关闭全局 AI"
|
||||
title="关闭"
|
||||
className="rounded-xl border border-white/10 bg-white/[0.03] text-white/80 hover:bg-white/[0.08] hover:text-white"
|
||||
onClick={onClose}
|
||||
variant="secondary"
|
||||
className="absolute bottom-4 right-16 rounded-xl border border-white/10 bg-white/[0.06] text-white hover:bg-white/[0.12]"
|
||||
onClick={stop}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
停止
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label="切换工具活动面板"
|
||||
title={showLogs ? "隐藏工具活动面板" : "显示工具活动面板"}
|
||||
className="rounded-xl border border-white/10 bg-white/[0.03] text-white/80 hover:bg-white/[0.08] hover:text-white"
|
||||
onClick={() => setShowLogs((prev) => !prev)}
|
||||
disabled={!canSend}
|
||||
aria-label="发送消息"
|
||||
title="发送"
|
||||
className="absolute bottom-4 right-4 h-10 w-10 rounded-full bg-sky-500 p-0 text-white hover:bg-sky-400"
|
||||
onClick={() => void send()}
|
||||
>
|
||||
{showLogs ? <PanelRightClose className="h-4 w-4" /> : <PanelRightOpen className="h-4 w-4" />}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label="恢复示例问题"
|
||||
title="恢复示例问题"
|
||||
className="rounded-xl border border-white/10 bg-white/[0.03] text-white/80 hover:bg-white/[0.08] hover:text-white"
|
||||
onClick={restoreDefaultPrompt}
|
||||
>
|
||||
<RefreshCcw className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label="清空对话"
|
||||
title="清空对话"
|
||||
disabled={!canClear}
|
||||
className="rounded-xl border border-white/10 bg-white/[0.03] text-white/80 hover:bg-white/[0.08] hover:text-white"
|
||||
onClick={clearConversation}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label="停止运行"
|
||||
title="停止本轮运行"
|
||||
disabled={!running}
|
||||
className="rounded-xl border border-white/10 bg-white/[0.03] text-white/80 hover:bg-white/[0.08] hover:text-white"
|
||||
onClick={stop}
|
||||
>
|
||||
<SquareStop className="h-4 w-4" />
|
||||
<SendHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="grid min-h-0 flex-1 grid-cols-1 xl:grid-cols-[minmax(0,1fr)_360px]">
|
||||
<div className="flex min-h-0 min-w-0 flex-col bg-[radial-gradient(circle_at_top,_rgba(56,189,248,0.08),_transparent_36%),linear-gradient(180deg,_rgba(255,255,255,0.02),_rgba(255,255,255,0.01))]">
|
||||
<div className="border-b border-white/8 px-5 py-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-xs uppercase tracking-[0.22em] text-white/45">
|
||||
<Sparkles className="h-3.5 w-3.5" />
|
||||
当前能力
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-white/65">保留 MNOTE 当前真实工具,不伪装未实现的会话历史和附件能力。</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge className="border-white/10 bg-white/[0.04] text-white/70 hover:bg-white/[0.04]" variant="outline">
|
||||
用户 {userCount}
|
||||
</Badge>
|
||||
<Badge className="border-white/10 bg-white/[0.04] text-white/70 hover:bg-white/[0.04]" variant="outline">
|
||||
AI {assistantCount}
|
||||
</Badge>
|
||||
<Badge className="border-white/10 bg-white/[0.04] text-white/70 hover:bg-white/[0.04]" variant="outline">
|
||||
活动 {logs.length}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{TOOLSET_CHIPS.map((chip) => (
|
||||
<Badge
|
||||
key={chip.id}
|
||||
variant="outline"
|
||||
className="rounded-full border-white/10 bg-white/[0.03] px-3 py-1.5 text-white/78 hover:bg-white/[0.03]"
|
||||
title={chip.description}
|
||||
>
|
||||
{chip.title}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<div className="mx-auto flex max-w-4xl flex-col gap-4 px-5 py-5">
|
||||
{messages.length === 0 ? (
|
||||
<div className="max-w-3xl rounded-[24px] border border-white/10 bg-white/[0.04] p-5 shadow-[0_10px_40px_rgba(0,0,0,0.24)]">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-1 inline-flex h-10 w-10 items-center justify-center rounded-2xl border border-white/10 bg-white/[0.06] text-white/85">
|
||||
<Bot className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-base font-semibold text-white">你好,我是 MNOTE 全局 AI。</div>
|
||||
<div className="mt-2 text-sm leading-7 text-white/72">我会基于当前已接入的工具完成可追溯的检索、阅读与受控写入任务:</div>
|
||||
<div className="mt-4 grid gap-3 sm:grid-cols-2">
|
||||
{CAPABILITY_ITEMS.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<div key={item.title} className="rounded-2xl border border-white/8 bg-black/15 p-3">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-white">
|
||||
<Icon className="h-4 w-4 text-sky-200/80" />
|
||||
{item.title}
|
||||
</div>
|
||||
<div className="mt-2 text-xs leading-6 text-white/58">{item.description}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-4 text-sm text-white/70">请输入你的目标,我会在允许的工具范围内自动完成分析,并把活动轨迹展示在右侧。</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{messages.map((message, index) => {
|
||||
const isUser = message.role === "user";
|
||||
return (
|
||||
<article key={`${message.role}-${index}`} className={`flex gap-3 ${isUser ? "justify-end" : "justify-start"}`}>
|
||||
{!isUser ? (
|
||||
<div className="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-2xl border border-white/10 bg-white/[0.06] text-sm font-semibold text-white/88">
|
||||
AI
|
||||
</div>
|
||||
) : null}
|
||||
<div
|
||||
className={`max-w-[min(760px,92%)] rounded-[22px] border px-4 py-3 text-sm leading-7 shadow-[0_10px_30px_rgba(0,0,0,0.16)] ${
|
||||
isUser
|
||||
? "border-sky-400/25 bg-sky-500/15 text-sky-50"
|
||||
: "border-white/10 bg-white/[0.04] text-white/90"
|
||||
}`}
|
||||
>
|
||||
<div className={`mb-2 text-xs ${isUser ? "text-sky-100/70" : "text-white/45"}`}>{isUser ? "我" : "AI"}</div>
|
||||
<div className="whitespace-pre-wrap break-words">{message.content}</div>
|
||||
</div>
|
||||
{isUser ? (
|
||||
<div className="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-2xl border border-sky-400/30 bg-sky-500/18 text-sm font-semibold text-sky-50">
|
||||
我
|
||||
</div>
|
||||
) : null}
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
<div ref={messageEndRef} />
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<div className="border-t border-white/8 px-5 py-4">
|
||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{TOOLSET_CHIPS.map((chip) => (
|
||||
<div
|
||||
key={`${chip.id}-summary`}
|
||||
className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.03] px-3 py-1.5"
|
||||
>
|
||||
<span className="text-xs font-medium text-white/82">{chip.title}</span>
|
||||
<span className="text-xs text-white/42">{chip.description}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<label className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.03] px-3 py-1.5 text-xs text-white/68">
|
||||
最大步数
|
||||
<input
|
||||
className="w-14 rounded-md border border-white/10 bg-black/20 px-2 py-1 text-right text-white outline-none"
|
||||
type="number"
|
||||
min={MIN_PANEL_AGENT_STEPS}
|
||||
max={MAX_PANEL_AGENT_STEPS}
|
||||
step={1}
|
||||
value={maxSteps}
|
||||
onChange={(e) => {
|
||||
const value = Number(e.target.value);
|
||||
if (!Number.isFinite(value)) return;
|
||||
setMaxSteps(clampStep(Math.floor(value)));
|
||||
}}
|
||||
disabled={running}
|
||||
/>
|
||||
</label>
|
||||
<Badge className="border-white/10 bg-white/[0.04] px-3 py-1.5 text-white/70 hover:bg-white/[0.04]" variant="outline">
|
||||
{running ? "状态:生成中…" : "状态:等待输入"}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<Textarea
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder="输入问题,Enter 发送,Shift+Enter 换行"
|
||||
className="min-h-[140px] rounded-[24px] border-white/10 bg-white/[0.03] px-4 py-4 pb-16 pr-40 text-sm leading-7 text-white placeholder:text-white/30"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
if (canSend) void send();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="pointer-events-none absolute bottom-4 left-4 text-xs text-white/38">Enter 发送,Shift+Enter 换行</div>
|
||||
|
||||
{running ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="absolute bottom-4 right-16 rounded-xl border border-white/10 bg-white/[0.06] text-white hover:bg-white/[0.12]"
|
||||
onClick={stop}
|
||||
>
|
||||
停止
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!canSend}
|
||||
aria-label="发送消息"
|
||||
title="发送"
|
||||
className="absolute bottom-4 right-4 h-10 w-10 rounded-full bg-sky-500 p-0 text-white hover:bg-sky-400"
|
||||
onClick={() => void send()}
|
||||
>
|
||||
<SendHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showLogs ? (
|
||||
<aside className="flex min-h-0 min-w-0 flex-col border-t border-white/8 bg-white/[0.02] xl:border-l xl:border-t-0">
|
||||
<div className="border-b border-white/8 px-4 py-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-white">本轮活动</div>
|
||||
<div className="mt-1 text-xs text-white/48">{running ? "AI 正在调度工具与生成回答" : "等待发起下一轮任务"}</div>
|
||||
</div>
|
||||
<Badge className="border-white/10 bg-white/[0.04] text-white/72 hover:bg-white/[0.04]" variant="outline">
|
||||
{logs.length}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<div className="space-y-3 p-4">
|
||||
{logs.length === 0 ? (
|
||||
<div className="rounded-2xl border border-dashed border-white/10 bg-white/[0.02] p-4 text-sm leading-7 text-white/45">
|
||||
还没有工具活动。发送问题后,这里会展示工具请求、执行结果和错误信息。
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{logs.map((log, index) => (
|
||||
<div key={`${log.type}-${index}`} className={`rounded-2xl border p-3 ${getLogToneClass(log)}`}>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-xs uppercase tracking-[0.18em] text-white/42">{getLogLabel(log)}</div>
|
||||
{"id" in log ? <div className="text-xs text-white/35">{log.id || "no-id"}</div> : null}
|
||||
</div>
|
||||
|
||||
{log.type === "error" ? (
|
||||
<div className="mt-3 whitespace-pre-wrap text-sm leading-7 text-red-100/90">{log.message}</div>
|
||||
) : null}
|
||||
|
||||
{log.type === "tool_call" ? (
|
||||
<>
|
||||
<div className="mt-3 flex items-center gap-2 text-sm font-medium text-white">
|
||||
<Search className="h-4 w-4 text-sky-200/80" />
|
||||
{log.tool}
|
||||
</div>
|
||||
<pre className="mt-3 overflow-auto rounded-xl border border-white/10 bg-black/20 p-3 text-xs leading-6 text-white/70">
|
||||
{formatJson(log.args)}
|
||||
</pre>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{log.type === "tool_result" ? (
|
||||
<>
|
||||
<div className="mt-3 flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-white">
|
||||
<Search className="h-4 w-4 text-emerald-200/80" />
|
||||
{log.tool}
|
||||
</div>
|
||||
<div className="text-xs text-white/45">{log.ms}ms</div>
|
||||
</div>
|
||||
<pre className="mt-3 overflow-auto rounded-xl border border-white/10 bg-black/20 p-3 text-xs leading-6 text-white/70">
|
||||
{formatJson(log.result)}
|
||||
</pre>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</aside>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</AiBridgePanel>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
'use client';
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { Sparkles } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type AiBridgePanelProps = {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
status?: string;
|
||||
onClose?: () => void;
|
||||
primaryAction?: ReactNode;
|
||||
secondaryActions?: ReactNode;
|
||||
children: ReactNode;
|
||||
sidebar?: ReactNode;
|
||||
className?: string;
|
||||
scrollBody?: boolean;
|
||||
scrollSidebar?: boolean;
|
||||
};
|
||||
|
||||
// 通用 AI 面板壳:统一视觉和双栏结构,具体状态由各场景 adapter 提供。
|
||||
export function AiBridgePanel({
|
||||
title,
|
||||
subtitle,
|
||||
status,
|
||||
onClose,
|
||||
primaryAction,
|
||||
secondaryActions,
|
||||
children,
|
||||
sidebar,
|
||||
className,
|
||||
scrollBody = true,
|
||||
scrollSidebar = true,
|
||||
}: AiBridgePanelProps) {
|
||||
return (
|
||||
<section
|
||||
className={cn(
|
||||
"flex h-[calc(100vh-112px)] min-h-[640px] w-full overflow-hidden rounded-[28px] border border-white/10 bg-[#070b14] text-white shadow-[0_24px_80px_rgba(0,0,0,0.45)]",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<header className="flex items-center gap-4 border-b border-white/8 px-5 py-4">
|
||||
<div className="inline-flex h-10 w-10 items-center justify-center rounded-2xl border border-sky-400/30 bg-sky-400/10 text-sky-100">
|
||||
<Sparkles className="h-5 w-5" />
|
||||
</div>
|
||||
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-semibold uppercase tracking-[0.24em] text-sky-200/70">MNOTE</span>
|
||||
<span className="h-1 w-1 rounded-full bg-white/25" />
|
||||
<span className="text-sm text-white/60">{subtitle}</span>
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-2">
|
||||
<h1 className="text-lg font-semibold tracking-tight text-white">{title}</h1>
|
||||
{status ? (
|
||||
<span className="rounded-full border border-sky-400/25 bg-sky-400/10 px-3 py-1 text-xs text-sky-100">
|
||||
{status}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{secondaryActions}
|
||||
{primaryAction}
|
||||
{onClose ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label="关闭"
|
||||
title="关闭"
|
||||
className="rounded-xl border border-white/10 bg-white/[0.03] text-white/80 hover:bg-white/[0.08] hover:text-white"
|
||||
onClick={onClose}
|
||||
>
|
||||
×
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className={cn("grid min-h-0 flex-1 grid-cols-1 xl:grid-cols-[minmax(0,1fr)_360px]", !sidebar && "xl:grid-cols-1")}>
|
||||
<div className="flex min-h-0 min-w-0 flex-col bg-[radial-gradient(circle_at_top,_rgba(56,189,248,0.08),_transparent_36%),linear-gradient(180deg,_rgba(255,255,255,0.02),_rgba(255,255,255,0.01))]">
|
||||
{scrollBody ? <ScrollArea className="min-h-0 flex-1">{children}</ScrollArea> : <div className="min-h-0 flex-1">{children}</div>}
|
||||
</div>
|
||||
|
||||
{sidebar ? (
|
||||
<aside className="flex min-h-0 min-w-0 flex-col border-t border-white/8 bg-white/[0.02] xl:border-l xl:border-t-0">
|
||||
{scrollSidebar ? (
|
||||
<ScrollArea className="min-h-0 flex-1">{sidebar}</ScrollArea>
|
||||
) : (
|
||||
<div className="min-h-0 flex-1">{sidebar}</div>
|
||||
)}
|
||||
</aside>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
"use client";
|
||||
|
||||
export type AiProvider = "online" | "local" | "ollama" | "codex";
|
||||
|
||||
export type AiPanelPrefs = {
|
||||
provider: AiProvider;
|
||||
model: string;
|
||||
maxSteps: number;
|
||||
};
|
||||
|
||||
export 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"));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const readAiPanelPrefs = (
|
||||
storageKeyPrefix: string,
|
||||
defaults: AiPanelPrefs,
|
||||
): AiPanelPrefs => {
|
||||
if (typeof window === "undefined") return defaults;
|
||||
|
||||
try {
|
||||
const providerRaw = (window.localStorage.getItem(`${storageKeyPrefix}_provider`) || "").trim();
|
||||
const model = window.localStorage.getItem(`${storageKeyPrefix}_model`) || defaults.model;
|
||||
const stepsRaw = window.localStorage.getItem(`${storageKeyPrefix}_max_steps`) || "";
|
||||
const parsedSteps = Number(stepsRaw);
|
||||
|
||||
const provider: AiProvider =
|
||||
providerRaw === "online" || providerRaw === "local" || providerRaw === "ollama" || providerRaw === "codex"
|
||||
? providerRaw
|
||||
: defaults.provider;
|
||||
|
||||
return {
|
||||
provider,
|
||||
model,
|
||||
maxSteps: Number.isFinite(parsedSteps) ? Math.floor(parsedSteps) : defaults.maxSteps,
|
||||
};
|
||||
} catch {
|
||||
return defaults;
|
||||
}
|
||||
};
|
||||
|
||||
export const writeAiPanelPrefs = (storageKeyPrefix: string, prefs: AiPanelPrefs) => {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
try {
|
||||
window.localStorage.setItem(`${storageKeyPrefix}_provider`, prefs.provider);
|
||||
window.localStorage.setItem(`${storageKeyPrefix}_model`, prefs.model);
|
||||
window.localStorage.setItem(`${storageKeyPrefix}_max_steps`, String(prefs.maxSteps));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
@@ -26,8 +26,6 @@ export function Breadcrumb({ documents }: BreadcrumbProps) {
|
||||
const showInspector = usePageLayoutStore((state) => state.showInspector);
|
||||
const toggleInspector = usePageLayoutStore((state) => state.toggleInspector);
|
||||
const backendStatus = useBackendHealth();
|
||||
const globalAgentOpen = useAiAgentUiStore((s) => s.globalAgentOpen);
|
||||
const toggleGlobalAgentOpen = useAiAgentUiStore((s) => s.toggleGlobalAgentOpen);
|
||||
const documentAgentAvailable = useAiAgentUiStore((s) => s.documentAgentAvailable);
|
||||
const toggleDocumentAgentOpen = useAiAgentUiStore((s) => s.toggleDocumentAgentOpen);
|
||||
const isStarred = useQuery(
|
||||
@@ -88,28 +86,16 @@ export function Breadcrumb({ documents }: BreadcrumbProps) {
|
||||
type="button"
|
||||
className={cn(
|
||||
"rounded-full px-3 py-1 text-sm transition-colors",
|
||||
globalAgentOpen
|
||||
documentAgentAvailable
|
||||
? "bg-[#2563eb] text-white hover:bg-[#1d4ed8]"
|
||||
: "hover:bg-wolai-bg-hover hover:text-wolai-text-primary",
|
||||
)}
|
||||
onClick={() => toggleGlobalAgentOpen()}
|
||||
title="打开全局 AI"
|
||||
>
|
||||
<Sparkles className="mr-1 inline h-4 w-4" />
|
||||
全局AI
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"hover:bg-wolai-bg-hover hover:text-wolai-text-primary rounded-full px-3 py-1 text-sm transition-colors",
|
||||
!documentAgentAvailable && "cursor-not-allowed opacity-50",
|
||||
)}
|
||||
onClick={() => toggleDocumentAgentOpen()}
|
||||
disabled={!documentAgentAvailable}
|
||||
title={documentAgentAvailable ? "打开页面 AI" : "仅在页面编辑区可用"}
|
||||
>
|
||||
<Sparkles className="mr-1 inline h-4 w-4" />
|
||||
AI
|
||||
页面AI
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -7,19 +7,15 @@ import { useEditorBridgeStore } from "@/store/editor-bridge";
|
||||
import { useAiAgentUiStore } from "@/store/ai-agent-ui";
|
||||
import { useAppPreferencesStore } from "@/store/app-preferences";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { AiBridgePanel } from "@/components/ai-agent/AiBridgePanel";
|
||||
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";
|
||||
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet";
|
||||
import { clamp, MIN_AGENT_STEPS, MAX_AGENT_STEPS } from "@/lib/constants";
|
||||
import { parseSseChunks, readAiPanelPrefs, type AiProvider, writeAiPanelPrefs } from "@/components/ai-agent/panelShared";
|
||||
|
||||
type AgentMessage = { role: "user" | "assistant"; content: string };
|
||||
type AiProvider = "online" | "local" | "ollama" | "codex";
|
||||
type CodexMode = "chat" | "test" | "dev";
|
||||
|
||||
const OLLAMA_QWEN3_30B = "qwen3:30b-a3b-instruct-2507-q4_K_M";
|
||||
@@ -116,41 +112,6 @@ const normalizeSessions = (sessions: ChatSession[]) => {
|
||||
.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);
|
||||
@@ -177,7 +138,6 @@ export function DocumentAiAgentPanel({
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [networkOn, setNetworkOn] = useState(() => !flightMode);
|
||||
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<AiProvider>("online");
|
||||
@@ -212,29 +172,18 @@ export function DocumentAiAgentPanel({
|
||||
}, [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 >= MIN_AGENT_STEPS) {
|
||||
setMaxSteps(clamp(Math.floor(parsed), MIN_AGENT_STEPS, MAX_AGENT_STEPS));
|
||||
}
|
||||
if (p === "local" || p === "online" || p === "ollama" || p === "codex") setAiProvider(p);
|
||||
if (typeof m === "string") setAiModel(m);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
const prefs = readAiPanelPrefs("doc_ai", { provider: "online", model: "", maxSteps: 10 });
|
||||
setMaxSteps(clamp(prefs.maxSteps, MIN_AGENT_STEPS, MAX_AGENT_STEPS));
|
||||
setAiProvider(prefs.provider);
|
||||
setAiModel(prefs.model);
|
||||
}, []);
|
||||
|
||||
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
|
||||
}
|
||||
writeAiPanelPrefs("doc_ai", {
|
||||
provider: aiProvider,
|
||||
model: aiModel,
|
||||
maxSteps: clamp(maxSteps, MIN_AGENT_STEPS, MAX_AGENT_STEPS),
|
||||
});
|
||||
}, [aiModel, aiProvider, maxSteps]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -676,73 +625,89 @@ export function DocumentAiAgentPanel({
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
<SheetContent side="right" showCloseButton={false} className="p-0">
|
||||
<SheetHeader className="border-b">
|
||||
<SheetTitle className="flex items-center justify-between gap-2">
|
||||
<span className="flex items-center gap-2">
|
||||
<SheetContent
|
||||
side="right"
|
||||
showCloseButton={false}
|
||||
className="w-[min(1400px,calc(100vw-24px))] max-w-none border-l-0 bg-transparent p-3 shadow-none sm:max-w-none"
|
||||
>
|
||||
<SheetTitle className="sr-only">页面 AI</SheetTitle>
|
||||
<AiBridgePanel
|
||||
title={pageTitle}
|
||||
subtitle="页面 AI"
|
||||
status={loading ? "运行中" : "待命"}
|
||||
onClose={() => setOpen(false)}
|
||||
scrollBody={false}
|
||||
className="h-[calc(100vh-24px)] min-h-0 rounded-[24px] border-white/10 shadow-none"
|
||||
secondaryActions={
|
||||
<>
|
||||
{page === "chat" ? (
|
||||
<Sparkles className="h-4 w-4" />
|
||||
<Sparkles className="h-4 w-4 text-white/75" />
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
size="icon-sm"
|
||||
onClick={() => setPage("chat")}
|
||||
disabled={loading}
|
||||
title="返回对话"
|
||||
className="rounded-xl border border-white/10 bg-white/[0.03] text-white/80 hover:bg-white/[0.08] hover:text-white"
|
||||
>
|
||||
<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="新建会话">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={startNewSession}
|
||||
disabled={loading}
|
||||
title="新建会话"
|
||||
className="rounded-xl border border-white/10 bg-white/[0.03] text-white/80 hover:bg-white/[0.08] hover:text-white"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
size="icon-sm"
|
||||
onClick={() => setPage("tools")}
|
||||
disabled={loading}
|
||||
title="工具(代替 MCP)"
|
||||
className="rounded-xl border border-white/10 bg-white/[0.03] text-white/80 hover:bg-white/[0.08] hover:text-white"
|
||||
>
|
||||
<Wrench className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
size="icon-sm"
|
||||
onClick={() => setPage("history")}
|
||||
disabled={loading}
|
||||
title="历史"
|
||||
className="rounded-xl border border-white/10 bg-white/[0.03] text-white/80 hover:bg-white/[0.08] hover:text-white"
|
||||
>
|
||||
<History className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
size="icon-sm"
|
||||
onClick={() => setPage("account")}
|
||||
disabled={loading}
|
||||
title="账户/模型"
|
||||
className="rounded-xl border border-white/10 bg-white/[0.03] text-white/80 hover:bg-white/[0.08] hover:text-white"
|
||||
>
|
||||
<User className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
size="icon-sm"
|
||||
onClick={() => setPage("settings")}
|
||||
disabled={loading}
|
||||
title="设置"
|
||||
className="rounded-xl border border-white/10 bg-white/[0.03] text-white/80 hover:bg-white/[0.08] hover:text-white"
|
||||
>
|
||||
<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="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
<div className={page === "chat" ? "" : "hidden"}>
|
||||
<div className="hidden">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -766,17 +731,6 @@ export function DocumentAiAgentPanel({
|
||||
<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">
|
||||
@@ -1446,6 +1400,7 @@ export function DocumentAiAgentPanel({
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</AiBridgePanel>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
|
||||
@@ -3,9 +3,11 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { ChevronLeft, History, Network, Plus, Settings, Settings2, Send, Sparkles, User, Wrench, X, Paperclip } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { AiBridgePanel } from "@/components/ai-agent/AiBridgePanel";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useAppPreferencesStore } from "@/store/app-preferences";
|
||||
import { parseSseChunks, readAiPanelPrefs, type AiProvider, writeAiPanelPrefs } from "@/components/ai-agent/panelShared";
|
||||
|
||||
type AgentAssetItem = {
|
||||
kind: "media" | "local-mindmap" | "test-pdf";
|
||||
@@ -18,7 +20,6 @@ type AgentAssetItem = {
|
||||
};
|
||||
|
||||
type AgentMessage = { role: "user" | "assistant"; content: string };
|
||||
type AiProvider = "online" | "local" | "ollama" | "codex";
|
||||
type CodexMode = "chat" | "test" | "dev";
|
||||
|
||||
const OLLAMA_QWEN3_30B = "qwen3:30b-a3b-instruct-2507-q4_K_M";
|
||||
@@ -183,7 +184,6 @@ export function MindmapAiAgentPanel({
|
||||
const [networkOn, setNetworkOn] = useState(() => !flightMode);
|
||||
const [toolAuto, setToolAuto] = useState(true);
|
||||
// 兼容旧 UI(已隐藏),避免影响已有交互与回滚风险
|
||||
const [toolPickerOpen, setToolPickerOpen] = useState(false);
|
||||
const [selectedTools, setSelectedTools] = useState<ToolName[]>(DEFAULT_TOOLS);
|
||||
const [aiProvider, setAiProvider] = useState<AiProvider>("online");
|
||||
const [aiModel, setAiModel] = useState<string>("");
|
||||
@@ -210,29 +210,18 @@ export function MindmapAiAgentPanel({
|
||||
}, [flightMode]);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const p = (window.localStorage.getItem("mindmap_ai_provider") || "").trim();
|
||||
const m = window.localStorage.getItem("mindmap_ai_model") || "";
|
||||
const stepsRaw = window.localStorage.getItem("mindmap_ai_max_steps") || "";
|
||||
if (p === "local" || p === "online" || p === "ollama" || p === "codex") setAiProvider(p);
|
||||
if (typeof m === "string") setAiModel(m);
|
||||
const parsed = Number(stepsRaw);
|
||||
if (Number.isFinite(parsed) && parsed >= 1) {
|
||||
setMaxSteps(Math.max(1, Math.min(24, Math.floor(parsed))));
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
const prefs = readAiPanelPrefs("mindmap_ai", { provider: "online", model: "", maxSteps: 10 });
|
||||
setAiProvider(prefs.provider);
|
||||
setAiModel(prefs.model);
|
||||
setMaxSteps(Math.max(1, Math.min(24, Math.floor(prefs.maxSteps))));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
window.localStorage.setItem("mindmap_ai_provider", aiProvider);
|
||||
window.localStorage.setItem("mindmap_ai_model", aiModel);
|
||||
window.localStorage.setItem("mindmap_ai_max_steps", String(maxSteps));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
writeAiPanelPrefs("mindmap_ai", {
|
||||
provider: aiProvider,
|
||||
model: aiModel,
|
||||
maxSteps: Math.max(1, Math.min(24, Math.floor(maxSteps))),
|
||||
});
|
||||
}, [aiProvider, aiModel, maxSteps]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -369,38 +358,6 @@ export function MindmapAiAgentPanel({
|
||||
return false;
|
||||
};
|
||||
|
||||
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 selectedNodes = useMemo(() => {
|
||||
const list = Array.isArray(activeNodes) ? activeNodes : [];
|
||||
|
||||
@@ -562,13 +519,6 @@ export function MindmapAiAgentPanel({
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const toggleTool = (tool: ToolName) => {
|
||||
setSelectedTools((prev) => {
|
||||
if (prev.includes(tool)) return prev.filter((t) => t !== tool);
|
||||
return [...prev, tool];
|
||||
});
|
||||
};
|
||||
|
||||
const uploadFiles = async (files: FileList | null) => {
|
||||
if (!files || files.length === 0) return;
|
||||
if (!workspaceId || !documentId) {
|
||||
@@ -979,42 +929,82 @@ export function MindmapAiAgentPanel({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="border-b p-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div 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>
|
||||
)}
|
||||
<span className="text-sm font-medium">{pageTitle}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="ghost" size="icon" onClick={startNewSession} disabled={loading} title="新建会话">
|
||||
<Plus className="h-4 w-4" />
|
||||
<AiBridgePanel
|
||||
title={pageTitle}
|
||||
subtitle="思维导图 AI"
|
||||
status={loading ? "运行中" : "待命"}
|
||||
onClose={onClose}
|
||||
scrollBody={false}
|
||||
className="h-full min-h-0 rounded-[24px] border-white/10 shadow-none"
|
||||
secondaryActions={
|
||||
<>
|
||||
{page === "chat" ? (
|
||||
<Sparkles className="h-4 w-4 text-white/75" />
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => setPage("chat")}
|
||||
disabled={loading}
|
||||
title="返回对话"
|
||||
className="rounded-xl border border-white/10 bg-white/[0.03] text-white/80 hover:bg-white/[0.08] hover:text-white"
|
||||
>
|
||||
<ChevronLeft 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={() => onClose?.()} disabled={loading} title="关闭">
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={startNewSession}
|
||||
disabled={loading}
|
||||
title="新建会话"
|
||||
className="rounded-xl border border-white/10 bg-white/[0.03] text-white/80 hover:bg-white/[0.08] hover:text-white"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => setPage("tools")}
|
||||
disabled={loading}
|
||||
title="工具(代替 MCP)"
|
||||
className="rounded-xl border border-white/10 bg-white/[0.03] text-white/80 hover:bg-white/[0.08] hover:text-white"
|
||||
>
|
||||
<Wrench className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => setPage("history")}
|
||||
disabled={loading}
|
||||
title="历史"
|
||||
className="rounded-xl border border-white/10 bg-white/[0.03] text-white/80 hover:bg-white/[0.08] hover:text-white"
|
||||
>
|
||||
<History className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => setPage("account")}
|
||||
disabled={loading}
|
||||
title="账户/模型"
|
||||
className="rounded-xl border border-white/10 bg-white/[0.03] text-white/80 hover:bg-white/[0.08] hover:text-white"
|
||||
>
|
||||
<User className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => setPage("settings")}
|
||||
disabled={loading}
|
||||
title="设置"
|
||||
className="rounded-xl border border-white/10 bg-white/[0.03] text-white/80 hover:bg-white/[0.08] hover:text-white"
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
{page === "chat" ? (
|
||||
<>
|
||||
@@ -1435,374 +1425,6 @@ export function MindmapAiAgentPanel({
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{false ? (
|
||||
<div className="hidden">
|
||||
<div className="flex items-center justify-between gap-2 pb-3">
|
||||
<div
|
||||
className="text-xs text-gray-500"
|
||||
title={
|
||||
selectedNodes.length
|
||||
? selectedNodes
|
||||
.map((n) => (n.text ? `${n.text}(${n.uid})` : n.uid))
|
||||
.slice(0, 3)
|
||||
.join(",")
|
||||
: ""
|
||||
}
|
||||
>
|
||||
{selectedNodes.length
|
||||
? `选中节点:${selectedNodes[0]?.text || selectedNodes[0]?.uid}${selectedNodes.length > 1 ? `(+${selectedNodes.length - 1})` : ""}`
|
||||
: "未选中节点(将以整图为上下文)"}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
className={`inline-flex items-center gap-1 rounded-md border px-2 py-1 text-xs ${networkOn ? "border-blue-200 bg-blue-50 text-blue-700" : "border-gray-200 text-gray-500"}`}
|
||||
title="联网检索(SearxNG)"
|
||||
onClick={() => setNetworkOn((v) => !v)}
|
||||
>
|
||||
<Network className="h-3 w-3" />
|
||||
联网
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`inline-flex items-center gap-1 rounded-md border px-2 py-1 text-xs ${toolPickerOpen ? "border-gray-300 bg-gray-50 text-gray-700" : "border-gray-200 text-gray-600"}`}
|
||||
title="选择允许使用的工具"
|
||||
onClick={() => setToolPickerOpen((v) => !v)}
|
||||
>
|
||||
<Settings2 className="h-3 w-3" />
|
||||
工具
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{toolPickerOpen && (
|
||||
<div className="mb-3 rounded-md border border-gray-200 bg-white p-2">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div className="text-xs font-medium text-gray-700">工具选择</div>
|
||||
<button
|
||||
type="button"
|
||||
className={`rounded px-2 py-1 text-xs ${toolAuto ? "bg-blue-500 text-white" : "border border-gray-200 text-gray-600"}`}
|
||||
onClick={() => setToolAuto((v) => !v)}
|
||||
title="自动:AI 自行选择;手动:仅允许勾选工具"
|
||||
>
|
||||
{toolAuto ? "自动" : "手动"}
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{DEFAULT_TOOLS.map((t) => (
|
||||
<label key={t} className={`flex cursor-pointer items-center gap-2 text-xs ${toolAuto ? "opacity-50" : ""}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
disabled={toolAuto}
|
||||
checked={selectedTools.includes(t)}
|
||||
onChange={() => toggleTool(t)}
|
||||
/>
|
||||
<span className="text-gray-700">{TOOL_LABEL[t]}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-2 text-[11px] text-gray-400">
|
||||
提示:如果你希望 AI 一定要“写入导图”,请在需求中明确说“请写入并保存”。
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 overflow-y-auto rounded-md border border-gray-200 bg-white p-2">
|
||||
<div className="space-y-2">
|
||||
{messages.map((m, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className={`whitespace-pre-wrap rounded-md px-2 py-2 text-sm ${m.role === "user" ? "bg-gray-50 text-gray-900" : "bg-white text-gray-800"}`}
|
||||
>
|
||||
<div className="mb-1 text-[11px] text-gray-400">{m.role === "user" ? "你" : "AI"}</div>
|
||||
<div>{m.content}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{attachments.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{attachments.map((a) => (
|
||||
<span
|
||||
key={a.id}
|
||||
className="inline-flex items-center gap-1 rounded-full border border-gray-200 bg-gray-50 px-2 py-1 text-[11px] text-gray-700"
|
||||
title={a.fileUrl}
|
||||
>
|
||||
@{a.title}
|
||||
<button
|
||||
type="button"
|
||||
className="text-gray-400 hover:text-gray-700"
|
||||
onClick={() => setAttachments((prev) => prev.filter((x) => x.id !== a.id))}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-2 rounded-md border border-gray-200 bg-white px-2 py-2 text-xs text-gray-700">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="text-gray-500">AI:</div>
|
||||
<label className="inline-flex cursor-pointer items-center gap-1">
|
||||
<input
|
||||
type="radio"
|
||||
name="mindmap-ai-provider"
|
||||
data-testid="mindmap-ai-provider-online"
|
||||
checked={aiProvider === "online"}
|
||||
onChange={() => setAiProvider("online")}
|
||||
/>
|
||||
在线
|
||||
</label>
|
||||
<label className="inline-flex cursor-pointer items-center gap-1">
|
||||
<input
|
||||
type="radio"
|
||||
name="mindmap-ai-provider"
|
||||
data-testid="mindmap-ai-provider-local"
|
||||
checked={aiProvider === "local"}
|
||||
onChange={() => setAiProvider("local")}
|
||||
/>
|
||||
本地
|
||||
</label>
|
||||
<label className="inline-flex cursor-pointer items-center gap-1">
|
||||
<input
|
||||
type="radio"
|
||||
name="mindmap-ai-provider"
|
||||
checked={aiProvider === "ollama"}
|
||||
onChange={() => setAiProvider("ollama")}
|
||||
/>
|
||||
Ollama
|
||||
</label>
|
||||
<label className="inline-flex cursor-pointer items-center gap-1">
|
||||
<input
|
||||
type="radio"
|
||||
name="mindmap-ai-provider"
|
||||
checked={aiProvider === "codex"}
|
||||
onChange={() => setAiProvider("codex")}
|
||||
/>
|
||||
Codex
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="text-gray-500">模型:</div>
|
||||
{aiProvider === "codex" ? (
|
||||
<div className="text-[11px] text-gray-500">
|
||||
消息开头加 <code className="rounded bg-gray-100 px-1 py-0.5">#chat</code> /{" "}
|
||||
<code className="rounded bg-gray-100 px-1 py-0.5">#test</code> /{" "}
|
||||
<code className="rounded bg-gray-100 px-1 py-0.5">#dev</code>(默认 <code className="rounded bg-gray-100 px-1 py-0.5">#chat</code>)。
|
||||
</div>
|
||||
) : aiProvider === "online" ? (
|
||||
<select
|
||||
data-testid="mindmap-ai-model-select"
|
||||
className="rounded border border-gray-200 bg-white px-2 py-1 text-xs outline-none focus:border-blue-300"
|
||||
value={ONLINE_MODELS.includes(aiModel as any) ? aiModel : ""}
|
||||
onChange={(e) => setAiModel(e.target.value)}
|
||||
>
|
||||
{ONLINE_MODELS.map((m) => (
|
||||
<option key={m || "__default__"} value={m}>
|
||||
{m ? m : "默认(ai.md)"}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : aiProvider === "ollama" ? (
|
||||
<select
|
||||
className="rounded border border-gray-200 bg-white px-2 py-1 text-xs outline-none focus:border-blue-300"
|
||||
value={aiModel === "" || aiModel === OLLAMA_QWEN3_30B ? aiModel : ""}
|
||||
onChange={(e) => setAiModel(e.target.value)}
|
||||
>
|
||||
<option value="">默认:{OLLAMA_QWEN3_30B}</option>
|
||||
<option value={OLLAMA_QWEN3_30B}>{OLLAMA_QWEN3_30B}</option>
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
data-testid="mindmap-ai-model-input"
|
||||
className="w-[220px] rounded border border-gray-200 bg-white px-2 py-1 text-xs outline-none focus:border-blue-300"
|
||||
value={aiModel}
|
||||
onChange={(e) => setAiModel(e.target.value)}
|
||||
placeholder="默认(ai.local.md/环境变量)"
|
||||
list="mindmap-local-model-suggestions-bottom"
|
||||
/>
|
||||
)}
|
||||
<datalist id="mindmap-local-model-suggestions-bottom">
|
||||
<option value={OLLAMA_QWEN3_30B} />
|
||||
</datalist>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="text-gray-500">最大步数:</div>
|
||||
<input
|
||||
className="w-[72px] rounded border border-gray-200 bg-white px-2 py-1 text-xs outline-none focus:border-blue-300"
|
||||
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))));
|
||||
}}
|
||||
title="工具调用/推理最大步数(上限 24)"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{aiProvider === "local" ? (
|
||||
<div className="mt-1 text-[11px] text-gray-400">
|
||||
本地 AI 需配置 `LOCAL_AI_BASE_URL`/`LOCAL_AI_MODEL` 或 `ai.local.md` / `ai-local.md`
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="relative mt-2">
|
||||
{mentionOpen && filteredAssets.length > 0 && (
|
||||
<div className="absolute bottom-[calc(100%+8px)] left-0 right-0 z-30 max-h-56 overflow-auto rounded-md border border-gray-200 bg-white shadow">
|
||||
{filteredAssets.map((a) => (
|
||||
<button
|
||||
key={a.id}
|
||||
type="button"
|
||||
className="flex w-full items-center justify-between gap-2 px-3 py-2 text-left text-sm hover:bg-gray-50"
|
||||
onClick={() => insertMention(a)}
|
||||
>
|
||||
<span className="truncate text-gray-800">{a.title}</span>
|
||||
<span className="shrink-0 text-[11px] text-gray-400">{a.kind}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
data-testid="mindmap-ai-input"
|
||||
className="w-full resize-none rounded-md border border-gray-200 bg-white p-2 text-sm outline-none focus:border-blue-300"
|
||||
rows={4}
|
||||
value={input}
|
||||
placeholder="输入你的需求。使用 @ 选择文件(PDF/附件/本地导图),例如:总结 @卤化反应原理_1-9.pdf 并写入导图(章->节->要点)。"
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
setInput(v);
|
||||
updateMentionState(v, e.target.selectionStart ?? v.length);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
// 焦点在 AI 输入框时,不应触发导图 Enter/Tab 快捷键
|
||||
e.stopPropagation();
|
||||
if (e.key === "Escape") {
|
||||
setMentionOpen(false);
|
||||
setToolPickerOpen(false);
|
||||
return;
|
||||
}
|
||||
if (e.key === "Enter") {
|
||||
// Enter 发送;Shift+Enter 换行
|
||||
const isComposing = Boolean((e.nativeEvent as unknown as { isComposing?: boolean })?.isComposing);
|
||||
if (!e.shiftKey && !isComposing) {
|
||||
e.preventDefault();
|
||||
if (!loading) void send();
|
||||
}
|
||||
return;
|
||||
}
|
||||
}}
|
||||
onClick={(e) => {
|
||||
const el = e.currentTarget;
|
||||
updateMentionState(el.value, el.selectionStart ?? el.value.length);
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="mt-2 flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1 rounded-md border border-gray-200 px-2 py-1 text-xs text-gray-600 hover:bg-gray-50"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
title="上传文件到当前页面附件"
|
||||
disabled={loading}
|
||||
>
|
||||
<Paperclip className="h-3 w-3" />
|
||||
上传
|
||||
</button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
void uploadFiles(e.target.files);
|
||||
}}
|
||||
accept="*/*"
|
||||
/>
|
||||
<div className="text-[11px] text-gray-400">Enter 发送 · Shift+Enter 换行</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-2 rounded-md border border-gray-200 px-3 py-2 text-sm text-gray-600 hover:bg-gray-50 disabled:opacity-50"
|
||||
disabled={!loading}
|
||||
onClick={() => abortRef.current?.abort()}
|
||||
title={aiProvider === "codex" ? "暂停本次执行(类似 ESC)" : "停止本次执行"}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
{aiProvider === "codex" ? "暂停" : "停止"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-2 rounded-md bg-blue-600 px-3 py-2 text-sm text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
disabled={loading || !input.trim()}
|
||||
onClick={() => void send()}
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
{loading ? "执行中..." : "发送"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<details className="mt-2 rounded-md border border-gray-200 bg-white p-2 text-xs text-gray-600">
|
||||
<summary className="cursor-pointer select-none">工具日志(可折叠)</summary>
|
||||
<div className="mt-2 space-y-2">
|
||||
{toolLogs.length === 0 ? <div className="text-gray-400">暂无工具日志</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 === "info") {
|
||||
return (
|
||||
<div key={idx} className="rounded border bg-gray-50 p-2 text-gray-600">
|
||||
{l.message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (l.type === "tool_call") {
|
||||
return (
|
||||
<div key={idx} className="rounded border p-2">
|
||||
<div className="text-[11px] text-gray-400">tool_call · {l.id}</div>
|
||||
<div className="font-medium text-gray-800">{l.tool}</div>
|
||||
<pre className="mt-1 max-h-40 overflow-auto whitespace-pre-wrap">{JSON.stringify(l.args, null, 2)}</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div key={idx} className="rounded border p-2">
|
||||
<div className="text-[11px] text-gray-400">
|
||||
tool_result · {l.id} · {l.ok ? "ok" : "fail"} · {l.ms}ms
|
||||
</div>
|
||||
<div className="font-medium text-gray-800">{l.tool}</div>
|
||||
<pre className="mt-1 max-h-40 overflow-auto whitespace-pre-wrap">{JSON.stringify(l.result, null, 2)}</pre>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</details>
|
||||
|
||||
{debug ? (
|
||||
<details className="mt-2 rounded-md border border-gray-200 bg-white p-2 text-xs text-gray-600">
|
||||
<summary className="cursor-pointer select-none">调试信息(SSE 事件)</summary>
|
||||
<pre className="mt-2 max-h-56 overflow-auto whitespace-pre-wrap">{debug}</pre>
|
||||
</details>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</AiBridgePanel>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Bot, Settings, Wrench, X } from "lucide-react";
|
||||
import { Bot, Settings, Wrench } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { AiBridgePanel } from "@/components/ai-agent/AiBridgePanel";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { clamp } from "@/lib/constants";
|
||||
import { useAppPreferencesStore } from "@/store/app-preferences";
|
||||
import { parseSseChunks, readAiPanelPrefs, type AiProvider, writeAiPanelPrefs } from "@/components/ai-agent/panelShared";
|
||||
|
||||
type AgentMessage = { role: "user" | "assistant"; content: string };
|
||||
type AiProvider = "online" | "local" | "ollama" | "codex";
|
||||
|
||||
type ToolLog =
|
||||
| { type: "tool_call"; id: string; tool: string; args: Record<string, unknown> }
|
||||
@@ -44,37 +45,6 @@ const OLLAMA_QWEN3_30B = "qwen3:30b-a3b-instruct-2507-q4_K_M";
|
||||
const isRecord = (v: unknown): v is Record<string, unknown> =>
|
||||
typeof v === "object" && v !== null && !Array.isArray(v);
|
||||
|
||||
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 blobToDataUrl = (blob: Blob) =>
|
||||
new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
@@ -133,27 +103,18 @@ export function OnlyOfficeAiAgentPanel({
|
||||
}, [flightMode]);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const stepsRaw = window.localStorage.getItem("onlyoffice_ai_max_steps") || "";
|
||||
const providerRaw = (window.localStorage.getItem("onlyoffice_ai_provider") || "").trim();
|
||||
const modelRaw = window.localStorage.getItem("onlyoffice_ai_model") || "";
|
||||
const parsed = Number(stepsRaw);
|
||||
if (providerRaw === "online" || providerRaw === "local" || providerRaw === "ollama" || providerRaw === "codex") setAiProvider(providerRaw);
|
||||
if (typeof modelRaw === "string") setAiModel(modelRaw);
|
||||
if (Number.isFinite(parsed) && parsed >= 1) setMaxSteps(clamp(Math.floor(parsed), 1, 24));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
const prefs = readAiPanelPrefs("onlyoffice_ai", { provider: "online", model: "", maxSteps: 10 });
|
||||
setAiProvider(prefs.provider);
|
||||
setAiModel(prefs.model);
|
||||
setMaxSteps(clamp(Math.floor(prefs.maxSteps), 1, 24));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
window.localStorage.setItem("onlyoffice_ai_provider", aiProvider);
|
||||
window.localStorage.setItem("onlyoffice_ai_model", aiModel);
|
||||
window.localStorage.setItem("onlyoffice_ai_max_steps", String(maxSteps));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
writeAiPanelPrefs("onlyoffice_ai", {
|
||||
provider: aiProvider,
|
||||
model: aiModel,
|
||||
maxSteps: clamp(Math.floor(maxSteps), 1, 24),
|
||||
});
|
||||
}, [aiProvider, aiModel, maxSteps]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -458,41 +419,43 @@ export function OnlyOfficeAiAgentPanel({
|
||||
</div>
|
||||
|
||||
{open ? (
|
||||
<div className="fixed right-0 top-0 z-[70] h-screen w-[420px] border-l bg-background shadow-xl">
|
||||
<div className="flex items-center justify-between border-b px-3 py-2">
|
||||
<div className="text-sm font-semibold">OnlyOffice AI</div>
|
||||
<Button variant="ghost" size="icon" onClick={() => setOpen(false)}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-1 border-b px-2 py-2">
|
||||
<Button
|
||||
variant={page === "chat" ? "default" : "secondary"}
|
||||
size="sm"
|
||||
onClick={() => setPage("chat")}
|
||||
>
|
||||
<Bot className="mr-2 h-4 w-4" />
|
||||
对话
|
||||
</Button>
|
||||
<Button
|
||||
variant={page === "tools" ? "default" : "secondary"}
|
||||
size="sm"
|
||||
onClick={() => setPage("tools")}
|
||||
>
|
||||
<Wrench className="mr-2 h-4 w-4" />
|
||||
工具
|
||||
</Button>
|
||||
<Button
|
||||
variant={page === "settings" ? "default" : "secondary"}
|
||||
size="sm"
|
||||
onClick={() => setPage("settings")}
|
||||
>
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
设置
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="fixed inset-y-0 right-0 z-[70] w-[min(960px,calc(100vw-24px))] p-3">
|
||||
<AiBridgePanel
|
||||
title={page === "chat" ? "OnlyOffice AI" : page === "tools" ? "OnlyOffice 工具" : "OnlyOffice 设置"}
|
||||
subtitle="OnlyOffice AI"
|
||||
status={loading ? "运行中" : "待命"}
|
||||
onClose={() => setOpen(false)}
|
||||
scrollBody={false}
|
||||
className="h-[calc(100vh-24px)] min-h-0 rounded-[24px] border-white/10 shadow-none"
|
||||
secondaryActions={
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => setPage("chat")}
|
||||
className="rounded-xl border border-white/10 bg-white/[0.03] text-white/80 hover:bg-white/[0.08] hover:text-white"
|
||||
>
|
||||
<Bot className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => setPage("tools")}
|
||||
className="rounded-xl border border-white/10 bg-white/[0.03] text-white/80 hover:bg-white/[0.08] hover:text-white"
|
||||
>
|
||||
<Wrench className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => setPage("settings")}
|
||||
className="rounded-xl border border-white/10 bg-white/[0.03] text-white/80 hover:bg-white/[0.08] hover:text-white"
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{page === "tools" ? (
|
||||
<div className="space-y-3 p-3 text-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -687,6 +650,7 @@ export function OnlyOfficeAiAgentPanel({
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</AiBridgePanel>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user