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:
lix-2026
2026-04-16 15:24:37 +08:00
parent 98db79b301
commit 2ff10fa86c
47 changed files with 6494 additions and 2674 deletions
@@ -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 = {