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:
@@ -1,7 +1,6 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import type { ReactNode } from "react";
|
||||
import { Sidebar } from "@/components/sidebar/sidebar";
|
||||
import { GlobalAiAgentHost } from "@/components/ai-agent/GlobalAiAgentHost";
|
||||
import { Breadcrumb } from "@/components/breadcrumb";
|
||||
import { MobileSidebarTrigger } from "@/components/mobile-sidebar-trigger";
|
||||
import { SearchPalette } from "@/components/search/search-palette";
|
||||
@@ -30,7 +29,6 @@ export default async function AppLayout({ children }: { children: ReactNode }) {
|
||||
</header>
|
||||
<main className="flex-1 overflow-hidden bg-white">{children}</main>
|
||||
<SearchPalette workspaceId={sidebarInitialData?.activeWorkspaceId ?? null} />
|
||||
<GlobalAiAgentHost />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1110,7 +1110,7 @@ export async function POST(request: Request) {
|
||||
}
|
||||
|
||||
const finalOutline = plan
|
||||
? plan.chapters.flatMap((chapter, chapterIndex) => [
|
||||
? plan.chapters.flatMap((chapter) => [
|
||||
{
|
||||
title: chapter.title,
|
||||
level: 1,
|
||||
@@ -1144,7 +1144,8 @@ export async function POST(request: Request) {
|
||||
pageLinkPattern,
|
||||
outline: finalOutline,
|
||||
};
|
||||
const result = await executeRustBridgeTool<{
|
||||
// 说明:导图树构建已经交给 Rust runtime,这里只保留 PDF 提取与 transport 壳。
|
||||
const rustResult = await executeRustBridgeTool<{
|
||||
ok: boolean;
|
||||
data?: unknown;
|
||||
meta?: { title?: string | null; outlineCount?: number | null } | null;
|
||||
@@ -1156,10 +1157,16 @@ export async function POST(request: Request) {
|
||||
data: payload,
|
||||
mode: "result",
|
||||
});
|
||||
const mindmapData = (result as any)?.data ?? {
|
||||
data: { text: pdfTitle },
|
||||
children: [],
|
||||
};
|
||||
const mindmapData =
|
||||
rustResult.result && typeof rustResult.result === "object" && !Array.isArray(rustResult.result) && "data" in rustResult.result
|
||||
? ((rustResult.result as { data?: unknown }).data ?? {
|
||||
data: { text: pdfTitle },
|
||||
children: [],
|
||||
})
|
||||
: {
|
||||
data: { text: pdfTitle },
|
||||
children: [],
|
||||
};
|
||||
|
||||
return NextResponse.json({
|
||||
mindmapData,
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||
import { api } from "@/lib/convex/api";
|
||||
import { buildDocumentBridgeContextWithActor } from "@/lib/documents/bridge";
|
||||
import { executeRustBridgeTool } from "@/lib/documents/rust-runtime";
|
||||
import {
|
||||
buildDocumentBridgeContextWithActor,
|
||||
buildDocumentCommandEnvelope,
|
||||
documentBridgeErrorResponse,
|
||||
} from "@/lib/documents/bridge";
|
||||
import {
|
||||
executeRustBridgeMutationTransport,
|
||||
resolveRustBridgeCommandPlan,
|
||||
} from "@/lib/documents/rust-runtime";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -13,39 +19,47 @@ interface EmptyTrashPayload {
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (isConvexEnabled()) {
|
||||
const { client } = await getAuthedConvexClient();
|
||||
const { auth, client } = await getAuthedConvexClient();
|
||||
const { workspaceId }: EmptyTrashPayload = await request.json().catch(() => ({}));
|
||||
if (!workspaceId) {
|
||||
return NextResponse.json({ error: "缺少 workspaceId" }, { status: 400 });
|
||||
}
|
||||
|
||||
const context = buildDocumentBridgeContextWithActor({
|
||||
request,
|
||||
actor: {
|
||||
actorType: "service",
|
||||
actorId: "mindmap-trash-empty",
|
||||
sessionId: null,
|
||||
},
|
||||
workspaceId,
|
||||
source: {
|
||||
channel: "mindmap-trash-route",
|
||||
client: "wolai-frontend",
|
||||
},
|
||||
});
|
||||
await executeRustBridgeTool({
|
||||
context,
|
||||
toolName: "mindmap_empty_trash",
|
||||
invocationKind: "command",
|
||||
args: { workspaceId },
|
||||
data: { workspaceId, source: "mindmap-trash-empty" },
|
||||
mode: "result",
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await client.mutation(api.mindmaps.emptyTrashByWorkspace, { workspaceId });
|
||||
const context = buildDocumentBridgeContextWithActor({
|
||||
request,
|
||||
actor: {
|
||||
actorType: "user",
|
||||
actorId: auth.userId,
|
||||
sessionId: null,
|
||||
},
|
||||
workspaceId,
|
||||
source: {
|
||||
channel: "mindmap-trash-route",
|
||||
client: "wolai-frontend",
|
||||
},
|
||||
});
|
||||
const envelope = buildDocumentCommandEnvelope({
|
||||
name: "mindmaps.emptyTrashByWorkspace",
|
||||
payload: { workspaceId },
|
||||
context,
|
||||
target: {
|
||||
workspaceId,
|
||||
},
|
||||
reason: "mindmap-trash:empty",
|
||||
refs: ["task-047", "mindmap-trash"],
|
||||
});
|
||||
const plan = await resolveRustBridgeCommandPlan({ context, envelope });
|
||||
const result = await executeRustBridgeMutationTransport<{
|
||||
ok?: boolean;
|
||||
deletedCount?: number;
|
||||
}>({
|
||||
client,
|
||||
plan,
|
||||
});
|
||||
return NextResponse.json({ ok: true, removed: result?.deletedCount ?? 0 });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: (error as Error).message }, { status: 400 });
|
||||
return documentBridgeErrorResponse(error);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||
import { api } from "@/lib/convex/api";
|
||||
import { buildMindmapRouteMeta } from "@/lib/mindmap/mindmapRouteMeta";
|
||||
import {
|
||||
buildDocumentBridgeContextWithActor,
|
||||
@@ -172,7 +171,43 @@ export async function DELETE(
|
||||
if (isConvexEnabled()) {
|
||||
const { auth, client } = await getAuthedConvexClient();
|
||||
try {
|
||||
const result = await client.mutation(api.mindmaps.softDelete, { docId, mindmapId });
|
||||
const context = buildDocumentBridgeContextWithActor({
|
||||
request,
|
||||
actor: {
|
||||
actorType: "user",
|
||||
actorId: auth.userId,
|
||||
sessionId: null,
|
||||
},
|
||||
workspaceId: null,
|
||||
source: {
|
||||
channel: "mindmap-route",
|
||||
client: "wolai-frontend",
|
||||
},
|
||||
});
|
||||
const envelope = buildDocumentCommandEnvelope({
|
||||
name: "mindmaps.delete",
|
||||
payload: {
|
||||
documentId: docId,
|
||||
mindmapId,
|
||||
},
|
||||
context,
|
||||
target: {
|
||||
workspaceId: null,
|
||||
pageId: docId,
|
||||
blockId: mindmapId,
|
||||
},
|
||||
reason: "mindmap-route:delete",
|
||||
refs: ["task-047", "mindmap-route"],
|
||||
});
|
||||
const plan = await resolveRustBridgeCommandPlan({ context, envelope });
|
||||
const result = await executeRustBridgeMutationTransport<{
|
||||
ok?: boolean;
|
||||
workspace_id?: string | null;
|
||||
deleted_at?: string | null;
|
||||
}>({
|
||||
client,
|
||||
plan,
|
||||
});
|
||||
return NextResponse.json({
|
||||
...(result ?? { ok: true }),
|
||||
meta: {
|
||||
@@ -186,7 +221,7 @@ export async function DELETE(
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: (error as Error).message }, { status: 400 });
|
||||
return documentBridgeErrorResponse(error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -207,19 +242,43 @@ export async function PATCH(
|
||||
}
|
||||
|
||||
try {
|
||||
if (action === "purge") {
|
||||
const result = await client.mutation(api.mindmaps.purge, { docId, mindmapId });
|
||||
return NextResponse.json({
|
||||
...(result ?? { ok: true }),
|
||||
meta: buildMindmapRouteMeta(request, {
|
||||
workspaceId: result?.workspace_id ?? null,
|
||||
documentId: docId,
|
||||
mindmapId,
|
||||
ownerUserId: auth.userId,
|
||||
}),
|
||||
});
|
||||
}
|
||||
const result = await client.mutation(api.mindmaps.restore, { docId, mindmapId });
|
||||
const context = buildDocumentBridgeContextWithActor({
|
||||
request,
|
||||
actor: {
|
||||
actorType: "user",
|
||||
actorId: auth.userId,
|
||||
sessionId: null,
|
||||
},
|
||||
workspaceId: null,
|
||||
source: {
|
||||
channel: "mindmap-route",
|
||||
client: "wolai-frontend",
|
||||
},
|
||||
});
|
||||
const envelope = buildDocumentCommandEnvelope({
|
||||
name: action === "purge" ? "mindmaps.purge" : "mindmaps.restore",
|
||||
payload: {
|
||||
documentId: docId,
|
||||
mindmapId,
|
||||
},
|
||||
context,
|
||||
target: {
|
||||
workspaceId: null,
|
||||
pageId: docId,
|
||||
blockId: mindmapId,
|
||||
},
|
||||
reason: `mindmap-route:${action}`,
|
||||
refs: ["task-047", "mindmap-route"],
|
||||
});
|
||||
const plan = await resolveRustBridgeCommandPlan({ context, envelope });
|
||||
const result = await executeRustBridgeMutationTransport<{
|
||||
ok?: boolean;
|
||||
workspace_id?: string | null;
|
||||
updated_at?: string | null;
|
||||
}>({
|
||||
client,
|
||||
plan,
|
||||
});
|
||||
return NextResponse.json({
|
||||
...(result ?? { ok: true }),
|
||||
meta: {
|
||||
@@ -229,13 +288,11 @@ export async function PATCH(
|
||||
mindmapId,
|
||||
ownerUserId: auth.userId,
|
||||
}),
|
||||
updatedAt: result?.updated_at ?? null,
|
||||
updatedAt: action === "restore" ? result?.updated_at ?? null : null,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const msg = (error as Error).message ?? "操作失败";
|
||||
const status = msg.includes("未找到") ? 404 : 400;
|
||||
return NextResponse.json({ error: msg }, { status });
|
||||
return documentBridgeErrorResponse(error);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||
import { api } from "@/lib/convex/api";
|
||||
import {
|
||||
buildDocumentBridgeContextWithActor,
|
||||
buildDocumentCommandEnvelope,
|
||||
@@ -179,19 +178,59 @@ export async function DELETE(
|
||||
if (isConvexEnabled()) {
|
||||
const { auth, client } = await getAuthedConvexClient();
|
||||
const mindmapId = `legacy-${docId}`;
|
||||
const result = await client.mutation(api.mindmaps.softDelete, { docId, mindmapId });
|
||||
return NextResponse.json({
|
||||
...(result ?? { ok: true }),
|
||||
meta: {
|
||||
...buildMindmapRouteMeta(request, {
|
||||
workspaceId: result?.workspace_id ?? null,
|
||||
try {
|
||||
const context = buildDocumentBridgeContextWithActor({
|
||||
request,
|
||||
actor: {
|
||||
actorType: "user",
|
||||
actorId: auth.userId,
|
||||
sessionId: null,
|
||||
},
|
||||
workspaceId: null,
|
||||
source: {
|
||||
channel: "mindmap-route",
|
||||
client: "wolai-frontend",
|
||||
},
|
||||
});
|
||||
const envelope = buildDocumentCommandEnvelope({
|
||||
name: "mindmaps.delete",
|
||||
payload: {
|
||||
documentId: docId,
|
||||
mindmapId,
|
||||
ownerUserId: auth.userId,
|
||||
}),
|
||||
deletedAt: result?.deleted_at ?? null,
|
||||
},
|
||||
});
|
||||
},
|
||||
context,
|
||||
target: {
|
||||
workspaceId: null,
|
||||
pageId: docId,
|
||||
blockId: mindmapId,
|
||||
},
|
||||
reason: "mindmap-route:delete",
|
||||
refs: ["task-047", "mindmap-route"],
|
||||
});
|
||||
const plan = await resolveRustBridgeCommandPlan({ context, envelope });
|
||||
const result = await executeRustBridgeMutationTransport<{
|
||||
ok?: boolean;
|
||||
workspace_id?: string | null;
|
||||
deleted_at?: string | null;
|
||||
}>({
|
||||
client,
|
||||
plan,
|
||||
});
|
||||
return NextResponse.json({
|
||||
...(result ?? { ok: true }),
|
||||
meta: {
|
||||
...buildMindmapRouteMeta(request, {
|
||||
workspaceId: result?.workspace_id ?? null,
|
||||
documentId: docId,
|
||||
mindmapId,
|
||||
ownerUserId: auth.userId,
|
||||
}),
|
||||
deletedAt: result?.deleted_at ?? null,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return documentBridgeErrorResponse(error);
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||
|
||||
@@ -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}
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { isPlainObject } from "@/lib/type-guards";
|
||||
|
||||
export type HermesBridgeConfig = {
|
||||
baseUrl: string;
|
||||
apiKey: string | null;
|
||||
};
|
||||
|
||||
export type HermesRunRequest = {
|
||||
input: Array<{ role: string; content: string }> | string;
|
||||
instructions?: string;
|
||||
conversation_history?: Array<{ role: string; content: string }>;
|
||||
session_id?: string;
|
||||
};
|
||||
|
||||
export type HermesRunStarted = {
|
||||
runId: string;
|
||||
};
|
||||
|
||||
export type HermesRunEvent =
|
||||
| { event: "tool.started"; tool: string; preview?: string | null }
|
||||
| { event: "tool.completed"; tool: string; duration?: number; error?: boolean }
|
||||
| { event: "message.delta"; delta: string }
|
||||
| { event: "run.completed"; output?: string; usage?: Record<string, unknown> }
|
||||
| { event: "run.failed"; error?: string }
|
||||
| { event: string; [key: string]: unknown };
|
||||
|
||||
const DEFAULT_BASE_URL = "http://127.0.0.1:8642";
|
||||
|
||||
const trimTrailingSlash = (value: string) => value.replace(/\/+$/, "");
|
||||
|
||||
export const readHermesBridgeConfig = (): HermesBridgeConfig => ({
|
||||
baseUrl: trimTrailingSlash((process.env.MNOTE_HERMES_API_BASE_URL || "").trim() || DEFAULT_BASE_URL),
|
||||
apiKey: (process.env.MNOTE_HERMES_API_KEY || "").trim() || null,
|
||||
});
|
||||
|
||||
export const buildHermesHeaders = (config: HermesBridgeConfig, init?: HeadersInit) => {
|
||||
const headers = new Headers(init);
|
||||
headers.set("Content-Type", "application/json");
|
||||
if (config.apiKey) {
|
||||
headers.set("Authorization", `Bearer ${config.apiKey}`);
|
||||
}
|
||||
return headers;
|
||||
};
|
||||
|
||||
export const startHermesRun = async (payload: HermesRunRequest): Promise<HermesRunStarted> => {
|
||||
const config = readHermesBridgeConfig();
|
||||
const response = await fetch(`${config.baseUrl}/v1/runs`, {
|
||||
method: "POST",
|
||||
headers: buildHermesHeaders(config),
|
||||
body: JSON.stringify(payload),
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => "");
|
||||
throw new Error(text || `Hermes run 启动失败:HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const json = (await response.json().catch(() => null)) as unknown;
|
||||
const runId = isPlainObject(json) ? String(json.run_id ?? "").trim() : "";
|
||||
if (!runId) throw new Error("Hermes run 响应缺少 run_id");
|
||||
return { runId };
|
||||
};
|
||||
|
||||
export const streamHermesRunEvents = async (
|
||||
runId: string,
|
||||
onEvent: (event: HermesRunEvent) => Promise<void> | void,
|
||||
) => {
|
||||
const config = readHermesBridgeConfig();
|
||||
const response = await fetch(`${config.baseUrl}/v1/runs/${encodeURIComponent(runId)}/events`, {
|
||||
method: "GET",
|
||||
headers: buildHermesHeaders(config),
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => "");
|
||||
throw new Error(text || `Hermes 事件流连接失败:HTTP ${response.status}`);
|
||||
}
|
||||
if (!response.body) throw new Error("Hermes 事件流不支持 body");
|
||||
|
||||
const reader = response.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 dataLines = raw
|
||||
.split(/\r?\n/)
|
||||
.filter((line) => line.startsWith("data:"))
|
||||
.map((line) => line.slice("data:".length).trimStart());
|
||||
if (dataLines.length === 0) continue;
|
||||
|
||||
let parsed: unknown = null;
|
||||
try {
|
||||
parsed = JSON.parse(dataLines.join("\n"));
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (!isPlainObject(parsed)) continue;
|
||||
await onEvent(parsed as HermesRunEvent);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,174 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { runAiAgent } from "./runAgent";
|
||||
import type { OpenAiCompatibleChatMessage } from "@/lib/ai/openaiCompatibleChat";
|
||||
|
||||
describe("runAiAgent", () => {
|
||||
it("按顺序执行 docs_search 和 docs_read 工具链", async () => {
|
||||
const events: Array<{ type: string; data: unknown }> = [];
|
||||
const chatCalls: OpenAiCompatibleChatMessage[][] = [];
|
||||
const runTool = vi.fn(async (toolId: string, toolArgs: Record<string, unknown>) => {
|
||||
if (toolId === "docs_search") {
|
||||
expect(toolArgs).toEqual({
|
||||
query: "Rust runtime 收口",
|
||||
limit: 5,
|
||||
});
|
||||
return {
|
||||
query: "Rust runtime 收口",
|
||||
results: [
|
||||
{
|
||||
id: "page_1",
|
||||
title: "Rust 文档",
|
||||
snippet: "这里记录 rust runtime 收口",
|
||||
},
|
||||
],
|
||||
source: "convex",
|
||||
};
|
||||
}
|
||||
|
||||
if (toolId === "docs_read") {
|
||||
expect(toolArgs).toEqual({
|
||||
documentId: "page_1",
|
||||
maxChars: 200,
|
||||
});
|
||||
return {
|
||||
documentId: "page_1",
|
||||
title: "Rust 文档",
|
||||
rawText: "这里记录 rust runtime 收口",
|
||||
rawTextLength: 24,
|
||||
source: "convex",
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(`未知工具: ${toolId}`);
|
||||
});
|
||||
|
||||
let chatStep = 0;
|
||||
const chat = vi.fn(async (messages: OpenAiCompatibleChatMessage[]) => {
|
||||
chatCalls.push(messages);
|
||||
chatStep += 1;
|
||||
|
||||
if (chatStep === 1) {
|
||||
return {
|
||||
text: '<docs_search>{"query":"Rust runtime 收口","limit":5}</docs_search>',
|
||||
raw: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (chatStep === 2) {
|
||||
expect(messages.at(-1)?.content).toContain('<tool_result tool="docs_search">');
|
||||
expect(messages.at(-1)?.content).toContain('"id":"page_1"');
|
||||
return {
|
||||
text: '<docs_read>{"documentId":"page_1","maxChars":200}</docs_read>',
|
||||
raw: null,
|
||||
};
|
||||
}
|
||||
|
||||
expect(messages.at(-1)?.content).toContain('<tool_result tool="docs_read">');
|
||||
expect(messages.at(-1)?.content).toContain("rust runtime 收口");
|
||||
return {
|
||||
text: "已找到目标文档并读取原文。",
|
||||
raw: null,
|
||||
};
|
||||
});
|
||||
|
||||
const result = await runAiAgent({
|
||||
userMessages: [{ role: "user", content: "请帮我查找 Rust runtime 收口的相关文档" }],
|
||||
cfg: {
|
||||
baseUrl: "http://127.0.0.1:11434/v1",
|
||||
apiKey: "",
|
||||
model: "test-model",
|
||||
},
|
||||
chat,
|
||||
allowedToolIds: new Set(["docs_search", "docs_read"]),
|
||||
runTool,
|
||||
maxSteps: 4,
|
||||
onEvent: (event) => events.push(event),
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
text: "已找到目标文档并读取原文。",
|
||||
steps: 3,
|
||||
});
|
||||
expect(chat).toHaveBeenCalledTimes(3);
|
||||
expect(runTool).toHaveBeenCalledTimes(2);
|
||||
expect(runTool).toHaveBeenNthCalledWith(1, "docs_search", {
|
||||
query: "Rust runtime 收口",
|
||||
limit: 5,
|
||||
});
|
||||
expect(runTool).toHaveBeenNthCalledWith(2, "docs_read", {
|
||||
documentId: "page_1",
|
||||
maxChars: 200,
|
||||
});
|
||||
|
||||
expect(chatCalls[0]?.[0]?.role).toBe("system");
|
||||
expect(chatCalls[0]?.[1]?.content).toBe("请帮我查找 Rust runtime 收口的相关文档");
|
||||
|
||||
expect(events).toEqual([
|
||||
{
|
||||
type: "tool_call",
|
||||
data: {
|
||||
id: expect.any(String),
|
||||
tool: "docs_search",
|
||||
args: {
|
||||
query: "Rust runtime 收口",
|
||||
limit: 5,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "tool_result",
|
||||
data: {
|
||||
id: expect.any(String),
|
||||
tool: "docs_search",
|
||||
ok: true,
|
||||
ms: expect.any(Number),
|
||||
result: {
|
||||
query: "Rust runtime 收口",
|
||||
results: [
|
||||
{
|
||||
id: "page_1",
|
||||
title: "Rust 文档",
|
||||
snippet: "这里记录 rust runtime 收口",
|
||||
},
|
||||
],
|
||||
source: "convex",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "tool_call",
|
||||
data: {
|
||||
id: expect.any(String),
|
||||
tool: "docs_read",
|
||||
args: {
|
||||
documentId: "page_1",
|
||||
maxChars: 200,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "tool_result",
|
||||
data: {
|
||||
id: expect.any(String),
|
||||
tool: "docs_read",
|
||||
ok: true,
|
||||
ms: expect.any(Number),
|
||||
result: {
|
||||
documentId: "page_1",
|
||||
title: "Rust 文档",
|
||||
rawText: "这里记录 rust runtime 收口",
|
||||
rawTextLength: 24,
|
||||
source: "convex",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "assistant_message",
|
||||
data: {
|
||||
text: "已找到目标文档并读取原文。",
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
// 遗留兼容:runAiAgent 已退出 /api/ai-agent/run 主链,保留此文件仅用于历史测试与渐进清理。
|
||||
import type { OpenAiCompatibleChatMessage, OpenAiCompatibleChatOptions } from "@/lib/ai/openaiCompatibleChat";
|
||||
import { openAiCompatibleChat } from "@/lib/ai/openaiCompatibleChat";
|
||||
import { parseToolTagCalls, formatToolResultTag } from "../protocol/toolTagProtocol";
|
||||
|
||||
@@ -48,7 +48,8 @@ export const createDocsServerTools = (args: {
|
||||
supabase?: DocsSupabaseClient;
|
||||
ctx: DocsToolContext;
|
||||
allowedToolIds: Set<string>;
|
||||
// 说明:Convex 迁移阶段用于“去 Supabase 化”。如果提供该能力,则完全不依赖 Supabase。
|
||||
// 说明:该文件现在主要服务于非 Convex 模式或兼容兜底;Convex 主链下 docs_* 已改由 Rust runtime 产出结果。
|
||||
// 说明:如果提供该能力,则走本地兼容 transport,不依赖 Supabase。
|
||||
searchDocs?: (args: {
|
||||
userId: string;
|
||||
query: string;
|
||||
|
||||
@@ -446,10 +446,10 @@ export const createMindmapServerTools = (args: {
|
||||
invocationKind: "command",
|
||||
toolArgs: withMindmapIds({ ops: normalized, reason }),
|
||||
data: base,
|
||||
target: mindmapTarget(doc),
|
||||
target: mindmapTarget(loaded.doc),
|
||||
reason,
|
||||
});
|
||||
await persistResultData(doc, result);
|
||||
await persistResultData(loaded.doc, result);
|
||||
return result;
|
||||
}
|
||||
const { data: nextData, applied, errors } = applyMindmapOps(base, normalized);
|
||||
@@ -460,7 +460,7 @@ export const createMindmapServerTools = (args: {
|
||||
opCount: normalized.length,
|
||||
});
|
||||
}
|
||||
await persistMindmap(doc, nextData);
|
||||
await persistMindmap(loaded.doc, nextData);
|
||||
return { ok: true, applied, errors, data: nextData, meta: { reason } };
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { applyMindmapOps, ensureMindmapUids, type MindmapOp, type MindmapTreeNode, type NodeRef } from "@/lib/mindmap/mindmapOps";
|
||||
import { ensureMindmapUids, type MindmapOp, type MindmapTreeNode, type NodeRef } from "@/lib/mindmap/mindmapOps";
|
||||
import { readMindmapLocal, writeMindmapLocal } from "@/lib/mindmap/mindmapLocalStore";
|
||||
import { buildDocumentBridgeContextWithActor } from "@/lib/documents/bridge";
|
||||
import { executeRustBridgeTool } from "@/lib/documents/rust-runtime";
|
||||
|
||||
type SupabaseRouteClient = {
|
||||
from: (table: string) => any;
|
||||
@@ -229,6 +231,21 @@ export const createOnlyOfficeServerTools = (args: {
|
||||
ctx: OnlyOfficeToolContext;
|
||||
allowedToolIds: Set<string>;
|
||||
}) => {
|
||||
const buildRustContext = () =>
|
||||
buildDocumentBridgeContextWithActor({
|
||||
request: new Request("http://localhost"),
|
||||
actor: {
|
||||
actorType: "service",
|
||||
actorId: "onlyoffice-asset-to-mindmap",
|
||||
sessionId: null,
|
||||
},
|
||||
workspaceId: null,
|
||||
source: {
|
||||
channel: "onlyoffice-asset-to-mindmap",
|
||||
client: "wolai-frontend",
|
||||
},
|
||||
});
|
||||
|
||||
const asset_extract_outline = async (toolArgs: Record<string, unknown>) => {
|
||||
const assetId = String(toolArgs.assetId ?? "").trim();
|
||||
const attachmentRef = String(toolArgs.attachmentRef ?? "").trim();
|
||||
@@ -308,11 +325,13 @@ export const createOnlyOfficeServerTools = (args: {
|
||||
};
|
||||
|
||||
const asset_to_mindmap = async (toolArgs: Record<string, unknown>) => {
|
||||
// 说明:附件大纲提取仍在 TS/MinerU 侧,真正的导图写入由 Rust mindmap_apply_ops 负责。
|
||||
const mindmapId = String(toolArgs.mindmapId ?? "").trim();
|
||||
if (!mindmapId) throw new Error("缺少 mindmapId");
|
||||
const parentUidArg = String(toolArgs.parentUid ?? "").trim();
|
||||
const maxItemsRaw = Number(toolArgs.maxItems ?? 120);
|
||||
const maxItems = Number.isFinite(maxItemsRaw) ? Math.max(10, Math.min(600, Math.floor(maxItemsRaw))) : 120;
|
||||
const reason = String(toolArgs.reason ?? "").trim() || null;
|
||||
|
||||
const documentId = String(args.ctx.documentId ?? "").trim();
|
||||
if (!documentId) throw new Error("缺少 documentId 上下文(OnlyOffice 工具需要落盘到指定文档)");
|
||||
@@ -356,8 +375,39 @@ export const createOnlyOfficeServerTools = (args: {
|
||||
if (!findNodeByUid(base, parentUid)) throw new Error("未找到 parentUid 对应节点");
|
||||
|
||||
const ops = buildOutlineOps({ parentUid, items, attachment });
|
||||
const { data: nextData, applied, errors } = applyMindmapOps(base, ops);
|
||||
await writeMindmapLocal(documentId, mindmapId, nextData, "OnlyOffice 生成导图");
|
||||
|
||||
const rustContext = buildRustContext();
|
||||
const rustResult = await executeRustBridgeTool<{
|
||||
ok: boolean;
|
||||
applied?: number;
|
||||
errors?: string[];
|
||||
data?: unknown;
|
||||
meta?: { reason?: string | null } | null;
|
||||
}>({
|
||||
context: rustContext,
|
||||
toolName: "mindmap_apply_ops",
|
||||
invocationKind: "command",
|
||||
args: {
|
||||
ops,
|
||||
reason,
|
||||
},
|
||||
data: base,
|
||||
target: {
|
||||
pageId: documentId,
|
||||
workspaceId: null,
|
||||
blockId: mindmapId,
|
||||
},
|
||||
reason,
|
||||
});
|
||||
|
||||
const resultData = rustResult.result && typeof rustResult.result === "object" ? (rustResult.result as Record<string, unknown>).data : null;
|
||||
const nextData = resultData && typeof resultData === "object" && !Array.isArray(resultData) ? (resultData as MindmapTreeNode) : base;
|
||||
await writeMindmapLocal(documentId, mindmapId, nextData, "OnlyOffice 生成导图(Rust)");
|
||||
|
||||
const applied = Number((rustResult.result as Record<string, unknown> | undefined)?.applied ?? ops.length) || 0;
|
||||
const errors = Array.isArray((rustResult.result as Record<string, unknown> | undefined)?.errors)
|
||||
? ((rustResult.result as Record<string, unknown>).errors as string[])
|
||||
: [];
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
@@ -371,6 +421,8 @@ export const createOnlyOfficeServerTools = (args: {
|
||||
fileName: attachment.title,
|
||||
items: items.length,
|
||||
strategy: String(outlineResult.strategy ?? ""),
|
||||
rustOwner: "mindmap_apply_ops",
|
||||
rustReason: reason,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// 遗留兼容:builtin 声明仍保留给历史兼容与文档对照,前端主链不再从这里继续扩展 Hermes 前置编排。
|
||||
import type { AiAgentTool, AiAgentToolSet } from "../types";
|
||||
|
||||
export const builtinTools: AiAgentTool[] = [
|
||||
@@ -409,6 +410,18 @@ export type BuiltinRustCutoverBinding = {
|
||||
* 完整的一一对应矩阵见 `design/ai-tool-cutover-matrix.md`。
|
||||
*/
|
||||
export const builtinRustCutoverBindings: Record<string, BuiltinRustCutoverBinding> = {
|
||||
docs_search: {
|
||||
rustToolsetId: "toolset.docs_read",
|
||||
rustToolName: "docs_search",
|
||||
status: "rust",
|
||||
note: "跨页文档搜索已切到 Rust runtime,TS 仅负责拉取搜索数据集 transport。",
|
||||
},
|
||||
docs_read: {
|
||||
rustToolsetId: "toolset.docs_read",
|
||||
rustToolName: "docs_read",
|
||||
status: "rust",
|
||||
note: "跨页文档读取结果已由 Rust runtime 统一裁剪与归一化,TS 仅负责读取目标文档 transport。",
|
||||
},
|
||||
search_web: {
|
||||
rustToolsetId: "toolset.readonly",
|
||||
rustToolName: "search_web",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// 遗留兼容:当前 AI 主链已转到 Hermes bridge,这里的 registry 仅保留给历史测试/兼容调用,不再作为前端主编排入口。
|
||||
import type { AiAgentTool, AiAgentToolSet, ToolPermissions } from "./types";
|
||||
|
||||
export type ToolRegistry = {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { api } from "@/lib/convex/api";
|
||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||
import {
|
||||
@@ -18,7 +17,7 @@ import {
|
||||
resolveRustBridgeCommandPlan,
|
||||
resolveRustBridgeQueryPlan,
|
||||
} from "@/lib/documents/rust-runtime";
|
||||
import { composeContentWithBlocks, extractBlocksFromContent } from "@/lib/document-content";
|
||||
import { extractBlocksFromContent } from "@/lib/document-content";
|
||||
|
||||
type BlockLike = {
|
||||
id: string;
|
||||
@@ -91,20 +90,6 @@ function replaceBlockInTree(blocks: BlockLike[], blockId: string, nextBlock: unk
|
||||
return { ok: true, nextBlocks: nextTop };
|
||||
}
|
||||
|
||||
function buildReferenceBlock(sourceDocumentId: string, blockId: string): BlockLike {
|
||||
return {
|
||||
id: randomUUID(),
|
||||
type: "blockReference",
|
||||
props: {
|
||||
sourceDocumentId,
|
||||
targetBlockId: blockId,
|
||||
display: "embed",
|
||||
},
|
||||
content: [],
|
||||
children: [],
|
||||
};
|
||||
}
|
||||
|
||||
async function buildBridgeContext(request: Request, workspaceId: string | null): Promise<BridgeContext> {
|
||||
return await buildDocumentBridgeContext({ request, workspaceId });
|
||||
}
|
||||
@@ -183,10 +168,6 @@ export async function executeBlockPatchBridgeCommand(input: {
|
||||
client,
|
||||
plan,
|
||||
});
|
||||
await client.mutation(api.documents.updateContent, {
|
||||
id: input.sourceDocumentId,
|
||||
content: composeContentWithBlocks(doc.content, replaced.nextBlocks as never),
|
||||
});
|
||||
await recordBridgeCommandArtifacts({ context, envelope });
|
||||
} catch (error) {
|
||||
await recordBridgeCommandFailureArtifacts({
|
||||
@@ -220,9 +201,6 @@ export async function executeBlockMoveBridgeCommand(input: {
|
||||
const sourceBlocks = extractBlocksFromContent(source.content) as BlockLike[];
|
||||
const removedRes = removeBlockSubtree(sourceBlocks, input.blockId);
|
||||
if (!removedRes.removed) throw new Error("源块不存在或无权限");
|
||||
const targetBlocks = extractBlocksFromContent(target.content) as BlockLike[];
|
||||
const nextSourceContent = composeContentWithBlocks(source.content, removedRes.nextBlocks as never);
|
||||
const nextTargetContent = composeContentWithBlocks(target.content, [...targetBlocks, removedRes.removed] as never);
|
||||
const context = await buildBridgeContext(input.request, null);
|
||||
const envelope = buildDocumentCommandEnvelope({
|
||||
name: "blocks.move",
|
||||
@@ -240,8 +218,6 @@ export async function executeBlockMoveBridgeCommand(input: {
|
||||
const plan = await resolveRustBridgeCommandPlan({ context, envelope });
|
||||
try {
|
||||
await executeRustBridgeMutationTransport({ client, plan });
|
||||
await client.mutation(api.documents.updateContent, { id: input.sourceDocumentId, content: nextSourceContent });
|
||||
await client.mutation(api.documents.updateContent, { id: input.targetDocumentId, content: nextTargetContent });
|
||||
await recordBridgeCommandArtifacts({ context, envelope });
|
||||
} catch (error) {
|
||||
await recordBridgeCommandFailureArtifacts({
|
||||
@@ -276,18 +252,14 @@ export async function executeBlockEmbedBridgeCommand(input: {
|
||||
const sourceBlocks = extractBlocksFromContent(source.content) as BlockLike[];
|
||||
const hit = findBlockInTree(sourceBlocks, input.blockId);
|
||||
if (!hit) throw new Error("源块不存在或无权限");
|
||||
const targetBlocks = extractBlocksFromContent(target.content) as BlockLike[];
|
||||
const anchorId = (targetMeta as { embed_default_block_id?: string | null } | null)?.embed_default_block_id ?? null;
|
||||
const anchorIndex =
|
||||
typeof anchorId === "string" && anchorId.trim()
|
||||
? targetBlocks.findIndex((block) => String(block.id ?? "") === anchorId)
|
||||
? extractBlocksFromContent(target.content).findIndex((block) => String((block as BlockLike).id ?? "") === anchorId)
|
||||
: -1;
|
||||
const insertIndex = anchorIndex >= 0 ? anchorIndex + 1 : targetBlocks.length;
|
||||
const nextTargetBlocks = [
|
||||
...targetBlocks.slice(0, insertIndex),
|
||||
buildReferenceBlock(input.sourceDocumentId, input.blockId),
|
||||
...targetBlocks.slice(insertIndex),
|
||||
];
|
||||
const targetBlocks = extractBlocksFromContent(target.content) as BlockLike[];
|
||||
void targetBlocks;
|
||||
void anchorIndex;
|
||||
const context = await buildBridgeContext(input.request, null);
|
||||
const envelope = buildDocumentCommandEnvelope({
|
||||
name: "blocks.embed",
|
||||
@@ -305,10 +277,6 @@ export async function executeBlockEmbedBridgeCommand(input: {
|
||||
const plan = await resolveRustBridgeCommandPlan({ context, envelope });
|
||||
try {
|
||||
await executeRustBridgeMutationTransport({ client, plan });
|
||||
await client.mutation(api.documents.updateContent, {
|
||||
id: input.targetDocumentId,
|
||||
content: composeContentWithBlocks(target.content, nextTargetBlocks as never),
|
||||
});
|
||||
await recordBridgeCommandArtifacts({ context, envelope });
|
||||
} catch (error) {
|
||||
await recordBridgeCommandFailureArtifacts({
|
||||
|
||||
@@ -12,16 +12,74 @@ type DocumentMetaResponse<T> = {
|
||||
meta: BridgeMeta;
|
||||
};
|
||||
|
||||
function getServerRequestOrigin(headerList: Headers): string {
|
||||
const forwardedProto = headerList.get("x-forwarded-proto")?.split(",")[0]?.trim();
|
||||
const forwardedHost = headerList.get("x-forwarded-host")?.split(",")[0]?.trim();
|
||||
const host = forwardedHost || headerList.get("host");
|
||||
function getHeaderFirstValue(value: string | null): string {
|
||||
return String(value || "")
|
||||
.split(",")[0]
|
||||
.trim();
|
||||
}
|
||||
|
||||
function parseHostParts(host: string): { hostname: string; port: string } {
|
||||
try {
|
||||
const parsed = new URL(`http://${host}`);
|
||||
return {
|
||||
hostname: parsed.hostname,
|
||||
port: parsed.port,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
hostname: host,
|
||||
port: "",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function isLocalHostname(hostname: string): boolean {
|
||||
const normalized = hostname.replace(/^\[(.*)\]$/, "$1").trim().toLowerCase();
|
||||
return (
|
||||
normalized === "localhost" ||
|
||||
normalized === "127.0.0.1" ||
|
||||
normalized === "::1" ||
|
||||
normalized === "0.0.0.0" ||
|
||||
normalized === "::"
|
||||
);
|
||||
}
|
||||
|
||||
function buildServerRequestOriginCandidates(headerList: Headers): string[] {
|
||||
const forwardedProto = getHeaderFirstValue(headerList.get("x-forwarded-proto"));
|
||||
const forwardedHost = getHeaderFirstValue(headerList.get("x-forwarded-host"));
|
||||
const host = forwardedHost || getHeaderFirstValue(headerList.get("host"));
|
||||
|
||||
if (!host) {
|
||||
throw new Error("缺少 host 头,无法构造 bridge 请求地址");
|
||||
}
|
||||
|
||||
return `${forwardedProto || "http"}://${host}`;
|
||||
const { hostname, port } = parseHostParts(host);
|
||||
const candidates = new Set<string>();
|
||||
const addCandidate = (proto: string, candidateHost: string) => {
|
||||
const normalizedProto = String(proto || "http").trim().toLowerCase() || "http";
|
||||
const normalizedHost = String(candidateHost || "").trim();
|
||||
if (!normalizedHost) return;
|
||||
candidates.add(`${normalizedProto}://${normalizedHost}`);
|
||||
};
|
||||
|
||||
// 说明:优先保留浏览器实际请求的 origin,用于正常的同源自调用。
|
||||
addCandidate(forwardedProto || "http", host);
|
||||
|
||||
// 说明:某些反代会把 x-forwarded-proto 设成 https,但本机 dev server 实际只监听 http。
|
||||
// 这里补一个 http 候选,避免 SSR 自请求被错误协议直接打挂。
|
||||
if ((forwardedProto || "").trim().toLowerCase() === "https") {
|
||||
addCandidate("http", host);
|
||||
}
|
||||
|
||||
// 说明:localhost / 0.0.0.0 / ::1 这类本机地址在 Node 侧自调用时最容易踩解析差异,
|
||||
// 统一补 127.0.0.1 与 localhost 两个稳定候选,避免 fetch 因回环地址选择失败。
|
||||
if (isLocalHostname(hostname)) {
|
||||
const portSuffix = port ? `:${port}` : "";
|
||||
addCandidate("http", `127.0.0.1${portSuffix}`);
|
||||
addCandidate("http", `localhost${portSuffix}`);
|
||||
}
|
||||
|
||||
return [...candidates];
|
||||
}
|
||||
|
||||
function copyHeaderIfPresent(target: Headers, source: Headers, name: string) {
|
||||
@@ -31,19 +89,21 @@ function copyHeaderIfPresent(target: Headers, source: Headers, name: string) {
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchDocumentMetaResponse(url: URL, headersToSend: Headers): Promise<Response> {
|
||||
return await fetch(url, {
|
||||
method: "GET",
|
||||
headers: headersToSend,
|
||||
cache: "no-store",
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchDocumentMetaViaBridge<T>(input: {
|
||||
documentId: string;
|
||||
workspaceId?: string | null;
|
||||
}): Promise<DocumentMetaResponse<T> | null> {
|
||||
const headerList = await headers();
|
||||
const requestHeaders = new Headers();
|
||||
const origin = getServerRequestOrigin(headerList);
|
||||
const url = new URL("/api/documents/meta", origin);
|
||||
|
||||
url.searchParams.set("documentId", input.documentId);
|
||||
if (input.workspaceId?.trim()) {
|
||||
url.searchParams.set("workspaceId", input.workspaceId.trim());
|
||||
}
|
||||
const originCandidates = buildServerRequestOriginCandidates(headerList);
|
||||
|
||||
copyHeaderIfPresent(requestHeaders, headerList, "cookie");
|
||||
copyHeaderIfPresent(requestHeaders, headerList, "authorization");
|
||||
@@ -54,11 +114,34 @@ export async function fetchDocumentMetaViaBridge<T>(input: {
|
||||
copyHeaderIfPresent(requestHeaders, headerList, "x-source-client");
|
||||
copyHeaderIfPresent(requestHeaders, headerList, "user-agent");
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: requestHeaders,
|
||||
cache: "no-store",
|
||||
});
|
||||
let response: Response | null = null;
|
||||
let lastError: unknown = null;
|
||||
const errorMessages: string[] = [];
|
||||
|
||||
for (const origin of originCandidates) {
|
||||
const url = new URL("/api/documents/meta", origin);
|
||||
url.searchParams.set("documentId", input.documentId);
|
||||
if (input.workspaceId?.trim()) {
|
||||
url.searchParams.set("workspaceId", input.workspaceId.trim());
|
||||
}
|
||||
|
||||
try {
|
||||
response = await fetchDocumentMetaResponse(url, requestHeaders);
|
||||
break;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
errorMessages.push(`${url.toString()} => ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!response) {
|
||||
const message =
|
||||
errorMessages.length > 0
|
||||
? `bridge 自请求失败:${errorMessages.join(" | ")}`
|
||||
: "bridge 自请求失败:未生成可用的请求地址";
|
||||
throw new Error(message, { cause: lastError instanceof Error ? lastError : undefined });
|
||||
}
|
||||
|
||||
if (response.status === 404) {
|
||||
return null;
|
||||
|
||||
@@ -123,6 +123,9 @@ const DOCUMENT_BRIDGE_MUTATION_FUNCTIONS = {
|
||||
"documents.restore": "documents:restore",
|
||||
"documents.duplicate": "documents:duplicateWithMindmaps",
|
||||
"documents.copy_tree": "documents:copyTree",
|
||||
"documents.template": "documents:setTemplate",
|
||||
"documents.emptyTrashByWorkspace": "documents:emptyTrashByWorkspace",
|
||||
"documents.purge": "documents:purge",
|
||||
"blocks.patch": "documents:updateContent",
|
||||
"blocks.move": "documents:updateContent",
|
||||
"blocks.embed": "documents:updateContent",
|
||||
@@ -130,6 +133,10 @@ const DOCUMENT_BRIDGE_MUTATION_FUNCTIONS = {
|
||||
"documents.stats.update": "documents:updateStats",
|
||||
"documents.options.update": "documents:updateOptions",
|
||||
"documents.save": "documents:updateContent",
|
||||
"mindmaps.delete": "mindmaps:softDelete",
|
||||
"mindmaps.restore": "mindmaps:restore",
|
||||
"mindmaps.purge": "mindmaps:purge",
|
||||
"mindmaps.emptyTrashByWorkspace": "mindmaps:emptyTrashByWorkspace",
|
||||
"media.assets.replace_storage": "mediaAssets:replaceStorageFromUpload",
|
||||
} as const satisfies Record<string, string>;
|
||||
|
||||
|
||||
@@ -20,6 +20,8 @@ import {
|
||||
executeRustBridgeMutationTransport,
|
||||
resolveRustBridgeCommandPlan,
|
||||
} from "@/lib/documents/rust-runtime";
|
||||
import { executeSaveBridgeCommand } from "@/lib/documents/save-command-adapter";
|
||||
import { buildDocumentSavePayload } from "@/lib/documents/save-contract";
|
||||
import type { Json } from "@/types/supabase";
|
||||
|
||||
export type DocumentCreatePayload = {
|
||||
@@ -64,6 +66,19 @@ export type DocumentCopyTreePayload = {
|
||||
}>;
|
||||
};
|
||||
|
||||
export type DocumentTemplatePayload = {
|
||||
documentId: string;
|
||||
isTemplate: boolean;
|
||||
};
|
||||
|
||||
export type DocumentEmptyTrashPayload = {
|
||||
workspaceId: string;
|
||||
};
|
||||
|
||||
export type DocumentPurgePayload = {
|
||||
documentId: string;
|
||||
};
|
||||
|
||||
export type PageCommandExecutionResult<TResult> = {
|
||||
requestId: string;
|
||||
traceId: string;
|
||||
@@ -103,52 +118,37 @@ async function buildRuntimeContext(request: Request, workspaceId: string | null)
|
||||
});
|
||||
}
|
||||
|
||||
async function recordLifecycleArtifacts<T>(input: {
|
||||
context: BridgeContext;
|
||||
envelope: CommandEnvelope<T>;
|
||||
client: ConvexHttpClient;
|
||||
}) {
|
||||
await recordBridgeCommandArtifacts({
|
||||
context: input.context,
|
||||
envelope: input.envelope,
|
||||
client: input.client,
|
||||
});
|
||||
}
|
||||
|
||||
async function recordLifecycleFailureArtifacts<T>(input: {
|
||||
context: BridgeContext;
|
||||
envelope: CommandEnvelope<T>;
|
||||
client: ConvexHttpClient;
|
||||
error: unknown;
|
||||
}) {
|
||||
await recordBridgeCommandFailureArtifacts({
|
||||
context: input.context,
|
||||
envelope: input.envelope,
|
||||
client: input.client,
|
||||
error: input.error,
|
||||
});
|
||||
}
|
||||
|
||||
export async function executePageLifecycleBridgeCommand<TPayload, TResult>(input: {
|
||||
context: BridgeContext;
|
||||
envelope: CommandEnvelope<TPayload>;
|
||||
client?: ConvexHttpClient;
|
||||
}): Promise<PageCommandExecutionResult<TResult>> {
|
||||
const client = input.client ?? (await getAuthedConvexClient()).client;
|
||||
const plan = await resolveRustBridgeCommandPlan({
|
||||
context: input.context,
|
||||
envelope: input.envelope,
|
||||
});
|
||||
const result = await executeRustBridgeMutationTransport<TResult>({
|
||||
client,
|
||||
plan,
|
||||
});
|
||||
let result;
|
||||
try {
|
||||
const plan = await resolveRustBridgeCommandPlan({
|
||||
context: input.context,
|
||||
envelope: input.envelope,
|
||||
});
|
||||
result = await executeRustBridgeMutationTransport<TResult>({
|
||||
client,
|
||||
plan,
|
||||
});
|
||||
|
||||
await recordBridgeCommandArtifacts({
|
||||
context: input.context,
|
||||
envelope: input.envelope,
|
||||
client,
|
||||
});
|
||||
await recordBridgeCommandArtifacts({
|
||||
context: input.context,
|
||||
envelope: input.envelope,
|
||||
client,
|
||||
});
|
||||
} catch (error) {
|
||||
await recordBridgeCommandFailureArtifacts({
|
||||
context: input.context,
|
||||
envelope: input.envelope,
|
||||
client,
|
||||
error,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
|
||||
return {
|
||||
requestId: input.context.requestId,
|
||||
@@ -200,7 +200,7 @@ export async function executeDocumentCreateChildBridgeCommand(request: Request):
|
||||
const pageId = safeRandomId();
|
||||
const context = await buildRuntimeContext(request, workspaceId);
|
||||
const envelope = buildDocumentCommandEnvelope({
|
||||
name: "documents.createChild",
|
||||
name: "documents.create",
|
||||
payload: {
|
||||
documentId: pageId,
|
||||
workspaceId,
|
||||
@@ -216,40 +216,23 @@ export async function executeDocumentCreateChildBridgeCommand(request: Request):
|
||||
},
|
||||
});
|
||||
|
||||
let created;
|
||||
try {
|
||||
created = await client.mutation(api.documents.create, {
|
||||
id: pageId,
|
||||
workspaceId,
|
||||
parentId,
|
||||
title: resolvedTitle,
|
||||
accessScope,
|
||||
content: contentPayload,
|
||||
});
|
||||
|
||||
await recordLifecycleArtifacts({
|
||||
context,
|
||||
envelope,
|
||||
client,
|
||||
});
|
||||
} catch (error) {
|
||||
await recordLifecycleFailureArtifacts({
|
||||
context,
|
||||
envelope,
|
||||
client,
|
||||
error,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
const created = await executePageLifecycleBridgeCommand<DocumentCreatePayload, {
|
||||
id: string;
|
||||
title?: string | null;
|
||||
}>({
|
||||
context,
|
||||
envelope,
|
||||
client,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
pageId: created.id,
|
||||
title: created.title ?? resolvedTitle,
|
||||
pageId: created.result.id,
|
||||
title: created.result.title ?? resolvedTitle,
|
||||
meta: {
|
||||
requestId: context.requestId,
|
||||
traceId: context.traceId,
|
||||
commandId: envelope.commandId,
|
||||
commandName: envelope.name,
|
||||
requestId: created.requestId,
|
||||
traceId: created.traceId,
|
||||
commandId: created.commandId,
|
||||
commandName: created.commandName,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -304,12 +287,23 @@ export async function executeDocumentEmbedBridgeCommand(request: Request): Promi
|
||||
const payload: Json = composeContentWithBlocks(targetContent.content, nextBlocks);
|
||||
const workspaceId = normalizeWorkspaceId(sourceDoc.workspace_id) ?? normalizeWorkspaceId((targetMeta as { workspace_id?: string | null } | null)?.workspace_id);
|
||||
const context = await buildRuntimeContext(request, workspaceId);
|
||||
const savePayload = buildDocumentSavePayload({
|
||||
documentId: normalizedTargetId,
|
||||
workspaceId,
|
||||
revision:
|
||||
typeof targetContent.revision === "number" && Number.isInteger(targetContent.revision)
|
||||
? targetContent.revision
|
||||
: null,
|
||||
content: payload,
|
||||
conflictDetectionKey:
|
||||
typeof targetContent.conflict_detection_key === "string"
|
||||
? targetContent.conflict_detection_key
|
||||
: null,
|
||||
blockCount: nextBlocks.length,
|
||||
});
|
||||
const envelope = buildDocumentCommandEnvelope({
|
||||
name: "documents.embed",
|
||||
payload: {
|
||||
sourceId: normalizedSourceId,
|
||||
targetId: normalizedTargetId,
|
||||
},
|
||||
name: "documents.save",
|
||||
payload: savePayload,
|
||||
context,
|
||||
target: {
|
||||
workspaceId,
|
||||
@@ -317,34 +311,18 @@ export async function executeDocumentEmbedBridgeCommand(request: Request): Promi
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await client.mutation(api.documents.updateContent, {
|
||||
id: normalizedTargetId,
|
||||
content: payload,
|
||||
});
|
||||
|
||||
await recordLifecycleArtifacts({
|
||||
context,
|
||||
envelope,
|
||||
client,
|
||||
});
|
||||
} catch (error) {
|
||||
await recordLifecycleFailureArtifacts({
|
||||
context,
|
||||
envelope,
|
||||
client,
|
||||
error,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
const result = await executeSaveBridgeCommand({
|
||||
context,
|
||||
envelope,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
meta: {
|
||||
requestId: context.requestId,
|
||||
traceId: context.traceId,
|
||||
commandId: envelope.commandId,
|
||||
commandName: envelope.name,
|
||||
requestId: result.requestId,
|
||||
traceId: result.traceId,
|
||||
commandId: result.commandId,
|
||||
commandName: result.commandName,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -384,34 +362,22 @@ export async function executeDocumentTemplateBridgeCommand(request: Request): Pr
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await client.mutation(api.documents.setTemplate, {
|
||||
id: normalizedDocumentId,
|
||||
isTemplate: payload.isTemplate,
|
||||
});
|
||||
|
||||
await recordLifecycleArtifacts({
|
||||
context,
|
||||
envelope,
|
||||
client,
|
||||
});
|
||||
} catch (error) {
|
||||
await recordLifecycleFailureArtifacts({
|
||||
context,
|
||||
envelope,
|
||||
client,
|
||||
error,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
const result = await executePageLifecycleBridgeCommand<DocumentTemplatePayload, {
|
||||
ok?: boolean;
|
||||
updated_at?: string | null;
|
||||
}>({
|
||||
context,
|
||||
envelope,
|
||||
client,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
meta: {
|
||||
requestId: context.requestId,
|
||||
traceId: context.traceId,
|
||||
commandId: envelope.commandId,
|
||||
commandName: envelope.name,
|
||||
requestId: result.requestId,
|
||||
traceId: result.traceId,
|
||||
commandId: result.commandId,
|
||||
commandName: result.commandName,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -442,31 +408,23 @@ export async function executeDocumentEmptyTrashBridgeCommand(request: Request):
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await client.mutation(api.documents.emptyTrashByWorkspace, { workspaceId });
|
||||
|
||||
await recordLifecycleArtifacts({
|
||||
context,
|
||||
envelope,
|
||||
client,
|
||||
});
|
||||
} catch (error) {
|
||||
await recordLifecycleFailureArtifacts({
|
||||
context,
|
||||
envelope,
|
||||
client,
|
||||
error,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
const result = await executePageLifecycleBridgeCommand<DocumentEmptyTrashPayload, {
|
||||
ok?: boolean;
|
||||
deletedCount?: number;
|
||||
}>({
|
||||
context,
|
||||
envelope,
|
||||
client,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
removed: typeof result.result?.deletedCount === "number" ? result.result.deletedCount : 0,
|
||||
meta: {
|
||||
requestId: context.requestId,
|
||||
traceId: context.traceId,
|
||||
commandId: envelope.commandId,
|
||||
commandName: envelope.name,
|
||||
requestId: result.requestId,
|
||||
traceId: result.traceId,
|
||||
commandId: result.commandId,
|
||||
commandName: result.commandName,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -501,31 +459,24 @@ export async function executeDocumentPurgeBridgeCommand(request: Request): Promi
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await client.mutation(api.documents.purge, { id: normalizedDocumentId });
|
||||
|
||||
await recordLifecycleArtifacts({
|
||||
context,
|
||||
envelope,
|
||||
client,
|
||||
});
|
||||
} catch (error) {
|
||||
await recordLifecycleFailureArtifacts({
|
||||
context,
|
||||
envelope,
|
||||
client,
|
||||
error,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
const result = await executePageLifecycleBridgeCommand<DocumentPurgePayload, {
|
||||
ok?: boolean;
|
||||
purged?: boolean;
|
||||
purged_at?: string | null;
|
||||
}>({
|
||||
context,
|
||||
envelope,
|
||||
client,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
purged: result.result?.purged ?? true,
|
||||
meta: {
|
||||
requestId: context.requestId,
|
||||
traceId: context.traceId,
|
||||
commandId: envelope.commandId,
|
||||
commandName: envelope.name,
|
||||
requestId: result.requestId,
|
||||
traceId: result.traceId,
|
||||
commandId: result.commandId,
|
||||
commandName: result.commandName,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -606,6 +606,19 @@ export async function executeRustBridgeMutationTransport<TResult>(input: {
|
||||
newId: assertStringArg(input.plan.argsJson, "newId"),
|
||||
title: readOptionalStringArg(input.plan.argsJson, "title"),
|
||||
});
|
||||
case "documents:setTemplate":
|
||||
return mutation(api.documents.setTemplate, {
|
||||
id: assertStringArg(input.plan.argsJson, "id"),
|
||||
isTemplate: Boolean(input.plan.argsJson.isTemplate),
|
||||
});
|
||||
case "documents:emptyTrashByWorkspace":
|
||||
return mutation(api.documents.emptyTrashByWorkspace, {
|
||||
workspaceId: assertStringArg(input.plan.argsJson, "workspaceId"),
|
||||
});
|
||||
case "documents:purge":
|
||||
return mutation(api.documents.purge, {
|
||||
id: assertStringArg(input.plan.argsJson, "id"),
|
||||
});
|
||||
case "documents:updateTitle":
|
||||
return mutation(api.documents.updateTitle, {
|
||||
id: assertStringArg(input.plan.argsJson, "id"),
|
||||
@@ -628,6 +641,25 @@ export async function executeRustBridgeMutationTransport<TResult>(input: {
|
||||
? input.plan.argsJson.createOnly
|
||||
: undefined,
|
||||
});
|
||||
case "mindmaps:softDelete":
|
||||
return mutation(api.mindmaps.softDelete, {
|
||||
docId: assertStringArg(input.plan.argsJson, "docId"),
|
||||
mindmapId: assertStringArg(input.plan.argsJson, "mindmapId"),
|
||||
});
|
||||
case "mindmaps:restore":
|
||||
return mutation(api.mindmaps.restore, {
|
||||
docId: assertStringArg(input.plan.argsJson, "docId"),
|
||||
mindmapId: assertStringArg(input.plan.argsJson, "mindmapId"),
|
||||
});
|
||||
case "mindmaps:purge":
|
||||
return mutation(api.mindmaps.purge, {
|
||||
docId: assertStringArg(input.plan.argsJson, "docId"),
|
||||
mindmapId: assertStringArg(input.plan.argsJson, "mindmapId"),
|
||||
});
|
||||
case "mindmaps:emptyTrashByWorkspace":
|
||||
return mutation(api.mindmaps.emptyTrashByWorkspace, {
|
||||
workspaceId: assertStringArg(input.plan.argsJson, "workspaceId"),
|
||||
});
|
||||
default:
|
||||
throw new DocumentBridgeError(
|
||||
`未注册的 Rust mutation transport: ${input.plan.functionName}`,
|
||||
|
||||
Reference in New Issue
Block a user