0.1.14 上线前更改

This commit is contained in:
liaibo
2026-01-11 12:35:53 +08:00
parent be71849aa5
commit 725a60d3aa
44 changed files with 3427 additions and 310 deletions
@@ -0,0 +1,610 @@
"use client";
import React, { useEffect, useMemo, useRef, useState } from "react";
import { Bot, Settings, Wrench, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Textarea } from "@/components/ui/textarea";
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: "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 clamp = (n: number, min: number, max: number) => Math.max(min, Math.min(max, n));
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();
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 [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(true);
const [toolAuto, setToolAuto] = useState(true);
const [aiProvider, setAiProvider] = useState<"online" | "local">("online");
const [aiModel, setAiModel] = useState<string>("");
const [maxSteps, setMaxSteps] = useState<number>(10);
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(() => {
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") setAiProvider(providerRaw);
if (typeof modelRaw === "string") setAiModel(modelRaw);
if (Number.isFinite(parsed) && parsed >= 1) setMaxSteps(clamp(Math.floor(parsed), 1, 24));
} catch {
// ignore
}
}, []);
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
}
}, [aiProvider, aiModel, maxSteps]);
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") {
// 记录插件窗口与来源,后续回发消息更稳
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;
}
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);
}, []);
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);
};
const send = async () => {
const text = input.trim();
if (!text) return;
if (loading) return;
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, model: aiModel } },
}),
});
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 === "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") {
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) {
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 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>
{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 === "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) => setAiProvider(e.target.value === "local" ? "local" : "online")}
disabled={loading}
>
<option value="online">线</option>
<option value="local"></option>
</select>
</label>
<label className="flex items-center justify-between gap-2">
<span className="font-medium"></span>
<select
className="w-[220px] rounded border px-2 py-1 text-xs"
value={aiModel}
onChange={(e) => setAiModel(e.target.value)}
disabled={loading || aiProvider !== "online"}
>
{ONLINE_MODELS.map((m) => (
<option key={m} value={m}>
{m || "默认"}
</option>
))}
</select>
</label>
</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}>
</Button>
</div>
</div>
</div>
</div>
) : null}
</div>
) : null}
</>
);
}