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:
@@ -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,
|
||||
|
||||
@@ -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": "..." }',
|
||||
"",
|
||||
"硬性约束:",
|
||||
"- 仅输出 addChild;parentUid 必须等于 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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user