chore: 收口 review 执行清单与 runtime 验证

- 补齐 design/10-review 执行清单、验收标准与相关设计治理记录

- 迁移已完成的 tree、mindmap、runtime fallback、AI kernel 等设计和缺陷条目

- 推进 Rust Web runtime、tree/sidebar、page aggregate、mindmap 与 OnlyOffice 路由侧验证支撑

- 增加 task177-task180 smoke/audit 脚本及前端相关测试覆盖
This commit is contained in:
lix-2026
2026-05-14 05:52:08 +08:00
parent b4a452a8b7
commit 96e03645f7
69 changed files with 4780 additions and 979 deletions
+167 -32
View File
@@ -38,6 +38,10 @@ function decodeCursor(raw: string | null | undefined) {
}
}
function stripDomainEventCursorPrefix(id: string) {
return id.startsWith("domain_event:") ? id.slice("domain_event:".length) : id;
}
function encodeCursor(row: { created_at?: string | null; id?: string | null } | null) {
if (!row?.created_at || !row?.id) return null;
return JSON.stringify({
@@ -46,6 +50,25 @@ function encodeCursor(row: { created_at?: string | null; id?: string | null } |
});
}
function encodeDomainEventCursor(row: { created_at?: string | null; id?: string | null } | null) {
if (!row?.created_at || !row?.id) return null;
return JSON.stringify({
createdAt: row.created_at,
id: `domain_event:${row.id}`,
});
}
function encodeOverviewNextCursor(
commandLogs: Array<{ created_at?: string | null; id?: string | null }>,
domainEvents: Array<{ created_at?: string | null; id?: string | null }>,
) {
// overview 的分页主轴仍以 command log 为准;没有 command log 时才回退到 domain event cursor。
// 实时流的 live tail 每轮查最新窗口,不依赖这个 next_cursor。
const oldestCommand = commandLogs[commandLogs.length - 1] ?? null;
const oldestEvent = domainEvents[domainEvents.length - 1] ?? null;
return encodeCursor(oldestCommand) ?? encodeDomainEventCursor(oldestEvent);
}
function matchesCursor<T extends Record<string, any>>(
row: T,
cursor: { createdAt: string; id: string } | null,
@@ -53,10 +76,11 @@ function matchesCursor<T extends Record<string, any>>(
if (!cursor) return true;
const createdAt = String(row.created_at ?? row.finished_at ?? "");
const id = String(row.id ?? "");
const cursorId = stripDomainEventCursorPrefix(cursor.id);
if (!createdAt || !id) return false;
if (createdAt < cursor.createdAt) return true;
if (createdAt > cursor.createdAt) return false;
return id < cursor.id;
return id < cursorId;
}
function normalizeStatusFilter(raw: string | null | undefined) {
@@ -69,6 +93,128 @@ function normalizeObjectFilter(raw: string | null | undefined) {
return normalized.length > 0 ? normalized : null;
}
type Cursor = { createdAt: string; id: string } | null;
const OVERVIEW_SCAN_MULTIPLIER = 4;
function overviewScanLimit(limit: number) {
return Math.min(500, Math.max(limit + 1, limit * OVERVIEW_SCAN_MULTIPLIER));
}
function applyCursorUpperBound(query: any, cursor: Cursor) {
return cursor ? query.lte("created_at", cursor.createdAt) : query;
}
async function fetchCommandLogWindow(ctx: any, args: {
workspaceId: string;
limit: number;
cursor: Cursor;
commandStatus: string | null;
targetPageId: string | null;
targetBlockId: string | null;
}) {
const scanLimit = overviewScanLimit(args.limit);
let query;
if (args.targetBlockId) {
query = ctx.db
.query("command_logs")
.withIndex("by_workspace_target_block_created_at", (q: any) =>
applyCursorUpperBound(
q.eq("workspace_id", args.workspaceId).eq("target_block_id", args.targetBlockId),
args.cursor,
),
);
} else if (args.targetPageId) {
query = ctx.db
.query("command_logs")
.withIndex("by_workspace_target_page_created_at", (q: any) =>
applyCursorUpperBound(
q.eq("workspace_id", args.workspaceId).eq("target_page_id", args.targetPageId),
args.cursor,
),
);
} else if (args.commandStatus) {
query = ctx.db
.query("command_logs")
.withIndex("by_workspace_status_created_at", (q: any) =>
applyCursorUpperBound(
q.eq("workspace_id", args.workspaceId).eq("status", args.commandStatus),
args.cursor,
),
);
} else {
query = ctx.db
.query("command_logs")
.withIndex("by_workspace_created_at", (q: any) =>
applyCursorUpperBound(q.eq("workspace_id", args.workspaceId), args.cursor),
);
}
const rows = await query.order("desc").take(scanLimit);
const filteredRows = rows.filter((row: any) => {
if (args.commandStatus && row.status !== args.commandStatus) return false;
if (args.targetPageId && String(row.target_page_id ?? "") !== args.targetPageId) return false;
if (args.targetBlockId && String(row.target_block_id ?? "") !== args.targetBlockId) return false;
return matchesCursor(row, args.cursor);
});
return {
rows: filteredRows.slice(0, args.limit),
hasMore: filteredRows.length > args.limit || rows.length === scanLimit,
};
}
async function fetchDomainEventWindow(ctx: any, args: {
workspaceId: string;
limit: number;
cursor: Cursor;
eventStatus: string | null;
aggregateType: string | null;
aggregateId: string | null;
}) {
const scanLimit = overviewScanLimit(args.limit);
let query;
if (args.aggregateType && args.aggregateId) {
query = ctx.db
.query("domain_events")
.withIndex("by_workspace_aggregate_created_at", (q: any) =>
applyCursorUpperBound(
q
.eq("workspace_id", args.workspaceId)
.eq("aggregate_type", args.aggregateType)
.eq("aggregate_id", args.aggregateId),
args.cursor,
),
);
} else if (args.eventStatus) {
query = ctx.db
.query("domain_events")
.withIndex("by_workspace_status_created_at", (q: any) =>
applyCursorUpperBound(
q.eq("workspace_id", args.workspaceId).eq("status", args.eventStatus),
args.cursor,
),
);
} else {
query = ctx.db
.query("domain_events")
.withIndex("by_workspace_created_at", (q: any) =>
applyCursorUpperBound(q.eq("workspace_id", args.workspaceId), args.cursor),
);
}
const rows = await query.order("desc").take(scanLimit);
const filteredRows = rows.filter((row: any) => {
if (args.eventStatus && row.status !== args.eventStatus) return false;
if (args.aggregateType && String(row.aggregate_type ?? "") !== args.aggregateType) return false;
if (args.aggregateId && String(row.aggregate_id ?? "") !== args.aggregateId) return false;
return matchesCursor(row, args.cursor);
});
return {
rows: filteredRows.slice(0, args.limit),
hasMore: filteredRows.length > args.limit || rows.length === scanLimit,
};
}
export const recordCommandLog = mutation({
args: {
workspaceId: v.string(),
@@ -280,44 +426,33 @@ export const listWorkspaceOverview = query({
const aggregateType = normalizeObjectFilter(args.aggregateType);
const aggregateId = normalizeObjectFilter(args.aggregateId);
const allCommandLogs = await ctx.db
.query("command_logs")
.withIndex("by_workspace_request", (q) => q.eq("workspace_id", args.workspaceId))
.collect();
const allDomainEvents = await ctx.db
.query("domain_events")
.withIndex("by_workspace_request", (q) => q.eq("workspace_id", args.workspaceId))
.collect();
const commandWindow = await fetchCommandLogWindow(ctx, {
workspaceId: args.workspaceId,
limit,
cursor,
commandStatus,
targetPageId,
targetBlockId,
});
const domainEventWindow = await fetchDomainEventWindow(ctx, {
workspaceId: args.workspaceId,
limit,
cursor,
eventStatus,
aggregateType,
aggregateId,
});
const filteredCommandLogs = sortByNewest(
allCommandLogs.filter((row: any) => {
if (commandStatus && row.status !== commandStatus) return false;
if (targetPageId && String(row.target_page_id ?? "") !== targetPageId) return false;
if (targetBlockId && String(row.target_block_id ?? "") !== targetBlockId) return false;
return matchesCursor(row, cursor);
}),
);
const pageCommandLogs = filteredCommandLogs.slice(0, limit);
const nextCursor = encodeCursor(pageCommandLogs[pageCommandLogs.length - 1] ?? null);
const commandIds = new Set(pageCommandLogs.map((row: any) => String(row.command_id)));
const domainEvents = sortByNewest(
allDomainEvents.filter((row: any) => {
if (commandIds.size > 0 && !commandIds.has(String(row.command_id ?? ""))) return false;
if (eventStatus && row.status !== eventStatus) return false;
if (aggregateType && String(row.aggregate_type ?? "") !== aggregateType) return false;
if (aggregateId && String(row.aggregate_id ?? "") !== aggregateId) return false;
return true;
}),
);
const pageCommandLogs = sortByNewest(commandWindow.rows);
const domainEvents = sortByNewest(domainEventWindow.rows);
const nextCursor = encodeOverviewNextCursor(pageCommandLogs, domainEvents);
return {
workspace_id: args.workspaceId,
command_logs: pageCommandLogs,
domain_events: domainEvents,
next_cursor: nextCursor,
has_more: filteredCommandLogs.length > pageCommandLogs.length,
has_more: commandWindow.hasMore || domainEventWindow.hasMore,
filters: {
command_status: commandStatus,
event_status: eventStatus,
+15 -2
View File
@@ -474,7 +474,11 @@ export default defineSchema({
.index("by_command_log_id", ["id"])
.index("by_workspace_request", ["workspace_id", "request_id"])
.index("by_workspace_trace", ["workspace_id", "trace_id"])
.index("by_workspace_command", ["workspace_id", "command_id"]),
.index("by_workspace_command", ["workspace_id", "command_id"])
.index("by_workspace_created_at", ["workspace_id", "created_at", "id"])
.index("by_workspace_status_created_at", ["workspace_id", "status", "created_at", "id"])
.index("by_workspace_target_page_created_at", ["workspace_id", "target_page_id", "created_at", "id"])
.index("by_workspace_target_block_created_at", ["workspace_id", "target_block_id", "created_at", "id"]),
domain_events: defineTable({
id: v.string(),
@@ -495,5 +499,14 @@ export default defineSchema({
.index("by_domain_event_id", ["id"])
.index("by_workspace_request", ["workspace_id", "request_id"])
.index("by_workspace_trace", ["workspace_id", "trace_id"])
.index("by_workspace_command", ["workspace_id", "command_id"]),
.index("by_workspace_command", ["workspace_id", "command_id"])
.index("by_workspace_created_at", ["workspace_id", "created_at", "id"])
.index("by_workspace_status_created_at", ["workspace_id", "status", "created_at", "id"])
.index("by_workspace_aggregate_created_at", [
"workspace_id",
"aggregate_type",
"aggregate_id",
"created_at",
"id",
]),
});
@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import { POST } from "@/app/api/mindmap-ai/expand-node/route";
describe("/api/mindmap-ai/expand-node route", () => {
it("显式退役旧 Next route,并指向 AI Agent 内置工具", async () => {
const response = await POST();
const payload = await response.json() as {
error: string;
code: string;
replacement: {
kind: string;
toolName: string;
route: string;
};
};
expect(response.status).toBe(410);
expect(response.headers.get("x-mnote-compat-boundary")).toBe("mindmap-expand-node-route-retired");
expect(payload).toMatchObject({
code: "mindmap-expand-node-route-retired",
replacement: {
kind: "ai-agent-tool",
toolName: "mindmap_expand_node",
route: "/api/ai-agent/run",
},
});
expect(payload.error).toContain("mindmap_expand_node");
});
});
@@ -1,357 +1,23 @@
import { NextResponse } from "next/server";
import { loadOnlineAiConfig } from "@/lib/ai/onlineAiConfig";
import { openAiCompatibleChat, tryExtractJsonObject } from "@/lib/ai/openaiCompatibleChat";
import { buildDocumentBridgeContextWithActor } from "@/lib/documents/bridge";
import { executeRustBridgeTool } from "@/lib/documents/rust-runtime";
import { readMindmapLocal } from "@/lib/mindmap/mindmapLocalStore";
type RequestPayload = {
documentId: string;
mindmapId: string;
targetUid: string;
instruction?: string;
sources?: { searxng?: boolean; rag?: boolean };
};
export const dynamic = "force-dynamic";
type SearxResult = { title: string; url: string; snippet?: string; engine?: string };
const defaultMindmapData = {
data: { text: "中心主题" },
children: [],
};
const findNodeByUid = (root: any, uid: string): any | null => {
const target = String(uid || "");
if (!target) return null;
const walk = (node: any): any | null => {
const nuid = String(node?.data?.uid || node?.uid || "");
if (nuid === target) return node;
const children = Array.isArray(node?.children) ? node.children : [];
for (const c of children) {
const hit = walk(c);
if (hit) return hit;
}
return null;
};
return walk(root);
};
const safeUrlOrNull = (value: unknown) => {
const s = typeof value === "string" ? value.trim() : "";
if (!s) return null;
try {
const u = new URL(s);
if (u.protocol !== "http:" && u.protocol !== "https:") return null;
return u.toString();
} catch {
return null;
}
};
const searchSearxng = async (q: string, count = 5): Promise<SearxResult[]> => {
const base = (process.env.SEARXNG_BASE_URL ?? "http://127.0.0.1:8889").replace(/\/+$/, "");
const token = (process.env.SEARXNG_API_TOKEN ?? "").trim();
const url = `${base}/search?q=${encodeURIComponent(q)}&format=json&language=zh-CN&categories=general&safesearch=1`;
const tryFetch = async (headers: Record<string, string>) => {
const res = await fetch(url, { headers, method: "GET" });
if (!res.ok) return null;
return (await res.json().catch(() => null)) as any;
};
let json: any = null;
if (token) {
json =
(await tryFetch({ Authorization: `Bearer ${token}` })) ??
(await tryFetch({ "X-API-Key": token })) ??
null;
}
if (!json) {
json = await tryFetch({});
}
const results = Array.isArray(json?.results) ? json.results : [];
const mapped: SearxResult[] = results
.map((r: any) => ({
title: String(r?.title ?? "").trim(),
url: String(r?.url ?? "").trim(),
snippet: String(r?.content ?? r?.snippet ?? "").trim(),
engine: String(r?.engine ?? "").trim(),
}))
.filter((r: SearxResult) => r.title && safeUrlOrNull(r.url))
.slice(0, Math.max(1, Math.min(10, count)));
return mapped;
};
const coerceOpsFromAiJson = (raw: Record<string, unknown>) => {
const ops = (raw as any)?.ops;
return Array.isArray(ops) ? ops : [];
};
export async function POST(request: Request) {
const payload = (await request.json().catch(() => null)) as RequestPayload | null;
if (!payload?.documentId || !payload?.mindmapId || !payload?.targetUid) {
return NextResponse.json({ error: "缺少 documentId/mindmapId/targetUid" }, { status: 400 });
}
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
/*
const supabase = await createSupabaseRouteClient();
const {
data: { session },
} = await supabase.auth.getSession();
if (!session) {
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
// 校验页面归属
const { data: doc, error: docError } = await supabase
.from("documents")
.select("id,title,mindmap_data")
.eq("id", payload.documentId)
.eq("user_id", session.user.id)
.maybeSingle();
if (docError) {
return NextResponse.json({ error: docError.message }, { status: 400 });
}
if (!doc) {
return NextResponse.json({ error: "页面不存在" }, { status: 404 });
}
const local = await readMindmapLocal(payload.documentId, payload.mindmapId);
const baseData = (local.ok ? local.data : (doc.mindmap_data ?? defaultMindmapData)) as MindmapTreeNode;
const target = findNodeByUid(baseData, payload.targetUid);
if (!target) {
return NextResponse.json({ error: "未找到目标节点(uid 不存在)" }, { status: 404 });
}
const targetText = String(target?.data?.text ?? "").trim();
const currentChildren = Array.isArray(target?.children)
? target.children
.map((c: any) => String(c?.data?.text ?? "").trim())
.filter(Boolean)
.slice(0, 20)
: [];
const instruction = String(payload.instruction ?? "").trim();
const query = [targetText, instruction].filter(Boolean).join(" ");
const useSearx = payload.sources?.searxng !== false;
const searxResults = useSearx && query ? await searchSearxng(query, 6).catch(() => []) : [];
const cfg = await loadOnlineAiConfig().catch(() => null);
if (!cfg) {
return NextResponse.json({ error: "未找到在线 AI 配置(ai.md 或环境变量)" }, { status: 500 });
}
const system = [
"你是一个“思维导图补完器”。",
"你必须输出严格 JSON(不要 Markdown/不要代码块/不要解释)。",
"你只能输出 {\"ops\": MindmapOp[]} 这一个对象。",
"默认策略:为 targetUid 新增 3~6 个子节点(addChild)。",
"每个新增节点必须提供 text,并尽量提供 hyperlink 与 refs(引用来自搜索结果 URL)。",
"不要删除/改名已有节点;不要输出 addSiblingAfter/updateText/deleteNode。",
].join("\n");
const user = [
`documentId=${payload.documentId}`,
`mindmapId=${payload.mindmapId}`,
`targetUid=${payload.targetUid}`,
"",
`目标节点:${targetText || "(empty)"}`,
currentChildren.length ? `当前子节点(供去重):${currentChildren.join("")}` : "",
instruction ? `用户要求:${instruction}` : "",
"",
"可用证据(搜索结果):",
...(searxResults.length
? searxResults.map((r, idx) => {
const snip = (r.snippet || "").replace(/\s+/g, " ").slice(0, 180);
return `${idx + 1}. ${r.title}\n url: ${r.url}\n snippet: ${snip}`;
})
: ["(无)"]),
"",
"MindmapOp JSON Schema(仅供理解):",
'{ "ops": [ { "op": "addChild", "parentUid": string, "node": { "text": string, "hyperlink"?: string, "refs"?: NodeRef[] } } ] }',
'NodeRef 示例:{ "kind": "url", "fileUrl": "https://example.com", "title": "来源标题", "snippet": "..." }',
"",
"硬性约束:",
"- 仅输出 addChildparentUid 必须等于 targetUid。",
"- 新增节点 text 不要与当前子节点重复。",
"- hyperlink 必须是 http(s) URL。",
"- 每个新增节点 refs 至少 1 条(kind=url, fileUrl=来源url)。",
"- 输出规模控制:最多 6 个节点。",
]
.filter(Boolean)
.join("\n");
let finishReason = "";
let ops: MindmapOp[] = [];
try {
const { text, raw } = await openAiCompatibleChat(
[
{ role: "system", content: system },
{ role: "user", content: user },
],
{
baseUrl: cfg.baseUrl,
apiKey: cfg.apiKey,
model: cfg.model,
timeoutMs: 40_000,
maxTokens: 1800,
maxCompletionTokens: 1800,
responseFormat: "json_object",
},
);
finishReason = String((raw as any)?.choices?.[0]?.finish_reason ?? "");
const json = tryExtractJsonObject(text);
if (json) {
ops = coerceOpsFromAiJson(json);
// 安全收敛:仅允许 addChild 且 parentUid==targetUid
ops = ops
.filter((op) => op && typeof op === "object" && (op as any).op === "addChild")
.filter((op) => String((op as any).parentUid || "") === payload.targetUid)
.slice(0, 8);
}
} catch {
// ignore: 后续走兜底策略
ops = [];
}
// 再做一次补齐/校验:refs/hyperlink
const fallbackRefsFrom = (r: SearxResult): NodeRef[] => [
export async function POST() {
return NextResponse.json(
{
kind: "url",
fileUrl: r.url,
title: r.title,
snippet: r.snippet ? r.snippet.slice(0, 300) : undefined,
},
];
const existed = new Set(currentChildren);
const fixed: MindmapOp[] = [];
for (const op of ops) {
const node = (op as any).node ?? {};
const textVal = String(node.text ?? "").trim();
if (!textVal) continue;
if (existed.has(textVal)) continue;
existed.add(textVal);
const href = safeUrlOrNull(node.hyperlink) ?? safeUrlOrNull(node.url) ?? null;
const refs = Array.isArray(node.refs) ? (node.refs as NodeRef[]) : [];
const hasRefUrl = refs.some((x) => x && x.kind === "url" && safeUrlOrNull((x as any).fileUrl));
let finalRefs = refs;
if (!hasRefUrl && searxResults.length) {
finalRefs = fallbackRefsFrom(searxResults[0]);
}
fixed.push({
op: "addChild",
parentUid: payload.targetUid,
node: {
text: textVal,
...(href ? { hyperlink: href } : {}),
...(finalRefs.length ? { refs: finalRefs } : {}),
error: "Next /api/mindmap-ai/expand-node 已退场,请改用 AI Agent 内置 mindmap_expand_node 工具。",
code: "mindmap-expand-node-route-retired",
replacement: {
kind: "ai-agent-tool",
toolName: "mindmap_expand_node",
route: "/api/ai-agent/run",
},
});
if (fixed.length >= 6) break;
}
// 兜底:若 AI 没产出有效 ops,则直接用搜索结果生成节点(保证功能可用 + 可追溯)
if (!fixed.length && searxResults.length) {
for (const r of searxResults.slice(0, 6)) {
const title = String(r.title || "").trim();
const url = safeUrlOrNull(r.url);
if (!title || !url) continue;
if (existed.has(title)) continue;
existed.add(title);
fixed.push({
op: "addChild",
parentUid: payload.targetUid,
node: {
text: title,
hyperlink: url,
refs: fallbackRefsFrom(r),
},
});
if (fixed.length >= 6) break;
}
}
if (!fixed.length) {
// 最后兜底:至少给出 3 个“待核验”节点(无引用)
const base = targetText || "补完";
for (let i = 1; i <= 3; i++) {
fixed.push({
op: "addChild",
parentUid: payload.targetUid,
node: {
text: `${base}(待核验)${i}`,
},
});
}
}
const context = buildDocumentBridgeContextWithActor({
request,
actor: {
actorType: "service",
actorId: "mindmap-expand-node",
sessionId: null,
},
workspaceId: (doc as any).workspace_id ?? null,
source: {
channel: "mindmap-expand-route",
client: "wolai-frontend",
{
status: 410,
headers: {
"x-mnote-compat-boundary": "mindmap-expand-node-route-retired",
},
},
});
const result = await executeRustBridgeTool<{
ok: boolean;
applied?: number;
errors?: string[];
ops?: unknown[];
data?: unknown;
meta?: unknown;
}>({
context,
toolName: "mindmap_expand_node",
invocationKind: "command",
args: {
documentId: payload.documentId,
mindmapId: payload.mindmapId,
targetUid: payload.targetUid,
instruction: payload.instruction ?? "",
ops: fixed,
searchResults: searxResults.map((r) => ({
title: r.title,
url: r.url,
snippet: r.snippet ?? "",
})),
reason: payload.instruction ?? "",
},
data: {
data: baseData,
source: "mindmap-expand-node",
},
mode: "result",
});
return NextResponse.json({
ok: true,
providerUsed: "online",
applied: (result.result as any)?.applied ?? 0,
errors: (result.result as any)?.errors ?? [],
ops: (result.result as any)?.ops ?? fixed,
data: (result.result as any)?.data ?? baseData,
meta: {
finishReason,
searched: useSearx,
searxCount: searxResults.length,
},
});
*/
);
}
@@ -24,6 +24,7 @@ export function AppLayoutShell({ initialData, children }: AppLayoutShellProps) {
sidebarQueryData: sidebarQuery.data,
treeStreamData: treeStream.data,
treeStreamStatus: treeStream.status,
treeStreamCursor: treeStream.cursor,
});
return (
@@ -815,6 +815,7 @@ export function DocumentAiAgentPanelRuntime({
subtree: contextSubtree,
outline: contextOutline,
evidence: contextEvidence,
pageSubtreeSource: pageAggregateSnapshot.pageSubtreeSource ?? "none",
pageOptions: pageAggregateSnapshot.pageOptions,
},
options: {
@@ -11,6 +11,7 @@ import { useAiAgentUiStore } from "@/store/ai-agent-ui";
export type PageAggregateAiSnapshot = {
blocks: Json | null;
pageSubtree: PageSubtreeProjection | null;
pageSubtreeSource?: "server" | "local" | "none";
persistedMeta: {
workspaceId: string | null;
revision: number | null;
@@ -45,6 +45,9 @@ type SidebarProps = {
activeTab: SidebarPanel | null;
onClose: () => void;
};
// 旧 Next route 已退场;补完节点能力保留在 AI Agent 的 mindmap_expand_node 工具中。
const MINDMAP_LEGACY_EXPAND_NODE_ENTRY_ENABLED = false;
const ColorInput = ({
value,
@@ -1631,41 +1634,43 @@ const AiPanel = ({
{docDebug && <div className="text-xs text-gray-500 whitespace-pre-wrap">{docDebug}</div>}
</div>
<div className="space-y-2 rounded-md border border-gray-200 p-3">
<div className="flex items-center justify-between">
<Label className="text-xs text-gray-500">AI </Label>
<div className="flex items-center gap-2">
<Toggle
pressed={expandUseSearx}
onPressedChange={(v) => setExpandUseSearx(Boolean(v))}
size="sm"
className="text-xs"
>
<Network className="h-4 w-4 mr-1" />
</Toggle>
{MINDMAP_LEGACY_EXPAND_NODE_ENTRY_ENABLED ? (
<div className="space-y-2 rounded-md border border-gray-200 p-3">
<div className="flex items-center justify-between">
<Label className="text-xs text-gray-500">AI </Label>
<div className="flex items-center gap-2">
<Toggle
pressed={expandUseSearx}
onPressedChange={(v) => setExpandUseSearx(Boolean(v))}
size="sm"
className="text-xs"
>
<Network className="h-4 w-4 mr-1" />
</Toggle>
</div>
</div>
<textarea
className="w-full rounded-md border border-gray-200 p-2 text-sm"
rows={3}
value={expandInstruction}
onChange={(e) => setExpandInstruction(e.target.value)}
placeholder="例如:补充该节点的关键概念、常见误区与参考链接(每条都要可点击来源)"
/>
<button
type="button"
disabled={expandLoading}
className="w-full rounded-md bg-blue-500 px-3 py-2 text-sm text-white hover:bg-blue-600 disabled:opacity-50"
onClick={runExpandSelectedNode}
>
{expandLoading ? "补完中..." : "补完选中节点(写入并保存)"}
</button>
{expandDebug && <div className="text-xs text-gray-500 whitespace-pre-wrap">{expandDebug}</div>}
<p className="text-xs text-gray-400">
SearxNG + 线 AI ops mindmap
</p>
</div>
<textarea
className="w-full rounded-md border border-gray-200 p-2 text-sm"
rows={3}
value={expandInstruction}
onChange={(e) => setExpandInstruction(e.target.value)}
placeholder="例如:补充该节点的关键概念、常见误区与参考链接(每条都要可点击来源)"
/>
<button
type="button"
disabled={expandLoading}
className="w-full rounded-md bg-blue-500 px-3 py-2 text-sm text-white hover:bg-blue-600 disabled:opacity-50"
onClick={runExpandSelectedNode}
>
{expandLoading ? "补完中..." : "补完选中节点(写入并保存)"}
</button>
{expandDebug && <div className="text-xs text-gray-500 whitespace-pre-wrap">{expandDebug}</div>}
<p className="text-xs text-gray-400">
SearxNG + 线 AI ops mindmap
</p>
</div>
) : null}
<Label className="text-xs text-gray-500"></Label>
<NativeSelect
@@ -104,6 +104,7 @@ describe("page-aggregate-client-state", () => {
const state = createPageAggregateClientState(page);
expect(state.documentId).toBe("doc_1");
expect(state.serverPageTitle).toBe("页面标题");
expect(state.persistedPageTitle).toBeNull();
expect(state.draftPageTitle).toBeNull();
@@ -221,7 +222,7 @@ describe("page-aggregate-client-state", () => {
});
});
it("本地标题或正文与服务端快照不一致时,不应继续复用旧 pageSubtree", () => {
it("本地标题或正文与服务端快照不一致时,应生成临时 pageSubtree 而不是复用旧快照", () => {
const page = createPageAggregate();
const initialState = createPageAggregateClientState(page);
@@ -239,7 +240,17 @@ describe("page-aggregate-client-state", () => {
selectPageAggregateClientPageSubtree(localContentState, {
liveSidebarTitle: "页面标题",
}),
).toBeNull();
).toEqual(
expect.objectContaining({
projectionId: expect.any(String),
projection: "page_tree",
rootNode: expect.objectContaining({
metadata: expect.objectContaining({
title: "页面标题",
}),
}),
}),
);
const draftTitleState = pageAggregateClientStateReducer(initialState, {
type: "set_draft_page_title",
@@ -249,7 +260,16 @@ describe("page-aggregate-client-state", () => {
selectPageAggregateClientPageSubtree(draftTitleState, {
liveSidebarTitle: "页面标题",
}),
).toBeNull();
).toEqual(
expect.objectContaining({
projection: "page_tree",
rootNode: expect.objectContaining({
metadata: expect.objectContaining({
title: "草稿标题",
}),
}),
}),
);
const persistedTitleState = pageAggregateClientStateReducer(initialState, {
type: "commit_persisted_page_title",
@@ -299,6 +319,7 @@ describe("page-aggregate-client-state", () => {
expect(snapshot).toEqual({
blocks: page.body.content as Json,
pageSubtree: page.tree.pageSubtree,
pageSubtreeSource: "server",
persistedMeta: {
workspaceId: "ws_1",
revision: 3,
@@ -1,10 +1,13 @@
import type { PageBodyPersistedMeta } from "@/lib/documents/page-command-client";
import type { PageAggregateProjection } from "@/lib/documents/page-aggregate";
import type { PageSubtreeProjection } from "@/lib/documents/page-subtree";
import { buildPageSubtreeProjection, type PageSubtreeProjection } from "@/lib/documents/page-subtree";
import type { PageOptionsState } from "@/types/page-options";
import type { Json } from "@/types/supabase";
export type PageAggregateClientPageSubtreeSource = "server" | "local" | "none";
export type PageAggregateClientState = {
documentId: string;
serverPageTitle: string;
persistedPageTitle: string | null;
draftPageTitle: string | null;
@@ -71,6 +74,7 @@ export function createPageAggregateClientState(
page: PageAggregateProjection,
): PageAggregateClientState {
return {
documentId: page.identity.documentId,
serverPageTitle: normalizePageTitle(page.head.title),
persistedPageTitle: null,
draftPageTitle: null,
@@ -160,13 +164,43 @@ export function selectPageAggregateClientPageSubtree(
liveSidebarTitle: string | null;
},
): PageSubtreeProjection | null {
return selectPageAggregateClientPageSubtreeState(state, input).pageSubtree;
}
export function selectPageAggregateClientPageSubtreeState(
state: PageAggregateClientState,
input: {
liveSidebarTitle: string | null;
},
): {
pageSubtree: PageSubtreeProjection | null;
source: PageAggregateClientPageSubtreeSource;
} {
const titleState = selectPageAggregateClientTitleState(state, input);
const contentUnchanged = state.content === state.serverContentSnapshot;
if (state.serverPageSubtreeSnapshot && !titleState.hasDraft && contentUnchanged) {
return withResolvedPageSubtreeTitle(state.serverPageSubtreeSnapshot, titleState.committedTitle);
return {
pageSubtree: withResolvedPageSubtreeTitle(state.serverPageSubtreeSnapshot, titleState.committedTitle),
source: "server",
};
}
return null;
if (state.content != null) {
return {
pageSubtree: buildPageSubtreeProjection({
documentId: state.documentId,
title: titleState.displayTitle,
content: state.content,
}),
source: "local",
};
}
return {
pageSubtree: null,
source: "none",
};
}
export function selectPageAggregateClientAiSnapshot(
@@ -178,6 +212,7 @@ export function selectPageAggregateClientAiSnapshot(
): {
blocks: Json | null;
pageSubtree: PageSubtreeProjection | null;
pageSubtreeSource: PageAggregateClientPageSubtreeSource;
persistedMeta: {
workspaceId: string | null;
revision: number | null;
@@ -186,12 +221,14 @@ export function selectPageAggregateClientAiSnapshot(
pageOptions: PageOptionsState;
} {
const blocks = state.content as Json | null;
const pageSubtreeState = selectPageAggregateClientPageSubtreeState(state, {
liveSidebarTitle: input.liveSidebarTitle,
});
return {
blocks,
pageSubtree: selectPageAggregateClientPageSubtree(state, {
liveSidebarTitle: input.liveSidebarTitle,
}),
pageSubtree: pageSubtreeState.pageSubtree,
pageSubtreeSource: pageSubtreeState.source,
persistedMeta: {
workspaceId: input.workspaceId,
revision: state.contentRevision,
@@ -3,6 +3,7 @@ import fs from "node:fs";
import path from "node:path";
const SIDEBAR_SOURCE = path.join(process.cwd(), "src/components/sidebar/sidebar.tsx");
const TREE_SHELL_DOM_HOST_SOURCE = path.join(process.cwd(), "src/components/sidebar/tree-shell-dom-host.tsx");
describe("sidebar file tree delete preflight source", () => {
it("rust_family 删除链应走 Rust delete preflight,而不是本地 delete target helper", () => {
@@ -28,4 +29,58 @@ describe("sidebar file tree delete preflight source", () => {
expect(source).toContain('if (viewMode === "filesystem") {');
expect(source).toContain("await handleDeleteResourceSelection();");
});
it("页面新建/删除成功后应本地更新 Sidebar,不再同步等待整树刷新", () => {
const source = fs.readFileSync(SIDEBAR_SOURCE, "utf8");
const createStart = source.indexOf("const handleCreate = useCallback(");
const createEnd = source.indexOf("const handleRename = useCallback(");
const deleteSelectionStart = source.indexOf("const handleDeleteResourceSelection = useCallback(");
const deleteSelectionEnd = source.indexOf("const handleFileTreeShellDeleteSelection = useCallback(");
const deleteStart = source.indexOf("const handleDelete = useCallback(");
const deleteEnd = source.indexOf("const handleDeleteFromContextMenuNode = useCallback(");
expect(createStart).toBeGreaterThanOrEqual(0);
expect(createEnd).toBeGreaterThan(createStart);
expect(deleteSelectionStart).toBeGreaterThanOrEqual(0);
expect(deleteSelectionEnd).toBeGreaterThan(deleteSelectionStart);
expect(deleteStart).toBeGreaterThanOrEqual(0);
expect(deleteEnd).toBeGreaterThan(deleteStart);
const createBody = source.slice(createStart, createEnd);
const deleteSelectionBody = source.slice(deleteSelectionStart, deleteSelectionEnd);
const deleteBody = source.slice(deleteStart, deleteEnd);
expect(createBody).not.toContain("await refreshTree();");
expect(deleteSelectionBody).toContain("removeDocumentsFromTree(docIds);");
expect(deleteSelectionBody).not.toContain("await refreshTree();");
expect(deleteBody).toContain("removeDocumentsFromTree([documentId]);");
expect(deleteBody).not.toContain("await refreshTree();");
});
it("Rust-family tree shell mutation 成功后应本地 apply,不再默认整树刷新", () => {
const source = fs.readFileSync(SIDEBAR_SOURCE, "utf8");
const domHostSource = fs.readFileSync(TREE_SHELL_DOM_HOST_SOURCE, "utf8");
const mutationStart = source.indexOf("const handleTreeShellMutation = useCallback(");
const mutationEnd = source.indexOf("useEffect(() => {", mutationStart);
expect(mutationStart).toBeGreaterThanOrEqual(0);
expect(mutationEnd).toBeGreaterThan(mutationStart);
const mutationBody = source.slice(mutationStart, mutationEnd);
expect(mutationBody).toContain('payload.type === "tree.node.created"');
expect(mutationBody).toContain('payload.type === "tree.node.renamed"');
expect(mutationBody).toContain('payload.type === "tree.subtree.moved"');
expect(mutationBody).toContain("insertNode(prev, parentId, Number.MAX_SAFE_INTEGER, nextNode)");
expect(mutationBody).toContain("renameDocumentInTree(prev, documentId, title, payload.updatedAt ?? null)");
expect(mutationBody).toContain("moveLocalNode(prev, documentId, parentId, sortOrder)");
expect(mutationBody).not.toMatch(
/const handleTreeShellMutation = useCallback\(\(\) => \{\s*void refreshTree\(\);\s*\}, \[refreshTree\]\);/,
);
expect(domHostSource).toContain("onTreeMutation?.({");
expect(domHostSource).toContain('type: "tree.node.created"');
expect(domHostSource).toContain("parentId: commandParentId");
expect(domHostSource).toContain("title: commandTitle");
expect(domHostSource).toContain("sortOrder: commandSortOrder");
});
});
+137 -20
View File
@@ -50,6 +50,7 @@ import {
import { usePreferredSidebarSnapshot } from "@/components/sidebar/use-preferred-sidebar-snapshot";
import { bindSidebarRefreshEvents } from "@/components/sidebar/sidebar-events";
import { SidebarTreeSurface } from "@/components/sidebar/tree-shell-surface";
import type { TreeShellMutationPayload } from "@/components/sidebar/tree-shell-host";
import { buildSidebarSectionsFromTree } from "@/lib/sidebar-tree";
import {
buildPageTreeProjectionItems,
@@ -203,6 +204,7 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
sidebarQueryData: sidebarQuery.data,
treeStreamData: treeStream.data,
treeStreamStatus: treeStream.status,
treeStreamCursor: treeStream.cursor,
});
const sidebarData = externalSidebarData ?? preferredSidebarSnapshot.data;
@@ -1188,9 +1190,91 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
[assetById, handleOpenAssetInMain],
);
const handleTreeShellMutation = useCallback(() => {
const moveLocalNode = useCallback((currentTree: SidebarTreeNode[], nodeId: string, parentId: string | null, index: number) => {
const cloned = cloneNodes(currentTree);
const { removed, tree: withoutTarget } = removeNode(cloned, nodeId);
if (!removed) {
return currentTree;
}
const next = insertNode(withoutTarget, parentId, index, removed);
return next;
}, []);
const handleTreeShellMutation = useCallback((payload: TreeShellMutationPayload) => {
const documentId = payload.documentId?.trim() || "";
if (!documentId) {
void refreshTree();
return;
}
if (payload.type === "tree.node.created") {
const parentId = payload.parentId?.trim() || null;
const title = payload.title?.trim() || "无标题";
const createdAt = payload.execution?.created_at || new Date().toISOString();
const updatedAt = payload.execution?.updated_at ?? payload.updatedAt ?? createdAt;
const commandSortOrder =
typeof payload.sortOrder === "number" && Number.isFinite(payload.sortOrder)
? payload.sortOrder
: null;
const accessScope = payload.execution?.access_scope ?? "private";
const nextNode: SidebarTreeNode = {
id: documentId,
workspace_id: payload.workspaceId?.trim() || sidebarData.activeWorkspaceId || "",
title,
parent_id: parentId,
sort_order: commandSortOrder,
access_scope: accessScope,
is_starred: false,
is_template: payload.execution?.is_template ?? false,
created_at: createdAt,
updated_at: updatedAt,
children: [],
kernel: {
nodeType: "page",
depth: 0,
position: commandSortOrder,
childCount: 0,
expandedByDefault: true,
},
};
setTree((prev) => {
if (nodeById.has(documentId)) return prev;
return insertNode(prev, parentId, Number.MAX_SAFE_INTEGER, nextNode);
});
setExpanded((prev) => {
const next = new Set(prev);
if (parentId) next.add(parentId);
next.add(documentId);
return next;
});
return;
}
if (payload.type === "tree.node.renamed") {
const title = payload.title?.trim();
if (!title) {
void refreshTree();
return;
}
setTree((prev) => renameDocumentInTree(prev, documentId, title, payload.updatedAt ?? null));
return;
}
if (payload.type === "tree.subtree.moved") {
const parentId = payload.parentId?.trim() || null;
const sortOrder =
typeof payload.sortOrder === "number" && Number.isFinite(payload.sortOrder)
? payload.sortOrder
: Number.MAX_SAFE_INTEGER;
setTree((prev) => moveLocalNode(prev, documentId, parentId, sortOrder));
if (parentId) {
setExpanded((prev) => new Set(prev).add(parentId));
}
return;
}
void refreshTree();
}, [refreshTree]);
}, [moveLocalNode, nodeById, refreshTree, sidebarData.activeWorkspaceId]);
useEffect(() => {
const handler = async (event: KeyboardEvent) => {
@@ -1619,6 +1703,23 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
[mediaAssets, mindmapAssets, refreshTree, tableAssets],
);
const removeDocumentsFromTree = useCallback((documentIds: string[]) => {
const targetIds = Array.from(new Set(documentIds.map((item) => item.trim()).filter(Boolean)));
if (targetIds.length === 0) return;
setTree((prev) => {
let next = prev;
let changed = false;
targetIds.forEach((documentId) => {
const result = removeNode(next, documentId);
if (result.removed) {
next = result.tree;
changed = true;
}
});
return changed ? next : prev;
});
}, []);
const handleDeleteResourceSelection = useCallback(async (selectionOverride?: {
selectedRowIds: string[];
anchorRowId: string | null;
@@ -1734,6 +1835,7 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
return;
}
removeDocumentsFromTree(docIds);
docIds.forEach((documentId) => emitDocumentsChanged(documentId));
if (activeId && docIds.includes(activeId)) {
router.push("/");
@@ -1744,7 +1846,6 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
await handleDeleteAssets(assetIds, selectedAssetHints);
}
await refreshTree();
setContextMenu(null);
if (!isRustFamilyTreeRenderer) {
setLegacyResourceSelection((prev) => reduceTreePaneSelection(prev, { type: "clear" }));
@@ -1760,7 +1861,7 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
resourceShellRowById,
resourceSelection,
handleDeleteAssets,
refreshTree,
removeDocumentsFromTree,
router,
sidebarData.activeWorkspaceId,
]);
@@ -1844,7 +1945,6 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
return next;
});
await refreshTree();
const query = new URLSearchParams();
if (nextNode.workspace_id) {
query.set("workspaceId", nextNode.workspace_id);
@@ -1861,7 +1961,7 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
creatingDocumentUnderParentRef.current.delete(creatingKey);
}
},
[refreshTree, router],
[router],
);
const handleRename = useCallback(
@@ -1885,16 +1985,6 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
[refreshTree, sidebarData.activeWorkspaceId],
);
const moveLocalNode = useCallback((currentTree: SidebarTreeNode[], nodeId: string, parentId: string | null, index: number) => {
const cloned = cloneNodes(currentTree);
const { removed, tree: withoutTarget } = removeNode(cloned, nodeId);
if (!removed) {
return currentTree;
}
const next = insertNode(withoutTarget, parentId, index, removed);
return next;
}, []);
const handleMove = useCallback(
async (documentId: string, parentId: string | null, index: number) => {
setTree((prev) => moveLocalNode(prev, documentId, parentId, index));
@@ -1948,8 +2038,9 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
return;
}
if (uploadTargetPlan.targetMindmapId) {
setExpandedAssetFolders((prev) => new Set(prev).add(uploadTargetPlan.targetMindmapId));
const targetMindmapId = uploadTargetPlan.targetMindmapId;
if (targetMindmapId) {
setExpandedAssetFolders((prev) => new Set(prev).add(targetMindmapId));
}
const errors: string[] = [];
@@ -2151,13 +2242,13 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
documentId,
workspaceId: sidebarData.activeWorkspaceId ?? null,
});
await refreshTree();
removeDocumentsFromTree([documentId]);
emitDocumentsChanged(documentId);
if (activeId === documentId) {
router.push("/");
}
},
[activeId, refreshTree, router, sidebarData.activeWorkspaceId],
[activeId, removeDocumentsFromTree, router, sidebarData.activeWorkspaceId],
);
const handleDeleteFromContextMenuNode = useCallback(
@@ -3489,6 +3580,32 @@ const removeNode = (
return { removed, tree: nextTree };
};
const renameDocumentInTree = (
nodes: SidebarTreeNode[],
documentId: string,
title: string,
updatedAt: string | null,
): SidebarTreeNode[] => {
let changed = false;
const next = nodes.map((node) => {
if (node.id === documentId) {
changed = true;
return {
...node,
title,
updated_at: updatedAt ?? node.updated_at,
};
}
const children = renameDocumentInTree(node.children, documentId, title, updatedAt);
if (children !== node.children) {
changed = true;
return { ...node, children };
}
return node;
});
return changed ? next : nodes;
};
const insertNode = (nodes: SidebarTreeNode[], parentId: string | null, index: number, newNode: SidebarTreeNode): SidebarTreeNode[] => {
if (!parentId) {
const next = [...nodes];
@@ -15,6 +15,7 @@ import type {
FileTreeShellDeleteSelectionPayload,
FileTreeShellExternalDropPayload,
FileTreeShellInternalDropPayload,
TreeShellMutationPayload,
TreeShellHostMode,
TreeShellPickerCommand,
} from "@/components/sidebar/tree-shell-host";
@@ -100,7 +101,7 @@ type TreeShellRustDomShellHostProps = {
onInternalDrop?: (payload: FileTreeShellInternalDropPayload) => void;
onDropFiles?: (payload: FileTreeShellExternalDropPayload) => void;
onAssetOpen?: (payload: { assetId: string; documentId: string | null }) => void;
onTreeMutation?: (payload: { type: string; documentId: string | null }) => void;
onTreeMutation?: (payload: TreeShellMutationPayload) => void;
children?: ReactNode;
};
@@ -120,6 +121,19 @@ type TreeShellRuntimeWasmModule = {
type TreeShellRuntimeReducer = (request: unknown) => Promise<unknown> | unknown;
type TreeCommandResponsePayload = {
id?: string;
result?: {
documentId?: string;
parentId?: string | null;
title?: string | null;
sortOrder?: number | null;
workspaceId?: string | null;
updatedAt?: string | null;
execution?: TreeShellMutationPayload["execution"];
} | null;
};
type TreeShellRuntimeGlobal = typeof globalThis & {
__MNOTE_TREE_SHELL_RUNTIME__?: {
reduceTreeShellRuntime?: TreeShellRuntimeReducer;
@@ -179,6 +193,20 @@ function normalizeShellRowKind(value: unknown, defaultValue = "") {
return rowKind;
}
function readTreeCommandResult(payload: unknown): NonNullable<TreeCommandResponsePayload["result"]> {
if (!isRecord(payload) || !isRecord(payload.result)) {
return {};
}
return payload.result;
}
function readTreeCommandDocumentId(payload: unknown, result: NonNullable<TreeCommandResponsePayload["result"]>) {
if (result.documentId) {
return normalizeString(result.documentId);
}
return isRecord(payload) ? normalizeString(payload.id) : "";
}
function itemHasVisibleChildren(item: TreeShellDomProjectionItem, childrenByParent: Map<string, TreeShellDomProjectionItem[]>) {
return item.childCount > 0 && (childrenByParent.get(item.nodeId)?.length ?? 0) > 0;
}
@@ -796,16 +824,40 @@ export function TreeShellRustDomShellHost({
if (event.kind === "createNode") {
const parentId = normalizeString(event.parentNodeId) || null;
const result = await runTreeCommand({ action: "create", parentId });
const documentId = normalizeString((result as { result?: { documentId?: string }; id?: string })?.result?.documentId, normalizeString((result as { id?: string })?.id));
onTreeMutation?.({ type: "tree.node.created", documentId: documentId || null });
const commandResult = readTreeCommandResult(result);
const documentId = readTreeCommandDocumentId(result, commandResult);
const commandParentId = normalizeString(commandResult.parentId, parentId ?? "") || parentId;
const commandTitle = normalizeString(commandResult.title, "无标题");
const commandSortOrder =
typeof commandResult.sortOrder === "number" && Number.isFinite(commandResult.sortOrder)
? commandResult.sortOrder
: null;
onTreeMutation?.({
type: "tree.node.created",
documentId: documentId || null,
parentId: commandParentId,
title: commandTitle,
sortOrder: commandSortOrder,
workspaceId: normalizeString(commandResult.workspaceId, workspaceId) || workspaceId,
updatedAt: normalizeString(commandResult.updatedAt) || null,
execution: commandResult.execution ?? null,
});
continue;
}
if (event.kind === "renameNode") {
const documentId = normalizeString(event.nodeId);
const title = normalizeString(event.title);
if (!documentId || !title) continue;
await runTreeCommand({ action: "rename", documentId, title });
onTreeMutation?.({ type: "tree.node.renamed", documentId });
const result = await runTreeCommand({ action: "rename", documentId, title });
const commandResult = readTreeCommandResult(result);
onTreeMutation?.({
type: "tree.node.renamed",
documentId,
title: normalizeString(commandResult.title, title),
workspaceId: normalizeString(commandResult.workspaceId, workspaceId) || workspaceId,
updatedAt: normalizeString(commandResult.updatedAt) || null,
execution: commandResult.execution ?? null,
});
continue;
}
if (event.kind === "moveSubtree") {
@@ -813,12 +865,26 @@ export function TreeShellRustDomShellHost({
if (!documentId) continue;
const parentId = normalizeString(event.targetParentId) || null;
const sortOrder = typeof event.sortOrder === "number" && Number.isFinite(event.sortOrder) ? event.sortOrder : 0;
await runTreeCommand({ action: "move", documentId, parentId, sortOrder });
onTreeMutation?.({ type: "tree.subtree.moved", documentId });
const result = await runTreeCommand({ action: "move", documentId, parentId, sortOrder });
const commandResult = readTreeCommandResult(result);
const commandParentId = normalizeString(commandResult.parentId, parentId ?? "") || parentId;
const commandSortOrder =
typeof commandResult.sortOrder === "number" && Number.isFinite(commandResult.sortOrder)
? commandResult.sortOrder
: sortOrder;
onTreeMutation?.({
type: "tree.subtree.moved",
documentId,
parentId: commandParentId,
sortOrder: commandSortOrder,
workspaceId: normalizeString(commandResult.workspaceId, workspaceId) || workspaceId,
updatedAt: normalizeString(commandResult.updatedAt) || null,
execution: commandResult.execution ?? null,
});
}
}
},
[onTreeMutation, runTreeCommand],
[onTreeMutation, runTreeCommand, workspaceId],
);
const handlePageOpen = useCallback(
@@ -837,7 +903,7 @@ export function TreeShellRustDomShellHost({
onPageExpandChange?.({ documentId: nodeId, expanded: state.expandedIds.includes(nodeId) });
}
},
[onPageExpandChange, pageState.expandedIds, reducePageAction],
[onPageExpandChange, pageState, reducePageAction],
);
const handlePageKeyDown = useCallback(
@@ -46,6 +46,22 @@ export type FileTreeShellDeleteSelectionPayload = {
focusedRowId: string | null;
};
export type TreeShellMutationPayload = {
type: string;
documentId: string | null;
parentId?: string | null;
title?: string | null;
sortOrder?: number | null;
workspaceId?: string | null;
updatedAt?: string | null;
execution?: ({
access_scope?: "private" | "shared" | "public";
is_template?: boolean;
created_at?: string | null;
updated_at?: string | null;
} & Record<string, unknown>) | null;
};
type TreeShellHostProps = {
mode: TreeShellHostMode;
surfaceTestId: string;
@@ -89,7 +105,7 @@ type TreeShellHostProps = {
onInternalDrop?: (payload: FileTreeShellInternalDropPayload) => void;
onDropFiles?: (payload: FileTreeShellExternalDropPayload) => void;
onAssetOpen?: (payload: { assetId: string; documentId: string | null }) => void;
onTreeMutation?: (payload: { type: string; documentId: string | null }) => void;
onTreeMutation?: (payload: TreeShellMutationPayload) => void;
children: ReactNode;
};
@@ -2,9 +2,11 @@
import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
import type {
FileTreeShellDeleteSelectionPayload,
FileTreeShellExternalDropPayload,
FileTreeShellInternalDropPayload,
TreeShellHostMode,
TreeShellMutationPayload,
TreeShellPickerCommand,
} from "@/components/sidebar/tree-shell-host";
import {
@@ -33,10 +35,7 @@ type TreeShellBridgePageExpandPayload = {
expanded: boolean;
};
type TreeShellBridgeMutationPayload = {
type: string;
documentId: string | null;
};
type TreeShellBridgeMutationPayload = TreeShellMutationPayload;
type TreeShellBridgeSelectionPayload = {
selectedRowIds: string[];
@@ -137,6 +136,7 @@ export type TreeShellIframeHostProps = {
onPageFocusChange?: (payload: { documentId: string | null }) => void;
onFileTreeContextMenu?: (payload: TreeShellBridgeContextMenuPayload) => void;
onFileTreeSelectionChange?: (payload: TreeShellBridgeSelectionPayload) => void;
onFileTreeDeleteSelection?: (payload: FileTreeShellDeleteSelectionPayload) => void;
onInternalDrop?: (payload: FileTreeShellInternalDropPayload) => void;
onDropFiles?: (payload: FileTreeShellExternalDropPayload) => void;
onAssetOpen?: (payload: { assetId: string; documentId: string | null }) => void;
@@ -160,6 +160,12 @@ type TreeShellBridgeMessage = {
copy: boolean;
files: File[];
itemKey: string | null;
parentId?: string | null;
title?: string | null;
sortOrder?: number | null;
workspaceId?: string | null;
updatedAt?: string | null;
execution?: TreeShellMutationPayload["execution"];
};
const INLINE_TREE_SHELL_LOADING_HTML = [
@@ -1269,15 +1275,21 @@ const LOCAL_TREE_SHELL_TEMPLATE = `<!doctype html>
sortOrder = position === "after" ? targetIndex + 1 : targetIndex;
}
try {
await runTreeCommand({
const result = await runTreeCommand({
action: "move",
documentId: sourceNodeId,
parentId,
sortOrder,
});
const commandResult = result && result.result && typeof result.result === "object" ? result.result : {};
setLastAction(targetItem ? \`页面已拖放到 \${targetItem.title}\` : "页面已完成拖放移动");
postToHost("tree.subtree.moved", {
documentId: sourceNodeId,
parentId: normalizeText(commandResult.parentId, parentId || "") || null,
sortOrder: Number.isFinite(commandResult.sortOrder) ? Number(commandResult.sortOrder) : sortOrder,
workspaceId: normalizeText(commandResult.workspaceId, workspaceId) || workspaceId || null,
updatedAt: normalizeText(commandResult.updatedAt) || null,
execution: commandResult.execution && typeof commandResult.execution === "object" ? commandResult.execution : null,
target: { documentId: sourceNodeId },
});
return true;
@@ -1301,8 +1313,15 @@ const LOCAL_TREE_SHELL_TEMPLATE = `<!doctype html>
normalizeText(result && result.id),
);
setLastAction(documentId ? \`已创建子页面 \${documentId}\` : "已创建子页面");
const commandResult = result && result.result && typeof result.result === "object" ? result.result : {};
postToHost("tree.node.created", {
documentId: documentId || null,
parentId: normalizeText(commandResult.parentId, parentId || "") || null,
title: normalizeText(commandResult.title, "无标题"),
sortOrder: Number.isFinite(commandResult.sortOrder) ? Number(commandResult.sortOrder) : null,
workspaceId: normalizeText(commandResult.workspaceId, workspaceId) || workspaceId || null,
updatedAt: normalizeText(commandResult.updatedAt) || null,
execution: commandResult.execution && typeof commandResult.execution === "object" ? commandResult.execution : null,
target: { documentId: documentId || null },
});
return true;
@@ -1322,16 +1341,21 @@ const LOCAL_TREE_SHELL_TEMPLATE = `<!doctype html>
);
if (!nodeId || !title) return false;
try {
await runTreeCommand({
const result = await runTreeCommand({
action: "rename",
documentId: nodeId,
title,
});
const commandResult = result && result.result && typeof result.result === "object" ? result.result : {};
const item = itemById.get(nodeId);
if (item) item.title = title;
setLastAction(\`已重命名为 \${title}\`);
postToHost("tree.node.renamed", {
documentId: nodeId,
title: normalizeText(commandResult.title, title),
workspaceId: normalizeText(commandResult.workspaceId, workspaceId) || workspaceId || null,
updatedAt: normalizeText(commandResult.updatedAt) || null,
execution: commandResult.execution && typeof commandResult.execution === "object" ? commandResult.execution : null,
target: { documentId: nodeId },
});
if (fallbackCommandEvent?.titleElement) {
@@ -4424,8 +4448,11 @@ export function TreeShellIframeHost({
mode,
workspaceId,
rootNodeId,
activePickerItemKey,
focusedDocumentId,
inlineBootstrapActiveDocumentId,
inlineInitialTreeHtml,
inlineProjectionItems,
allowRootPick,
excludeIds,
resolvedChannel,
@@ -4590,6 +4617,12 @@ export function TreeShellIframeHost({
onTreeMutation?.({
type: message.type,
documentId: message.documentId,
...(message.parentId !== undefined ? { parentId: message.parentId } : {}),
...(message.title !== undefined ? { title: message.title } : {}),
...(message.sortOrder !== undefined ? { sortOrder: message.sortOrder } : {}),
...(message.workspaceId !== undefined ? { workspaceId: message.workspaceId } : {}),
...(message.updatedAt !== undefined ? { updatedAt: message.updatedAt } : {}),
...(message.execution !== undefined ? { execution: message.execution } : {}),
});
return;
}
@@ -6,6 +6,7 @@ import {
type FileTreeShellDeleteSelectionPayload,
type FileTreeShellExternalDropPayload,
type FileTreeShellInternalDropPayload,
type TreeShellMutationPayload,
type TreeShellPickerCommand,
type TreeRendererFamily,
} from "@/components/sidebar/tree-shell-host";
@@ -36,7 +37,7 @@ type SidebarPageTreeSurfaceProps = {
onPageContextMenu?: (payload: { documentId: string; x: number; y: number }) => void;
onPageExpandChange?: (payload: { documentId: string | null; expanded: boolean }) => void;
onPageFocusChange?: (payload: { documentId: string | null }) => void;
onTreeMutation?: (payload: { type: string; documentId: string | null }) => void;
onTreeMutation?: (payload: TreeShellMutationPayload) => void;
};
type SidebarFileTreeSurfaceProps = {
@@ -74,7 +75,7 @@ type SidebarFileTreeSurfaceProps = {
}) => void;
onFileTreeDeleteSelection?: (payload: FileTreeShellDeleteSelectionPayload) => void;
onAssetOpen?: (payload: { assetId: string; documentId: string | null }) => void;
onTreeMutation?: (payload: { type: string; documentId: string | null }) => void;
onTreeMutation?: (payload: TreeShellMutationPayload) => void;
};
export type SidebarTreeSurfaceProps =
@@ -40,6 +40,7 @@ function Harness(props: {
sidebarQueryData: SidebarInitialData | null;
treeStreamData: SidebarInitialData | null;
treeStreamStatus?: "idle" | "connecting" | "live" | "fallback";
treeStreamCursor?: string | null;
onState: (state: ReturnType<typeof usePreferredSidebarSnapshot>) => void;
}) {
const state = usePreferredSidebarSnapshot(props);
@@ -145,6 +146,80 @@ describe("usePreferredSidebarSnapshot", () => {
});
});
it("query 版本比已更新过的 stream 更新时,应优先 query,避免旧 stream 回压", async () => {
const initialData = buildSidebarData([buildDocument({ title: "标题 A" })]);
const staleTreeStream = buildSidebarData([
buildDocument({
title: "标题 B",
updated_at: "2026-04-21T00:00:01.000Z",
}),
]);
const refreshedQuery = buildSidebarData([
buildDocument({
title: "标题 C",
updated_at: "2026-04-21T00:00:02.000Z",
}),
]);
await act(async () => {
root.render(
<Harness
initialData={initialData}
sidebarQueryData={refreshedQuery}
treeStreamData={staleTreeStream}
treeStreamStatus="live"
treeStreamCursor="cursor_stream_1"
onState={onState}
/>,
);
});
expect(onState.mock.lastCall?.[0]).toMatchObject({
source: "query",
cursor: null,
data: expect.objectContaining({
kernelSidebarTree: [expect.objectContaining({ title: "标题 C" })],
}),
});
});
it("stream 与 query 版本相同时,应优先 stream 并暴露 cursor", async () => {
const initialData = buildSidebarData([buildDocument({ title: "标题 A" })]);
const queryData = buildSidebarData([
buildDocument({
title: "标题 B",
updated_at: "2026-04-21T00:00:01.000Z",
}),
]);
const treeStreamData = buildSidebarData([
buildDocument({
title: "标题 B",
updated_at: "2026-04-21T00:00:01.000Z",
}),
]);
await act(async () => {
root.render(
<Harness
initialData={initialData}
sidebarQueryData={queryData}
treeStreamData={treeStreamData}
treeStreamStatus="live"
treeStreamCursor="cursor_stream_2"
onState={onState}
/>,
);
});
expect(onState.mock.lastCall?.[0]).toMatchObject({
source: "tree_stream",
cursor: "cursor_stream_2",
data: expect.objectContaining({
kernelSidebarTree: [expect.objectContaining({ title: "标题 B" })],
}),
});
});
it("tree stream 进入 fallback 后应回退到 query 快照", async () => {
const initialData = buildSidebarData([buildDocument({ title: "标题 A" })]);
const staleTreeStream = buildSidebarData([buildDocument({ title: "标题 A" })]);
@@ -1,6 +1,6 @@
import { useMemo } from "react";
import type { SidebarInitialData } from "@/components/sidebar/types";
import { buildSidebarDataSyncKey } from "@/components/sidebar/sidebar-sync";
import { buildSidebarDataSyncKey, getSidebarDataFreshness } from "@/components/sidebar/sidebar-sync";
export type PreferredSidebarSnapshotSource = "initial" | "query" | "tree_stream";
@@ -9,6 +9,7 @@ export function usePreferredSidebarSnapshot(input: {
sidebarQueryData: SidebarInitialData | null;
treeStreamData: SidebarInitialData | null;
treeStreamStatus?: "idle" | "connecting" | "live" | "fallback";
treeStreamCursor?: string | null;
}) {
const querySyncKey = useMemo(
() => (input.sidebarQueryData ? buildSidebarDataSyncKey(input.sidebarQueryData) : null),
@@ -19,29 +20,40 @@ export function usePreferredSidebarSnapshot(input: {
[input.treeStreamData],
);
const initialSyncKey = useMemo(() => buildSidebarDataSyncKey(input.initialData), [input.initialData]);
const initialVersion = useMemo(() => getSidebarDataFreshness(input.initialData), [input.initialData]);
const queryVersion = useMemo(
() => (input.sidebarQueryData ? getSidebarDataFreshness(input.sidebarQueryData) : null),
[input.sidebarQueryData],
);
const treeStreamVersion = useMemo(
() => (input.treeStreamData ? getSidebarDataFreshness(input.treeStreamData) : null),
[input.treeStreamData],
);
const streamIsPreferred = input.treeStreamStatus !== "fallback";
const queryHasFreshSnapshot =
input.sidebarQueryData != null &&
querySyncKey != null &&
querySyncKey !== initialSyncKey &&
treeStreamSyncKey === initialSyncKey;
const preferredVersion = Math.max(
initialVersion,
queryVersion ?? 0,
streamIsPreferred ? treeStreamVersion ?? 0 : 0,
);
const queryHasPreferredVersion = queryVersion != null && queryVersion >= preferredVersion;
const treeStreamHasPreferredVersion =
streamIsPreferred &&
input.treeStreamData != null &&
treeStreamVersion != null &&
treeStreamVersion >= preferredVersion;
const source = useMemo<PreferredSidebarSnapshotSource>(() => {
if (queryHasFreshSnapshot) {
return "query";
}
if (input.treeStreamData && streamIsPreferred) {
if (treeStreamHasPreferredVersion) {
return "tree_stream";
}
if (input.sidebarQueryData) {
if (queryHasPreferredVersion) {
return "query";
}
return "initial";
}, [
input.sidebarQueryData,
input.treeStreamData,
queryHasFreshSnapshot,
streamIsPreferred,
queryHasPreferredVersion,
treeStreamHasPreferredVersion,
]);
const data =
@@ -56,13 +68,22 @@ export function usePreferredSidebarSnapshot(input: {
: source === "query"
? querySyncKey ?? initialSyncKey
: initialSyncKey;
const version =
source === "tree_stream"
? treeStreamVersion ?? initialVersion
: source === "query"
? queryVersion ?? initialVersion
: initialVersion;
const cursor = source === "tree_stream" ? input.treeStreamCursor ?? null : null;
return useMemo(
() => ({
data,
source,
syncKey,
version,
cursor,
}),
[data, source, syncKey],
[cursor, data, source, syncKey, version],
);
}
@@ -301,4 +301,33 @@ describe("tiptap-content-converter", () => {
]);
});
it("mindmap 引用多轮 TipTap/legacy 转换时保持 mindmapId 稳定", () => {
const original = {
type: "doc" as const,
content: [
{
type: "paragraph",
attrs: {
blockId: "mind_block_stable",
mnoteBlockType: "mindmap",
mindmapId: "mindmap_stable_1",
rootNodeId: "root",
},
content: [{ type: "text", text: "中心主题" }],
},
],
};
const firstLegacy = blocksFromTiptapDoc(original);
const secondTiptap = tiptapDocFromBlocks(firstLegacy as never);
const secondLegacy = blocksFromTiptapDoc(secondTiptap);
const thirdTiptap = tiptapDocFromBlocks(secondLegacy as never);
expect(firstLegacy[0]?.props?.mindmapId).toBe("mindmap_stable_1");
expect(secondLegacy[0]?.props?.mindmapId).toBe("mindmap_stable_1");
expect(secondTiptap.content?.[0]?.attrs?.mindmapId).toBe("mindmap_stable_1");
expect(thirdTiptap.content?.[0]?.attrs?.mindmapId).toBe("mindmap_stable_1");
expect(thirdTiptap.content?.[0]?.attrs?.blockId).toBe("mind_block_stable");
});
});
@@ -55,6 +55,12 @@ describe("mindmap action map", () => {
expect(getMindmapActionMapping("showMenu")).toMatchObject({ readonlyAllowed: true });
});
it("导出能力暂缓时不下发未受控 runtime command", () => {
expect(getMindmapActionMapping("export")).toMatchObject({ target: "localView", readonlyAllowed: true });
expect(getMindmapActionMapping("export")?.runtimeCommand).toBeUndefined();
expect(mapMindmapActionToCommand({ actionId: "export", mindmapId: "mind_1" })).toEqual({});
});
it("把主题、结构和扩展字段映射到 kernel/compat", () => {
expect(mapMindmapActionToCommand({ actionId: "setTheme", mindmapId: "mind_1", value: "classic4" })).toEqual({
command: { type: "setTheme", mindmapId: "mind_1", theme: "classic4" },
@@ -62,7 +62,8 @@ const mappings: MindmapActionMapping[] = [
{ actionId: "formula", target: "compatPatch", requiresActiveNode: true, readonlyAllowed: false, compatPath: nodeDataPath("formula") },
{ actionId: "painter", target: "compatPatch", requiresActiveNode: true, readonlyAllowed: false, compatPath: nodeDataPath("style") },
{ actionId: "import", target: "compatPatch", requiresActiveNode: false, readonlyAllowed: false, compatPath: () => "import" },
{ actionId: "export", target: "runtimeCommand", runtimeCommand: "EXPORT", requiresActiveNode: false, readonlyAllowed: true },
// 导出能力暂缓:UI state 会禁用该入口,这里不能继续下发未受控的 runtime EXPORT 命令。
{ actionId: "export", target: "localView", requiresActiveNode: false, readonlyAllowed: true },
{ actionId: "centerRoot", target: "localView", runtimeMethod: "centerRoot", requiresActiveNode: false, readonlyAllowed: true },
{ actionId: "zoomIn", target: "localView", runtimeMethod: "zoomIn", requiresActiveNode: false, readonlyAllowed: true },
{ actionId: "zoomOut", target: "localView", runtimeMethod: "zoomOut", requiresActiveNode: false, readonlyAllowed: true },
@@ -44,6 +44,12 @@ describe("mindmap UI state", () => {
expect(state.disabledActions.insertChild).toBe(false);
});
it("导出能力暂缓时在 UI state 中保持禁用", () => {
const state = deriveMindmapUiState({ activeNodeId: "node_1", readonly: false });
expect(state.disabledActions.export).toBe(true);
});
it("派生默认 shell state,给 Leptos shell 提供稳定合同", () => {
const state = deriveMindmapUiState({
activeNodeId: "node_1",