feat(kernel): complete tree-first graph tasks 074-080
This commit is contained in:
@@ -0,0 +1,677 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useMemo, useRef, useState } from "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 { useOnlyOfficeAiBridgeStore } from "@/store/onlyoffice-ai-bridge";
|
||||
import { parseSseChunks, readAiPanelPrefs, type AiProvider, writeAiPanelPrefs } from "@/components/ai-agent/panelShared";
|
||||
|
||||
type AgentMessage = { role: "user" | "assistant"; content: string };
|
||||
|
||||
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: "info"; message: string }
|
||||
| { type: "error"; message: string };
|
||||
|
||||
type PanelPage = "chat" | "tools" | "settings";
|
||||
|
||||
type AgentAttachment = { id: string; title: string; fileUrl: string; mimeType?: string | null };
|
||||
|
||||
const CHANNEL = "mnote_onlyoffice_agent_tools_v1";
|
||||
|
||||
const DEFAULT_MESSAGES: AgentMessage[] = [
|
||||
{
|
||||
role: "assistant",
|
||||
content:
|
||||
"你好,我是 OnlyOffice AI Agent。\n- 先选中一段文字,再说“改写/补全/翻译/润色/删除/插入”\n- 我会通过 oo_* 工具读取/替换选区\n- 需要引用资料时可联网检索或用 LightRAG/文档检索",
|
||||
},
|
||||
];
|
||||
|
||||
const ONLINE_MODELS = [
|
||||
"",
|
||||
"gemini-2.5-flash",
|
||||
"gemini-2.5-pro",
|
||||
"gemini-3-pro-preview",
|
||||
"gemini-3-flash-preview",
|
||||
] as const;
|
||||
|
||||
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 blobToDataUrl = (blob: Blob) =>
|
||||
new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(String(reader.result ?? ""));
|
||||
reader.onerror = () => reject(new Error("读取图片失败(FileReader)"));
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
|
||||
export function OnlyOfficeAiAgentPanelRuntime({
|
||||
openFile,
|
||||
initialOpen = false,
|
||||
}: {
|
||||
openFile: { id: string; title: string; fileUrl: string; mimeType?: string | null };
|
||||
initialOpen?: boolean;
|
||||
}) {
|
||||
const flightMode = useAppPreferencesStore((s) => s.flightMode);
|
||||
const bridgePluginReady = useOnlyOfficeAiBridgeStore((s) => s.pluginReady);
|
||||
const bridgeTargetOrigin = useOnlyOfficeAiBridgeStore((s) => s.targetOrigin);
|
||||
const bridgeTargetWindow = useOnlyOfficeAiBridgeStore((s) => s.targetWindow);
|
||||
const [open, setOpen] = useState(initialOpen);
|
||||
const [page, setPage] = useState<PanelPage>("chat");
|
||||
|
||||
const [messages, setMessages] = useState<AgentMessage[]>(() => DEFAULT_MESSAGES);
|
||||
const [input, setInput] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [toolLogs, setToolLogs] = useState<ToolLog[]>([]);
|
||||
|
||||
const [networkOn, setNetworkOn] = useState(() => !flightMode);
|
||||
const [toolAuto, setToolAuto] = useState(true);
|
||||
const [aiProvider, setAiProvider] = useState<AiProvider>("online");
|
||||
const [aiModel, setAiModel] = useState<string>("");
|
||||
const [maxSteps, setMaxSteps] = useState<number>(10);
|
||||
|
||||
// Codex:每个面板对话维护一个 session,支持连续对话与“暂停(类似 ESC)”
|
||||
const [codexSessionId, setCodexSessionId] = useState<string | null>(null);
|
||||
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const pluginTargetRef = useRef<{ win: Window | null; origin: string }>({ win: null, origin: "*" });
|
||||
const pendingPluginCallsRef = useRef<
|
||||
Map<string, { resolve: (v: unknown) => void; reject: (e: Error) => void; timeoutId: number }>
|
||||
>(new Map());
|
||||
|
||||
const attachments = useMemo<AgentAttachment[]>(
|
||||
() => [
|
||||
{
|
||||
id: openFile.id,
|
||||
title: openFile.title,
|
||||
fileUrl: openFile.fileUrl,
|
||||
mimeType: openFile.mimeType ?? null,
|
||||
},
|
||||
],
|
||||
[openFile.fileUrl, openFile.id, openFile.mimeType, openFile.title],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (flightMode) {
|
||||
setNetworkOn(false);
|
||||
}
|
||||
}, [flightMode]);
|
||||
|
||||
useEffect(() => {
|
||||
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(() => {
|
||||
writeAiPanelPrefs("onlyoffice_ai", {
|
||||
provider: aiProvider,
|
||||
model: aiModel,
|
||||
maxSteps: clamp(Math.floor(maxSteps), 1, 24),
|
||||
});
|
||||
}, [aiProvider, aiModel, maxSteps]);
|
||||
|
||||
useEffect(() => {
|
||||
pluginTargetRef.current = {
|
||||
win: bridgeTargetWindow,
|
||||
origin: bridgeTargetOrigin || "*",
|
||||
};
|
||||
}, [bridgeTargetOrigin, bridgeTargetWindow]);
|
||||
|
||||
useEffect(() => {
|
||||
const onMessage = (ev: MessageEvent) => {
|
||||
const data = ev.data as unknown;
|
||||
if (!isRecord(data)) return;
|
||||
if (data.channel !== CHANNEL) return;
|
||||
|
||||
const type = String(data.type ?? "").trim();
|
||||
if (type === "ready") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "result") {
|
||||
const callId = String(data.callId ?? "").trim();
|
||||
if (!callId) return;
|
||||
const pending = pendingPluginCallsRef.current.get(callId);
|
||||
if (!pending) return;
|
||||
pendingPluginCallsRef.current.delete(callId);
|
||||
window.clearTimeout(pending.timeoutId);
|
||||
|
||||
const ok = Boolean(data.ok);
|
||||
if (ok) {
|
||||
pending.resolve("result" in data ? (data as Record<string, unknown>).result : null);
|
||||
} else {
|
||||
pending.reject(new Error(String((data as Record<string, unknown>).error ?? "插件执行失败")));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("message", onMessage);
|
||||
return () => window.removeEventListener("message", onMessage);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const pendingPluginCalls = pendingPluginCallsRef.current;
|
||||
return () => {
|
||||
abortRef.current?.abort();
|
||||
abortRef.current = null;
|
||||
pendingPluginCalls.forEach(({ reject, timeoutId }) => {
|
||||
window.clearTimeout(timeoutId);
|
||||
reject(new Error("OnlyOffice AI 面板已卸载"));
|
||||
});
|
||||
pendingPluginCalls.clear();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const callPlugin = async (callId: string, tool: string, args: Record<string, unknown>) => {
|
||||
const target = pluginTargetRef.current;
|
||||
if (!target.win) throw new Error("插件未就绪(未收到 ready),请稍等或刷新文档");
|
||||
|
||||
const payload = { channel: CHANNEL, type: "call", callId, tool, args };
|
||||
const result = await new Promise<unknown>((resolve, reject) => {
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
pendingPluginCallsRef.current.delete(callId);
|
||||
reject(new Error("插件调用超时"));
|
||||
}, 60_000);
|
||||
|
||||
pendingPluginCallsRef.current.set(callId, { resolve, reject, timeoutId });
|
||||
try {
|
||||
target.win!.postMessage(payload, target.origin || "*");
|
||||
} catch (e) {
|
||||
window.clearTimeout(timeoutId);
|
||||
pendingPluginCallsRef.current.delete(callId);
|
||||
reject(e instanceof Error ? e : new Error(String(e)));
|
||||
}
|
||||
});
|
||||
return result;
|
||||
};
|
||||
|
||||
const postClientToolResult = async ({
|
||||
requestId,
|
||||
callId,
|
||||
ok,
|
||||
result,
|
||||
error,
|
||||
}: {
|
||||
requestId: string;
|
||||
callId: string;
|
||||
ok: boolean;
|
||||
result?: unknown;
|
||||
error?: string;
|
||||
}) => {
|
||||
await fetch("/api/ai-agent/client-tool-result", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ requestId, callId, ok, result, error }),
|
||||
});
|
||||
};
|
||||
|
||||
const resolveImageRefToDataUrl = async (imageRef: string) => {
|
||||
const s = String(imageRef ?? "").trim();
|
||||
if (!s) throw new Error("缺少 imageRef");
|
||||
|
||||
// 1) 优先当作附件 id
|
||||
const match = attachments.find((a) => a.id === s) ?? null;
|
||||
const url = match ? match.fileUrl : s;
|
||||
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error(`图片下载失败:HTTP ${res.status}`);
|
||||
const blob = await res.blob();
|
||||
return await blobToDataUrl(blob);
|
||||
};
|
||||
|
||||
const handleClientToolCall = async (payloadText: string) => {
|
||||
let data: unknown = null;
|
||||
try {
|
||||
data = JSON.parse(payloadText || "null");
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const obj = isRecord(data) ? data : ({} as Record<string, unknown>);
|
||||
const requestId = String(obj.requestId ?? "").trim();
|
||||
const callId = String(obj.callId ?? "").trim();
|
||||
const tool = String(obj.tool ?? "").trim();
|
||||
const args = isRecord(obj.args) ? (obj.args as Record<string, unknown>) : {};
|
||||
if (!requestId || !callId || !tool) return;
|
||||
|
||||
try {
|
||||
let result: unknown = null;
|
||||
if (tool === "oo_insert_image") {
|
||||
const imageRef = String(args.imageRef ?? "").trim();
|
||||
const src = await resolveImageRefToDataUrl(imageRef);
|
||||
const width = Number(args.width ?? 0);
|
||||
const height = Number(args.height ?? 0);
|
||||
result = await callPlugin(callId, tool, { ...args, src, width, height });
|
||||
} else {
|
||||
result = await callPlugin(callId, tool, args);
|
||||
}
|
||||
await postClientToolResult({ requestId, callId, ok: true, result });
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
await postClientToolResult({ requestId, callId, ok: false, error: msg });
|
||||
}
|
||||
};
|
||||
|
||||
const stop = () => {
|
||||
abortRef.current?.abort();
|
||||
abortRef.current = null;
|
||||
setLoading(false);
|
||||
if (aiProvider === "codex") {
|
||||
setToolLogs((prev) => [...prev, { type: "info", message: "已暂停(可继续对话)" }]);
|
||||
}
|
||||
};
|
||||
|
||||
const send = async () => {
|
||||
const text = input.trim();
|
||||
if (!text) return;
|
||||
if (loading) return;
|
||||
|
||||
const codexSessionIdForRequest =
|
||||
aiProvider === "codex" ? String(codexSessionId ?? "").trim() || null : null;
|
||||
|
||||
const nextMessages: AgentMessage[] = [...messages, { role: "user", content: text }];
|
||||
setMessages(nextMessages);
|
||||
setInput("");
|
||||
setLoading(true);
|
||||
setToolLogs([]);
|
||||
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
|
||||
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: "onlyoffice",
|
||||
messages: nextMessages.slice(-20),
|
||||
attachments,
|
||||
toolChoice: {
|
||||
mode: toolAuto ? "auto" : "manual",
|
||||
toolSets: [
|
||||
"toolset.readonly",
|
||||
"toolset.rag_read",
|
||||
"toolset.media_read",
|
||||
"toolset.docs_read",
|
||||
"toolset.onlyoffice_editor",
|
||||
],
|
||||
},
|
||||
options: {
|
||||
searxng: networkOn,
|
||||
ai: {
|
||||
provider: aiProvider,
|
||||
...(aiProvider === "codex" && codexSessionIdForRequest ? { sessionId: codexSessionIdForRequest } : {}),
|
||||
...(aiProvider !== "codex" && String(aiModel || "").trim() ? { model: String(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 ?? "") : "";
|
||||
throw new Error(err || `HTTP ${res.status}`);
|
||||
}
|
||||
|
||||
await parseSseChunks(res, (event, dataText) => {
|
||||
if (event === "codex_session") {
|
||||
try {
|
||||
const d = JSON.parse(dataText || "null") as unknown;
|
||||
const obj = isRecord(d) ? d : ({} as Record<string, unknown>);
|
||||
const sid = String(obj.sessionId ?? "").trim();
|
||||
if (sid) {
|
||||
setCodexSessionId(sid);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (event === "assistant_message") {
|
||||
try {
|
||||
const d = JSON.parse(dataText || "null") as unknown;
|
||||
const t = isRecord(d) && "text" in d ? String(d.text ?? "") : "";
|
||||
if (t) setMessages((prev) => [...prev, { role: "assistant", content: t }]);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (event === "client_tool_call") {
|
||||
void handleClientToolCall(dataText);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event === "tool_call") {
|
||||
try {
|
||||
const d = JSON.parse(dataText || "null") as unknown;
|
||||
const obj = isRecord(d) ? d : ({} as Record<string, unknown>);
|
||||
setToolLogs((prev) => [
|
||||
...prev,
|
||||
{
|
||||
type: "tool_call",
|
||||
id: String(obj.id ?? ""),
|
||||
tool: String(obj.tool ?? ""),
|
||||
args: (isRecord(obj.args) ? obj.args : {}) as Record<string, unknown>,
|
||||
},
|
||||
]);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (event === "tool_result") {
|
||||
try {
|
||||
const d = JSON.parse(dataText || "null") as unknown;
|
||||
const obj = isRecord(d) ? d : ({} as Record<string, unknown>);
|
||||
setToolLogs((prev) => [
|
||||
...prev,
|
||||
{
|
||||
type: "tool_result",
|
||||
id: String(obj.id ?? ""),
|
||||
tool: String(obj.tool ?? ""),
|
||||
ok: Boolean(obj.ok),
|
||||
ms: Number(obj.ms ?? 0),
|
||||
result: "result" in obj ? obj.result : null,
|
||||
},
|
||||
]);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (event === "error") {
|
||||
if (controller.signal.aborted && aiProvider === "codex") return;
|
||||
try {
|
||||
const d = JSON.parse(dataText || "null") as unknown;
|
||||
const msg = isRecord(d) && "message" in d ? String(d.message ?? "") : "";
|
||||
setToolLogs((prev) => [...prev, { type: "error", message: msg || "未知错误" }]);
|
||||
} catch {
|
||||
setToolLogs((prev) => [...prev, { type: "error", message: "未知错误" }]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
if (controller.signal.aborted && aiProvider === "codex") return;
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
setToolLogs((prev) => [...prev, { type: "error", message: msg }]);
|
||||
} finally {
|
||||
abortRef.current = null;
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const canSend = useMemo(() => input.trim().length > 0 && !loading, [input, loading]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="fixed bottom-4 right-4 z-[60]">
|
||||
<Button
|
||||
className="shadow"
|
||||
onClick={() => {
|
||||
setOpen((v) => !v);
|
||||
if (!open) setPage("chat");
|
||||
}}
|
||||
>
|
||||
<Bot className="mr-2 h-4 w-4" />
|
||||
AI
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{open ? (
|
||||
<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">
|
||||
<div className="font-medium">联网检索</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={networkOn}
|
||||
onChange={(e) => setNetworkOn(e.target.checked)}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="font-medium">自动工具</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={toolAuto}
|
||||
onChange={(e) => setToolAuto(e.target.checked)}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
<div className="rounded border p-2 text-xs text-muted-foreground">
|
||||
<div>插件状态:{bridgePluginReady ? "已连接" : "未连接(等待 ready)"}</div>
|
||||
<div>说明:oo_* 工具依赖该插件执行“选区读写”。</div>
|
||||
</div>
|
||||
|
||||
<details open className="rounded border p-2">
|
||||
<summary className="cursor-pointer select-none text-sm font-medium">工具日志(可折叠)</summary>
|
||||
<div className="mt-2 space-y-2">
|
||||
{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 === "info") {
|
||||
return (
|
||||
<div key={idx} className="rounded border bg-muted p-2 text-muted-foreground">
|
||||
{l.message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (l.type === "tool_call") {
|
||||
return (
|
||||
<div key={idx} className="rounded border p-2">
|
||||
<div className="text-xs text-muted-foreground">tool_call · {l.id}</div>
|
||||
<div className="font-medium">{l.tool}</div>
|
||||
<pre className="mt-1 whitespace-pre-wrap text-xs">{JSON.stringify(l.args, null, 2)}</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div key={idx} className="rounded border p-2">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
tool_result · {l.id} · {l.ok ? "ok" : "fail"} · {l.ms}ms
|
||||
</div>
|
||||
<div className="font-medium">{l.tool}</div>
|
||||
<pre className="mt-1 whitespace-pre-wrap text-xs">{JSON.stringify(l.result, null, 2)}</pre>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{page === "settings" ? (
|
||||
<div className="space-y-3 p-3 text-sm">
|
||||
<label className="flex items-center justify-between gap-2">
|
||||
<span className="font-medium">最大步数</span>
|
||||
<input
|
||||
className="w-[96px] 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(clamp(Math.floor(v), 1, 24));
|
||||
}}
|
||||
disabled={loading}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center justify-between gap-2">
|
||||
<span className="font-medium">AI 提供方</span>
|
||||
<select
|
||||
className="rounded border px-2 py-1 text-xs"
|
||||
value={aiProvider}
|
||||
onChange={(e) => {
|
||||
const v = String(e.target.value || "").trim();
|
||||
if (v === "online" || v === "local" || v === "ollama" || v === "codex") setAiProvider(v);
|
||||
else setAiProvider("online");
|
||||
}}
|
||||
disabled={loading}
|
||||
>
|
||||
<option value="online">在线</option>
|
||||
<option value="local">本地</option>
|
||||
<option value="ollama">Ollama</option>
|
||||
<option value="codex">Codex</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center justify-between gap-2">
|
||||
<span className="font-medium">模型</span>
|
||||
{aiProvider === "codex" ? (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
消息开头加 <code className="rounded bg-muted px-1 py-0.5">#chat</code> /{" "}
|
||||
<code className="rounded bg-muted px-1 py-0.5">#test</code> /{" "}
|
||||
<code className="rounded bg-muted px-1 py-0.5">#dev</code>(默认 <code className="rounded bg-muted px-1 py-0.5">#chat</code>)。
|
||||
</div>
|
||||
) : aiProvider === "online" ? (
|
||||
<select
|
||||
className="w-[220px] rounded border px-2 py-1 text-xs"
|
||||
value={ONLINE_MODELS.includes(aiModel as any) ? aiModel : ""}
|
||||
onChange={(e) => setAiModel(e.target.value)}
|
||||
disabled={loading}
|
||||
>
|
||||
{ONLINE_MODELS.map((m) => (
|
||||
<option key={m} value={m}>
|
||||
{m || "默认"}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : aiProvider === "ollama" ? (
|
||||
<select
|
||||
className="w-[320px] rounded border px-2 py-1 text-xs"
|
||||
value={aiModel === "" || aiModel === OLLAMA_QWEN3_30B ? aiModel : ""}
|
||||
onChange={(e) => setAiModel(e.target.value)}
|
||||
disabled={loading}
|
||||
>
|
||||
<option value="">默认:{OLLAMA_QWEN3_30B}</option>
|
||||
<option value={OLLAMA_QWEN3_30B}>{OLLAMA_QWEN3_30B}</option>
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
className="w-[320px] rounded border px-2 py-1 text-xs"
|
||||
value={aiModel}
|
||||
onChange={(e) => setAiModel(e.target.value)}
|
||||
placeholder="默认(ai.local.md/环境变量)"
|
||||
disabled={loading}
|
||||
list="oo-local-model-suggestions"
|
||||
/>
|
||||
)}
|
||||
</label>
|
||||
<datalist id="oo-local-model-suggestions">
|
||||
<option value={OLLAMA_QWEN3_30B} />
|
||||
</datalist>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{page === "chat" ? (
|
||||
<div className="flex h-[calc(100vh-96px)] flex-col">
|
||||
<ScrollArea className="flex-1">
|
||||
<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 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"
|
||||
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()}>
|
||||
发送
|
||||
</Button>
|
||||
<Button variant="secondary" disabled={!loading} onClick={stop}>
|
||||
{aiProvider === "codex" ? "暂停" : "停止"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</AiBridgePanel>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export { OnlyOfficeAiAgentPanelRuntime as OnlyOfficeAiAgentPanel };
|
||||
@@ -1,658 +1,70 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Bot, Settings, Wrench } from "lucide-react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Bot } 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";
|
||||
import { useOnlyOfficeAiBridgeStore } from "@/store/onlyoffice-ai-bridge";
|
||||
|
||||
type AgentMessage = { role: "user" | "assistant"; content: string };
|
||||
|
||||
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: "info"; message: string }
|
||||
| { type: "error"; message: string };
|
||||
|
||||
type PanelPage = "chat" | "tools" | "settings";
|
||||
|
||||
type AgentAttachment = { id: string; title: string; fileUrl: string; mimeType?: string | null };
|
||||
type OnlyOfficeAiAgentPanelProps = {
|
||||
openFile: { id: string; title: string; fileUrl: string; mimeType?: string | null };
|
||||
};
|
||||
|
||||
const CHANNEL = "mnote_onlyoffice_agent_tools_v1";
|
||||
|
||||
const DEFAULT_MESSAGES: AgentMessage[] = [
|
||||
const OnlyOfficeAiAgentPanelRuntime = dynamic<OnlyOfficeAiAgentPanelProps & { initialOpen?: boolean }>(
|
||||
() => import("./OnlyOfficeAiAgentPanel.runtime").then((mod) => mod.OnlyOfficeAiAgentPanelRuntime),
|
||||
{
|
||||
role: "assistant",
|
||||
content:
|
||||
"你好,我是 OnlyOffice AI Agent。\n- 先选中一段文字,再说“改写/补全/翻译/润色/删除/插入”\n- 我会通过 oo_* 工具读取/替换选区\n- 需要引用资料时可联网检索或用 LightRAG/文档检索",
|
||||
ssr: false,
|
||||
loading: () => null,
|
||||
},
|
||||
];
|
||||
);
|
||||
|
||||
const ONLINE_MODELS = [
|
||||
"",
|
||||
"gemini-2.5-flash",
|
||||
"gemini-2.5-pro",
|
||||
"gemini-3-pro-preview",
|
||||
"gemini-3-flash-preview",
|
||||
] as const;
|
||||
|
||||
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 blobToDataUrl = (blob: Blob) =>
|
||||
new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(String(reader.result ?? ""));
|
||||
reader.onerror = () => reject(new Error("读取图片失败(FileReader)"));
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
|
||||
export function OnlyOfficeAiAgentPanel({
|
||||
openFile,
|
||||
}: {
|
||||
openFile: { id: string; title: string; fileUrl: string; mimeType?: string | null };
|
||||
}) {
|
||||
const flightMode = useAppPreferencesStore((s) => s.flightMode);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [page, setPage] = useState<PanelPage>("chat");
|
||||
|
||||
const [messages, setMessages] = useState<AgentMessage[]>(() => DEFAULT_MESSAGES);
|
||||
const [input, setInput] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [toolLogs, setToolLogs] = useState<ToolLog[]>([]);
|
||||
|
||||
const [networkOn, setNetworkOn] = useState(() => !flightMode);
|
||||
const [toolAuto, setToolAuto] = useState(true);
|
||||
const [aiProvider, setAiProvider] = useState<AiProvider>("online");
|
||||
const [aiModel, setAiModel] = useState<string>("");
|
||||
const [maxSteps, setMaxSteps] = useState<number>(10);
|
||||
|
||||
// Codex:每个面板对话维护一个 session,支持连续对话与“暂停(类似 ESC)”
|
||||
const [codexSessionId, setCodexSessionId] = useState<string | null>(null);
|
||||
|
||||
const [pluginReady, setPluginReady] = useState(false);
|
||||
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const pluginTargetRef = useRef<{ win: Window | null; origin: string }>({ win: null, origin: "*" });
|
||||
const pendingPluginCallsRef = useRef<
|
||||
Map<string, { resolve: (v: unknown) => void; reject: (e: Error) => void; timeoutId: number }>
|
||||
>(new Map());
|
||||
|
||||
const attachments = useMemo<AgentAttachment[]>(
|
||||
() => [
|
||||
{
|
||||
id: openFile.id,
|
||||
title: openFile.title,
|
||||
fileUrl: openFile.fileUrl,
|
||||
mimeType: openFile.mimeType ?? null,
|
||||
},
|
||||
],
|
||||
[openFile.fileUrl, openFile.id, openFile.mimeType, openFile.title],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (flightMode) {
|
||||
setNetworkOn(false);
|
||||
}
|
||||
}, [flightMode]);
|
||||
|
||||
useEffect(() => {
|
||||
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(() => {
|
||||
writeAiPanelPrefs("onlyoffice_ai", {
|
||||
provider: aiProvider,
|
||||
model: aiModel,
|
||||
maxSteps: clamp(Math.floor(maxSteps), 1, 24),
|
||||
});
|
||||
}, [aiProvider, aiModel, maxSteps]);
|
||||
// 轻量 host:常驻接住插件 ready 握手,真正的面板 runtime 在首次点击后再挂载。
|
||||
export function OnlyOfficeAiAgentPanel(props: OnlyOfficeAiAgentPanelProps) {
|
||||
const captureReady = useOnlyOfficeAiBridgeStore((state) => state.captureReady);
|
||||
const reset = useOnlyOfficeAiBridgeStore((state) => state.reset);
|
||||
const [activated, setActivated] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const onMessage = (ev: MessageEvent) => {
|
||||
const data = ev.data as unknown;
|
||||
if (!isRecord(data)) return;
|
||||
if (data.channel !== CHANNEL) return;
|
||||
if (!data || typeof data !== "object") return;
|
||||
|
||||
const type = String(data.type ?? "").trim();
|
||||
if (type === "ready") {
|
||||
// 记录插件窗口与来源,后续回发消息更稳
|
||||
pluginTargetRef.current = {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
win: (ev.source as any) && typeof (ev.source as any).postMessage === "function" ? ((ev.source as any) as Window) : null,
|
||||
origin: String(ev.origin || "*"),
|
||||
};
|
||||
setPluginReady(true);
|
||||
return;
|
||||
}
|
||||
const channel = "channel" in (data as Record<string, unknown>) ? String((data as Record<string, unknown>).channel ?? "") : "";
|
||||
const type = "type" in (data as Record<string, unknown>) ? String((data as Record<string, unknown>).type ?? "") : "";
|
||||
if (channel !== CHANNEL || type !== "ready") return;
|
||||
|
||||
if (type === "result") {
|
||||
const callId = String(data.callId ?? "").trim();
|
||||
if (!callId) return;
|
||||
const pending = pendingPluginCallsRef.current.get(callId);
|
||||
if (!pending) return;
|
||||
pendingPluginCallsRef.current.delete(callId);
|
||||
window.clearTimeout(pending.timeoutId);
|
||||
|
||||
const ok = Boolean(data.ok);
|
||||
if (ok) {
|
||||
pending.resolve("result" in data ? (data as Record<string, unknown>).result : null);
|
||||
} else {
|
||||
pending.reject(new Error(String((data as Record<string, unknown>).error ?? "插件执行失败")));
|
||||
}
|
||||
}
|
||||
const targetWindow =
|
||||
ev.source && typeof (ev.source as Window).postMessage === "function" ? (ev.source as Window) : null;
|
||||
captureReady({
|
||||
targetOrigin: String(ev.origin || "*"),
|
||||
targetWindow,
|
||||
});
|
||||
};
|
||||
|
||||
window.addEventListener("message", onMessage);
|
||||
return () => window.removeEventListener("message", onMessage);
|
||||
}, []);
|
||||
return () => {
|
||||
window.removeEventListener("message", onMessage);
|
||||
reset();
|
||||
};
|
||||
}, [captureReady, reset]);
|
||||
|
||||
const callPlugin = async (callId: string, tool: string, args: Record<string, unknown>) => {
|
||||
const target = pluginTargetRef.current;
|
||||
if (!target.win) throw new Error("插件未就绪(未收到 ready),请稍等或刷新文档");
|
||||
|
||||
const payload = { channel: CHANNEL, type: "call", callId, tool, args };
|
||||
const result = await new Promise<unknown>((resolve, reject) => {
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
pendingPluginCallsRef.current.delete(callId);
|
||||
reject(new Error("插件调用超时"));
|
||||
}, 60_000);
|
||||
|
||||
pendingPluginCallsRef.current.set(callId, { resolve, reject, timeoutId });
|
||||
try {
|
||||
target.win!.postMessage(payload, target.origin || "*");
|
||||
} catch (e) {
|
||||
window.clearTimeout(timeoutId);
|
||||
pendingPluginCallsRef.current.delete(callId);
|
||||
reject(e instanceof Error ? e : new Error(String(e)));
|
||||
}
|
||||
});
|
||||
return result;
|
||||
};
|
||||
|
||||
const postClientToolResult = async ({
|
||||
requestId,
|
||||
callId,
|
||||
ok,
|
||||
result,
|
||||
error,
|
||||
}: {
|
||||
requestId: string;
|
||||
callId: string;
|
||||
ok: boolean;
|
||||
result?: unknown;
|
||||
error?: string;
|
||||
}) => {
|
||||
await fetch("/api/ai-agent/client-tool-result", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ requestId, callId, ok, result, error }),
|
||||
});
|
||||
};
|
||||
|
||||
const resolveImageRefToDataUrl = async (imageRef: string) => {
|
||||
const s = String(imageRef ?? "").trim();
|
||||
if (!s) throw new Error("缺少 imageRef");
|
||||
|
||||
// 1) 优先当作附件 id
|
||||
const match = attachments.find((a) => a.id === s) ?? null;
|
||||
const url = match ? match.fileUrl : s;
|
||||
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error(`图片下载失败:HTTP ${res.status}`);
|
||||
const blob = await res.blob();
|
||||
return await blobToDataUrl(blob);
|
||||
};
|
||||
|
||||
const handleClientToolCall = async (payloadText: string) => {
|
||||
let data: unknown = null;
|
||||
try {
|
||||
data = JSON.parse(payloadText || "null");
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const obj = isRecord(data) ? data : ({} as Record<string, unknown>);
|
||||
const requestId = String(obj.requestId ?? "").trim();
|
||||
const callId = String(obj.callId ?? "").trim();
|
||||
const tool = String(obj.tool ?? "").trim();
|
||||
const args = isRecord(obj.args) ? (obj.args as Record<string, unknown>) : {};
|
||||
if (!requestId || !callId || !tool) return;
|
||||
|
||||
try {
|
||||
let result: unknown = null;
|
||||
if (tool === "oo_insert_image") {
|
||||
const imageRef = String(args.imageRef ?? "").trim();
|
||||
const src = await resolveImageRefToDataUrl(imageRef);
|
||||
const width = Number(args.width ?? 0);
|
||||
const height = Number(args.height ?? 0);
|
||||
result = await callPlugin(callId, tool, { ...args, src, width, height });
|
||||
} else {
|
||||
result = await callPlugin(callId, tool, args);
|
||||
}
|
||||
await postClientToolResult({ requestId, callId, ok: true, result });
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
await postClientToolResult({ requestId, callId, ok: false, error: msg });
|
||||
}
|
||||
};
|
||||
|
||||
const stop = () => {
|
||||
abortRef.current?.abort();
|
||||
abortRef.current = null;
|
||||
setLoading(false);
|
||||
if (aiProvider === "codex") {
|
||||
setToolLogs((prev) => [...prev, { type: "info", message: "已暂停(可继续对话)" }]);
|
||||
}
|
||||
};
|
||||
|
||||
const send = async () => {
|
||||
const text = input.trim();
|
||||
if (!text) return;
|
||||
if (loading) return;
|
||||
|
||||
const codexSessionIdForRequest =
|
||||
aiProvider === "codex" ? String(codexSessionId ?? "").trim() || null : null;
|
||||
|
||||
const nextMessages: AgentMessage[] = [...messages, { role: "user", content: text }];
|
||||
setMessages(nextMessages);
|
||||
setInput("");
|
||||
setLoading(true);
|
||||
setToolLogs([]);
|
||||
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
|
||||
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: "onlyoffice",
|
||||
messages: nextMessages.slice(-20),
|
||||
attachments,
|
||||
toolChoice: {
|
||||
mode: toolAuto ? "auto" : "manual",
|
||||
toolSets: [
|
||||
"toolset.readonly",
|
||||
"toolset.rag_read",
|
||||
"toolset.media_read",
|
||||
"toolset.docs_read",
|
||||
"toolset.onlyoffice_editor",
|
||||
],
|
||||
},
|
||||
options: {
|
||||
searxng: networkOn,
|
||||
ai: {
|
||||
provider: aiProvider,
|
||||
...(aiProvider === "codex" && codexSessionIdForRequest ? { sessionId: codexSessionIdForRequest } : {}),
|
||||
...(aiProvider !== "codex" && String(aiModel || "").trim() ? { model: String(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 ?? "") : "";
|
||||
throw new Error(err || `HTTP ${res.status}`);
|
||||
}
|
||||
|
||||
await parseSseChunks(res, (event, dataText) => {
|
||||
if (event === "codex_session") {
|
||||
try {
|
||||
const d = JSON.parse(dataText || "null") as unknown;
|
||||
const obj = isRecord(d) ? d : ({} as Record<string, unknown>);
|
||||
const sid = String(obj.sessionId ?? "").trim();
|
||||
if (sid) {
|
||||
setCodexSessionId(sid);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (event === "assistant_message") {
|
||||
try {
|
||||
const d = JSON.parse(dataText || "null") as unknown;
|
||||
const t = isRecord(d) && "text" in d ? String(d.text ?? "") : "";
|
||||
if (t) setMessages((prev) => [...prev, { role: "assistant", content: t }]);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (event === "client_tool_call") {
|
||||
void handleClientToolCall(dataText);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event === "tool_call") {
|
||||
try {
|
||||
const d = JSON.parse(dataText || "null") as unknown;
|
||||
const obj = isRecord(d) ? d : ({} as Record<string, unknown>);
|
||||
setToolLogs((prev) => [
|
||||
...prev,
|
||||
{
|
||||
type: "tool_call",
|
||||
id: String(obj.id ?? ""),
|
||||
tool: String(obj.tool ?? ""),
|
||||
args: (isRecord(obj.args) ? obj.args : {}) as Record<string, unknown>,
|
||||
},
|
||||
]);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (event === "tool_result") {
|
||||
try {
|
||||
const d = JSON.parse(dataText || "null") as unknown;
|
||||
const obj = isRecord(d) ? d : ({} as Record<string, unknown>);
|
||||
setToolLogs((prev) => [
|
||||
...prev,
|
||||
{
|
||||
type: "tool_result",
|
||||
id: String(obj.id ?? ""),
|
||||
tool: String(obj.tool ?? ""),
|
||||
ok: Boolean(obj.ok),
|
||||
ms: Number(obj.ms ?? 0),
|
||||
result: "result" in obj ? obj.result : null,
|
||||
},
|
||||
]);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (event === "error") {
|
||||
if (controller.signal.aborted && aiProvider === "codex") return;
|
||||
try {
|
||||
const d = JSON.parse(dataText || "null") as unknown;
|
||||
const msg = isRecord(d) && "message" in d ? String(d.message ?? "") : "";
|
||||
setToolLogs((prev) => [...prev, { type: "error", message: msg || "未知错误" }]);
|
||||
} catch {
|
||||
setToolLogs((prev) => [...prev, { type: "error", message: "未知错误" }]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
if (controller.signal.aborted && aiProvider === "codex") return;
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
setToolLogs((prev) => [...prev, { type: "error", message: msg }]);
|
||||
} finally {
|
||||
abortRef.current = null;
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const canSend = useMemo(() => input.trim().length > 0 && !loading, [input, loading]);
|
||||
|
||||
return (
|
||||
<>
|
||||
if (!activated) {
|
||||
return (
|
||||
<div className="fixed bottom-4 right-4 z-[60]">
|
||||
<Button
|
||||
className="shadow"
|
||||
onClick={() => {
|
||||
setOpen((v) => !v);
|
||||
if (!open) setPage("chat");
|
||||
setActivated(true);
|
||||
}}
|
||||
>
|
||||
<Bot className="mr-2 h-4 w-4" />
|
||||
AI
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
{open ? (
|
||||
<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">
|
||||
<div className="font-medium">联网检索</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={networkOn}
|
||||
onChange={(e) => setNetworkOn(e.target.checked)}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="font-medium">自动工具</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={toolAuto}
|
||||
onChange={(e) => setToolAuto(e.target.checked)}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
<div className="rounded border p-2 text-xs text-muted-foreground">
|
||||
<div>插件状态:{pluginReady ? "已连接" : "未连接(等待 ready)"}</div>
|
||||
<div>说明:oo_* 工具依赖该插件执行“选区读写”。</div>
|
||||
</div>
|
||||
|
||||
<details open className="rounded border p-2">
|
||||
<summary className="cursor-pointer select-none text-sm font-medium">工具日志(可折叠)</summary>
|
||||
<div className="mt-2 space-y-2">
|
||||
{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 === "info") {
|
||||
return (
|
||||
<div key={idx} className="rounded border bg-muted p-2 text-muted-foreground">
|
||||
{l.message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (l.type === "tool_call") {
|
||||
return (
|
||||
<div key={idx} className="rounded border p-2">
|
||||
<div className="text-xs text-muted-foreground">tool_call · {l.id}</div>
|
||||
<div className="font-medium">{l.tool}</div>
|
||||
<pre className="mt-1 whitespace-pre-wrap text-xs">{JSON.stringify(l.args, null, 2)}</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div key={idx} className="rounded border p-2">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
tool_result · {l.id} · {l.ok ? "ok" : "fail"} · {l.ms}ms
|
||||
</div>
|
||||
<div className="font-medium">{l.tool}</div>
|
||||
<pre className="mt-1 whitespace-pre-wrap text-xs">{JSON.stringify(l.result, null, 2)}</pre>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{page === "settings" ? (
|
||||
<div className="space-y-3 p-3 text-sm">
|
||||
<label className="flex items-center justify-between gap-2">
|
||||
<span className="font-medium">最大步数</span>
|
||||
<input
|
||||
className="w-[96px] 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(clamp(Math.floor(v), 1, 24));
|
||||
}}
|
||||
disabled={loading}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center justify-between gap-2">
|
||||
<span className="font-medium">AI 提供方</span>
|
||||
<select
|
||||
className="rounded border px-2 py-1 text-xs"
|
||||
value={aiProvider}
|
||||
onChange={(e) => {
|
||||
const v = String(e.target.value || "").trim();
|
||||
if (v === "online" || v === "local" || v === "ollama" || v === "codex") setAiProvider(v);
|
||||
else setAiProvider("online");
|
||||
}}
|
||||
disabled={loading}
|
||||
>
|
||||
<option value="online">在线</option>
|
||||
<option value="local">本地</option>
|
||||
<option value="ollama">Ollama</option>
|
||||
<option value="codex">Codex</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center justify-between gap-2">
|
||||
<span className="font-medium">模型</span>
|
||||
{aiProvider === "codex" ? (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
消息开头加 <code className="rounded bg-muted px-1 py-0.5">#chat</code> /{" "}
|
||||
<code className="rounded bg-muted px-1 py-0.5">#test</code> /{" "}
|
||||
<code className="rounded bg-muted px-1 py-0.5">#dev</code>(默认 <code className="rounded bg-muted px-1 py-0.5">#chat</code>)。
|
||||
</div>
|
||||
) : aiProvider === "online" ? (
|
||||
<select
|
||||
className="w-[220px] rounded border px-2 py-1 text-xs"
|
||||
value={ONLINE_MODELS.includes(aiModel as any) ? aiModel : ""}
|
||||
onChange={(e) => setAiModel(e.target.value)}
|
||||
disabled={loading}
|
||||
>
|
||||
{ONLINE_MODELS.map((m) => (
|
||||
<option key={m} value={m}>
|
||||
{m || "默认"}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : aiProvider === "ollama" ? (
|
||||
<select
|
||||
className="w-[320px] rounded border px-2 py-1 text-xs"
|
||||
value={aiModel === "" || aiModel === OLLAMA_QWEN3_30B ? aiModel : ""}
|
||||
onChange={(e) => setAiModel(e.target.value)}
|
||||
disabled={loading}
|
||||
>
|
||||
<option value="">默认:{OLLAMA_QWEN3_30B}</option>
|
||||
<option value={OLLAMA_QWEN3_30B}>{OLLAMA_QWEN3_30B}</option>
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
className="w-[320px] rounded border px-2 py-1 text-xs"
|
||||
value={aiModel}
|
||||
onChange={(e) => setAiModel(e.target.value)}
|
||||
placeholder="默认(ai.local.md/环境变量)"
|
||||
disabled={loading}
|
||||
list="oo-local-model-suggestions"
|
||||
/>
|
||||
)}
|
||||
</label>
|
||||
<datalist id="oo-local-model-suggestions">
|
||||
<option value={OLLAMA_QWEN3_30B} />
|
||||
</datalist>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{page === "chat" ? (
|
||||
<div className="flex h-[calc(100vh-96px)] flex-col">
|
||||
<ScrollArea className="flex-1">
|
||||
<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 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"
|
||||
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()}>
|
||||
发送
|
||||
</Button>
|
||||
<Button variant="secondary" disabled={!loading} onClick={stop}>
|
||||
{aiProvider === "codex" ? "暂停" : "停止"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</AiBridgePanel>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
return <OnlyOfficeAiAgentPanelRuntime {...props} initialOpen />;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user