feat(kernel): complete tree-first graph tasks 074-080
This commit is contained in:
@@ -11,5 +11,12 @@ crons.weekly(
|
||||
(internal as any).maintenance.cleanupWeekly,
|
||||
);
|
||||
|
||||
export default crons;
|
||||
// 每日触发一次 kernel-aware refresh 过渡链。
|
||||
// 说明:当前仍复用 LightRAG 入库,但任务结果与语料口径已带上 kernel-aware 刷新语义。
|
||||
crons.daily(
|
||||
"kernel_aware_refresh_daily_transition",
|
||||
{ hourUTC: 4, minuteUTC: 15 },
|
||||
(internal as any).jobs.enqueueKernelAwareRefreshSweep,
|
||||
);
|
||||
|
||||
export default crons;
|
||||
|
||||
@@ -7,6 +7,211 @@ import { extractTextFromDocumentContent, extractTextFromMindmapData } from "./_u
|
||||
import { enqueueIngestDocumentJob, enqueueIngestMediaAssetJob, enqueueIngestMindmapJob } from "./_utils/ingestJobs";
|
||||
import { extractTextFromAttachment } from "./_utils/attachmentExtract";
|
||||
|
||||
type KernelAwareRefreshTarget = {
|
||||
documentIds: string[];
|
||||
mindmapRefs: Array<{ docId: string; mindmapId: string }>;
|
||||
assetIds: string[];
|
||||
};
|
||||
|
||||
function uniqueNonEmptyStrings(values: Iterable<string | null | undefined>, limit: number): string[] {
|
||||
const seen = new Set<string>();
|
||||
const output: string[] = [];
|
||||
for (const value of values) {
|
||||
const normalized = String(value ?? "").trim();
|
||||
if (!normalized || seen.has(normalized)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(normalized);
|
||||
output.push(normalized);
|
||||
if (output.length >= limit) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function truncateKernelText(value: string, limit = 800): string {
|
||||
const normalized = String(value ?? "").replace(/\s+/g, " ").trim();
|
||||
if (!normalized) {
|
||||
return "";
|
||||
}
|
||||
return normalized.length > limit ? `${normalized.slice(0, Math.max(0, limit - 1))}…` : normalized;
|
||||
}
|
||||
|
||||
function makeKernelAwareRefreshText(input: {
|
||||
workspaceId: string;
|
||||
documentRows: Array<{ id: string; title?: string | null; raw_text?: string | null; updated_at?: string | null }>;
|
||||
mindmapRows: Array<{ document_id?: string | null; mindmap_id?: string | null; data?: unknown }>;
|
||||
assetRows: Array<{ id: string; document_id?: string | null; file_name?: string | null; ocr_text?: string | null }>;
|
||||
}): string {
|
||||
const lines: string[] = [];
|
||||
lines.push(`# kernel-aware refresh workspace ${input.workspaceId}`);
|
||||
lines.push("");
|
||||
|
||||
for (const doc of input.documentRows) {
|
||||
const title = String(doc.title ?? "").trim() || "无标题";
|
||||
const updatedAt = String(doc.updated_at ?? "").trim() || "unknown";
|
||||
const rawText = truncateKernelText(String(doc.raw_text ?? ""), 600);
|
||||
lines.push(`## node:${doc.id}`);
|
||||
lines.push(`title=${title}`);
|
||||
lines.push(`subtreeRoot=${doc.id}`);
|
||||
lines.push(`updatedAt=${updatedAt}`);
|
||||
if (rawText) {
|
||||
lines.push(`evidence=${rawText}`);
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
for (const mindmap of input.mindmapRows) {
|
||||
const docId = String(mindmap.document_id ?? "").trim();
|
||||
const mindmapId = String(mindmap.mindmap_id ?? "").trim();
|
||||
if (!docId || !mindmapId) {
|
||||
continue;
|
||||
}
|
||||
const text = truncateKernelText(extractTextFromMindmapData(mindmap.data ?? null), 400);
|
||||
if (!text) {
|
||||
continue;
|
||||
}
|
||||
lines.push(`## subtree:${docId}:${mindmapId}`);
|
||||
lines.push(`node=${docId}`);
|
||||
lines.push(`subtreeRoot=${docId}`);
|
||||
lines.push(`evidence=${text}`);
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
for (const asset of input.assetRows) {
|
||||
const assetId = String(asset.id ?? "").trim();
|
||||
const docId = String(asset.document_id ?? "").trim();
|
||||
if (!assetId || !docId) {
|
||||
continue;
|
||||
}
|
||||
const title = String(asset.file_name ?? "").trim() || assetId;
|
||||
const ocrText = truncateKernelText(String(asset.ocr_text ?? ""), 400);
|
||||
if (!ocrText) {
|
||||
continue;
|
||||
}
|
||||
lines.push(`## evidence:${assetId}`);
|
||||
lines.push(`node=${docId}`);
|
||||
lines.push(`subtreeRoot=${docId}`);
|
||||
lines.push(`title=${title}`);
|
||||
lines.push(`evidence=${ocrText}`);
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
return lines.join("\n").trim();
|
||||
}
|
||||
|
||||
async function selectKernelAwareRefreshTargets(ctx: any, args: { workspaceId: string; userId: string }): Promise<KernelAwareRefreshTarget> {
|
||||
const [documents, mindmaps, assets] = await Promise.all([
|
||||
ctx.db
|
||||
.query("documents")
|
||||
.withIndex("by_workspace", (q: any) => q.eq("workspace_id", args.workspaceId))
|
||||
.collect(),
|
||||
ctx.db
|
||||
.query("mindmaps")
|
||||
.withIndex("by_workspace", (q: any) => q.eq("workspace_id", args.workspaceId))
|
||||
.collect(),
|
||||
ctx.db
|
||||
.query("media_assets")
|
||||
.withIndex("by_workspace", (q: any) => q.eq("workspace_id", args.workspaceId))
|
||||
.collect(),
|
||||
]);
|
||||
|
||||
const aliveDocuments = documents
|
||||
.filter((row: any) => row.deleted_at == null)
|
||||
.filter((row: any) => String(row.user_id ?? "") === args.userId)
|
||||
.sort((left: any, right: any) => String(right.updated_at ?? "").localeCompare(String(left.updated_at ?? "")));
|
||||
const ownedMindmaps = mindmaps
|
||||
.filter((row: any) => row.deleted_at == null)
|
||||
.filter((row: any) => String(row.user_id ?? "") === args.userId)
|
||||
.sort((left: any, right: any) => String(right.updated_at ?? "").localeCompare(String(left.updated_at ?? "")));
|
||||
const aliveAssets = assets
|
||||
.filter((row: any) => row.deleted_at == null && row.purged_at == null)
|
||||
.sort((left: any, right: any) => String(right.updated_at ?? "").localeCompare(String(left.updated_at ?? "")));
|
||||
|
||||
const documentIds = uniqueNonEmptyStrings(aliveDocuments.map((row: any) => row.id), 12);
|
||||
const selectedDocumentIds = new Set(documentIds);
|
||||
const mindmapRefs = ownedMindmaps
|
||||
.filter((row: any) => selectedDocumentIds.has(String(row.document_id ?? "").trim()))
|
||||
.slice(0, 8)
|
||||
.map((row: any) => ({
|
||||
docId: String(row.document_id ?? "").trim(),
|
||||
mindmapId: String(row.mindmap_id ?? "").trim(),
|
||||
}))
|
||||
.filter((row: { docId: string; mindmapId: string }) => row.docId && row.mindmapId);
|
||||
const assetIds = uniqueNonEmptyStrings(
|
||||
aliveAssets
|
||||
.filter((row: any) => selectedDocumentIds.has(String(row.document_id ?? "").trim()))
|
||||
.map((row: any) => row.id),
|
||||
10,
|
||||
);
|
||||
|
||||
return { documentIds, mindmapRefs, assetIds };
|
||||
}
|
||||
|
||||
async function buildKernelAwareRefreshPayload(ctx: any, args: {
|
||||
workspaceId: string;
|
||||
userId: string;
|
||||
target: KernelAwareRefreshTarget;
|
||||
}) {
|
||||
const documentRows = await Promise.all(
|
||||
args.target.documentIds.map(async (documentId) => {
|
||||
const [meta, contentRes] = await Promise.all([
|
||||
ctx.runQuery(internal.documents.getMetaForIngest, { userId: args.userId, id: documentId }),
|
||||
ctx.runQuery(internal.documents.getContentForIngest, { userId: args.userId, id: documentId }),
|
||||
]);
|
||||
if (!meta) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
id: documentId,
|
||||
title: meta.title ?? "无标题",
|
||||
raw_text: extractTextFromDocumentContent(contentRes?.content ?? null),
|
||||
updated_at: meta.updated_at ?? null,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
const mindmapRows = await Promise.all(
|
||||
args.target.mindmapRefs.map(async (ref) => {
|
||||
const result = await ctx.runQuery(internal.mindmaps.getForIngest, {
|
||||
userId: args.userId,
|
||||
docId: ref.docId,
|
||||
mindmapId: ref.mindmapId,
|
||||
});
|
||||
if (!result?.ok || !result.meta?.exists) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
document_id: ref.docId,
|
||||
mindmap_id: ref.mindmapId,
|
||||
data: result.data ?? null,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
const assetRows = await Promise.all(
|
||||
args.target.assetIds.map(async (assetId) => {
|
||||
const asset = await ctx.runQuery(api.mediaAssets.getById, { userId: args.userId, id: assetId });
|
||||
if (!asset) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
id: assetId,
|
||||
document_id: asset.document_id ?? null,
|
||||
file_name: asset.file_name ?? null,
|
||||
ocr_text: asset.ocr_text ?? null,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
documentRows: documentRows.filter((item): item is NonNullable<typeof item> => Boolean(item)),
|
||||
mindmapRows: mindmapRows.filter((item): item is NonNullable<typeof item> => Boolean(item)),
|
||||
assetRows: assetRows.filter((item): item is NonNullable<typeof item> => Boolean(item)),
|
||||
};
|
||||
}
|
||||
|
||||
export const get = query({
|
||||
args: { userId: v.string(), id: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
@@ -85,6 +290,133 @@ export const enqueueRagIndexMediaAsset = mutation({
|
||||
},
|
||||
});
|
||||
|
||||
export const enqueueKernelAwareRefresh = mutation({
|
||||
args: { userId: v.string(), workspaceId: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
const workspaceId = String(args.workspaceId ?? "").trim();
|
||||
if (!workspaceId) {
|
||||
throw new Error("缺少 workspaceId");
|
||||
}
|
||||
const membership = await ctx.db
|
||||
.query("workspace_members")
|
||||
.withIndex("by_workspace_user", (q) => q.eq("workspace_id", workspaceId).eq("user_id", args.userId))
|
||||
.first();
|
||||
if (!membership) {
|
||||
throw new Error("无权访问该工作空间");
|
||||
}
|
||||
const ts = nowIso();
|
||||
const id = `refresh:kernel-aware:${workspaceId}`;
|
||||
const payload = {
|
||||
workspaceId,
|
||||
enqueuedAt: ts,
|
||||
trigger: "manual",
|
||||
};
|
||||
const existing = await ctx.db
|
||||
.query("jobs")
|
||||
.withIndex("by_job_id", (q) => q.eq("id", id))
|
||||
.first();
|
||||
if (existing) {
|
||||
await ctx.db.patch(existing._id, {
|
||||
user_id: args.userId,
|
||||
type: "refresh.kernel_aware_transition",
|
||||
status: "queued",
|
||||
payload,
|
||||
result: null,
|
||||
error: null,
|
||||
updated_at: ts,
|
||||
started_at: null,
|
||||
finished_at: null,
|
||||
});
|
||||
} else {
|
||||
await ctx.db.insert("jobs", {
|
||||
id,
|
||||
user_id: args.userId,
|
||||
type: "refresh.kernel_aware_transition",
|
||||
status: "queued",
|
||||
payload,
|
||||
result: null,
|
||||
error: null,
|
||||
created_at: ts,
|
||||
updated_at: ts,
|
||||
started_at: null,
|
||||
finished_at: null,
|
||||
});
|
||||
}
|
||||
await ctx.scheduler.runAfter(0, internal.jobs.start, { id });
|
||||
return { ok: true, id };
|
||||
},
|
||||
});
|
||||
|
||||
export const enqueueKernelAwareRefreshSweep = internalMutation({
|
||||
args: {},
|
||||
handler: async (ctx) => {
|
||||
const memberships = await ctx.db.query("workspace_members").collect();
|
||||
const ownersByWorkspace = new Map<string, string>();
|
||||
for (const membership of memberships) {
|
||||
if (membership.role !== "owner") {
|
||||
continue;
|
||||
}
|
||||
if (!ownersByWorkspace.has(membership.workspace_id)) {
|
||||
ownersByWorkspace.set(membership.workspace_id, membership.user_id);
|
||||
}
|
||||
}
|
||||
|
||||
const scheduled: string[] = [];
|
||||
for (const [workspaceId, userId] of ownersByWorkspace.entries()) {
|
||||
const id = `refresh:kernel-aware:${workspaceId}`;
|
||||
const ts = nowIso();
|
||||
const payload = {
|
||||
workspaceId,
|
||||
enqueuedAt: ts,
|
||||
trigger: "cron",
|
||||
};
|
||||
const existing = await ctx.db
|
||||
.query("jobs")
|
||||
.withIndex("by_job_id", (q) => q.eq("id", id))
|
||||
.first();
|
||||
if (existing && (existing.status === "queued" || existing.status === "running")) {
|
||||
scheduled.push(id);
|
||||
continue;
|
||||
}
|
||||
if (existing) {
|
||||
await ctx.db.patch(existing._id, {
|
||||
user_id: userId,
|
||||
type: "refresh.kernel_aware_transition",
|
||||
status: "queued",
|
||||
payload,
|
||||
result: null,
|
||||
error: null,
|
||||
updated_at: ts,
|
||||
started_at: null,
|
||||
finished_at: null,
|
||||
});
|
||||
} else {
|
||||
await ctx.db.insert("jobs", {
|
||||
id,
|
||||
user_id: userId,
|
||||
type: "refresh.kernel_aware_transition",
|
||||
status: "queued",
|
||||
payload,
|
||||
result: null,
|
||||
error: null,
|
||||
created_at: ts,
|
||||
updated_at: ts,
|
||||
started_at: null,
|
||||
finished_at: null,
|
||||
});
|
||||
}
|
||||
await ctx.scheduler.runAfter(0, internal.jobs.start, { id });
|
||||
scheduled.push(id);
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
scheduledCount: scheduled.length,
|
||||
jobIds: scheduled,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const start = internalMutation({
|
||||
args: { id: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
@@ -320,6 +652,81 @@ export const run = internalAction({
|
||||
return;
|
||||
}
|
||||
|
||||
if (job.type === "refresh.kernel_aware_transition") {
|
||||
const workspaceId = String(job.payload?.workspaceId ?? "").trim();
|
||||
if (!workspaceId) throw new Error("缺少 workspaceId");
|
||||
|
||||
const membership = await ctx.runQuery((internal as any).jobs._getWorkspaceMembership, {
|
||||
workspaceId,
|
||||
userId: job.user_id,
|
||||
});
|
||||
if (!membership) {
|
||||
throw new Error("工作空间不存在或无权限");
|
||||
}
|
||||
|
||||
const target = await ctx.runQuery((internal as any).jobs._selectKernelAwareRefreshTargets, {
|
||||
workspaceId,
|
||||
userId: job.user_id,
|
||||
});
|
||||
const payload = await buildKernelAwareRefreshPayload(ctx, {
|
||||
workspaceId,
|
||||
userId: job.user_id,
|
||||
target,
|
||||
});
|
||||
const text = makeKernelAwareRefreshText({
|
||||
workspaceId,
|
||||
documentRows: payload.documentRows,
|
||||
mindmapRows: payload.mindmapRows,
|
||||
assetRows: payload.assetRows,
|
||||
});
|
||||
const ingest = await lightragIngestText({
|
||||
fileSource: `kernel-aware-refresh:${workspaceId}`,
|
||||
text,
|
||||
});
|
||||
|
||||
await Promise.all([
|
||||
...target.documentIds.slice(0, 6).map((documentId: string) =>
|
||||
ctx.runMutation(api.jobs.enqueueRagIndexDocument, { userId: job.user_id, documentId }).catch(() => null),
|
||||
),
|
||||
...target.mindmapRefs.slice(0, 4).map((item: { docId: string; mindmapId: string }) =>
|
||||
ctx.runMutation(api.jobs.enqueueRagIndexMindmap, {
|
||||
userId: job.user_id,
|
||||
docId: item.docId,
|
||||
mindmapId: item.mindmapId,
|
||||
}).catch(() => null),
|
||||
),
|
||||
...target.assetIds.slice(0, 4).map((assetId: string) =>
|
||||
ctx.runMutation(api.jobs.enqueueRagIndexMediaAsset, { userId: job.user_id, assetId }).catch(() => null),
|
||||
),
|
||||
]);
|
||||
|
||||
await ctx.runMutation(internal.jobs.finishSuccess, {
|
||||
id: args.id,
|
||||
result: {
|
||||
ok: true,
|
||||
kind: "kernel_aware_refresh",
|
||||
workspaceId,
|
||||
refreshMode: "kernel_aware_transition",
|
||||
bridge: {
|
||||
backend: "lightrag",
|
||||
fileSource: `kernel-aware-refresh:${workspaceId}`,
|
||||
skipped: Boolean(ingest.skipped),
|
||||
reason: ingest.reason ?? null,
|
||||
trackId: ingest.trackId,
|
||||
},
|
||||
refreshedDocuments: target.documentIds.length,
|
||||
refreshedMindmaps: target.mindmapRefs.length,
|
||||
refreshedAssets: target.assetIds.length,
|
||||
kernelPreview: {
|
||||
nodeIds: payload.documentRows.map((item) => item.id).slice(0, 8),
|
||||
subtreeRootIds: payload.documentRows.map((item) => item.id).slice(0, 8),
|
||||
evidenceAssetIds: payload.assetRows.map((item) => item.id).slice(0, 8),
|
||||
},
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error(`未知任务类型:${job.type}`);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
@@ -346,6 +753,23 @@ export const _getInternal = internalQuery({
|
||||
},
|
||||
});
|
||||
|
||||
export const _getWorkspaceMembership = internalQuery({
|
||||
args: { workspaceId: v.string(), userId: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
return await ctx.db
|
||||
.query("workspace_members")
|
||||
.withIndex("by_workspace_user", (q) => q.eq("workspace_id", args.workspaceId).eq("user_id", args.userId))
|
||||
.first();
|
||||
},
|
||||
});
|
||||
|
||||
export const _selectKernelAwareRefreshTargets = internalQuery({
|
||||
args: { workspaceId: v.string(), userId: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
return await selectKernelAwareRefreshTargets(ctx, args);
|
||||
},
|
||||
});
|
||||
|
||||
export const finishSuccess = internalMutation({
|
||||
args: { id: v.string(), result: v.any() },
|
||||
handler: async (ctx, args) => {
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { headers } from "next/headers";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { DocumentShell } from "@/components/editor/document-shell";
|
||||
import type { PageFont, PageLayoutDensity, PageOptionsState, DocumentStats } from "@/types/page-options";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { fetchDocumentMetaViaBridge } from "@/lib/documents/bridge-server";
|
||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||
import { buildDocumentBridgeContext, buildDocumentQueryEnvelope } from "@/lib/documents/bridge";
|
||||
import { buildPageSubtreeProjection } from "@/lib/documents/page-subtree";
|
||||
import { executeRustBridgeQueryTransport, resolveRustBridgeQueryPlan } from "@/lib/documents/rust-runtime";
|
||||
|
||||
interface DocumentPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
@@ -39,6 +44,84 @@ type DocumentMetaPayload = {
|
||||
todo_done_count?: number | null;
|
||||
};
|
||||
|
||||
type DocumentContentPayload = {
|
||||
content?: unknown;
|
||||
revision?: number | null;
|
||||
conflict_detection_key?: string | null;
|
||||
};
|
||||
|
||||
async function fetchDocumentContentOnServer(input: {
|
||||
documentId: string;
|
||||
workspaceId: string;
|
||||
}): Promise<{
|
||||
content: unknown;
|
||||
revision: number | null;
|
||||
conflictDetectionKey: string | null;
|
||||
}> {
|
||||
try {
|
||||
const headerList = await headers();
|
||||
const requestHeaders = new Headers();
|
||||
[
|
||||
"cookie",
|
||||
"authorization",
|
||||
"x-request-id",
|
||||
"x-trace-id",
|
||||
"x-session-id",
|
||||
"x-source-channel",
|
||||
"x-source-client",
|
||||
"user-agent",
|
||||
].forEach((name) => {
|
||||
const value = headerList.get(name);
|
||||
if (value) {
|
||||
requestHeaders.set(name, value);
|
||||
}
|
||||
});
|
||||
|
||||
const request = new Request("http://mnote.local/documents/content", {
|
||||
method: "GET",
|
||||
headers: requestHeaders,
|
||||
});
|
||||
const { client } = await getAuthedConvexClient();
|
||||
const bridgeContext = await buildDocumentBridgeContext({
|
||||
request,
|
||||
workspaceId: input.workspaceId,
|
||||
});
|
||||
const envelope = buildDocumentQueryEnvelope({
|
||||
name: "documents.content.get",
|
||||
payload: {
|
||||
documentId: input.documentId,
|
||||
workspaceId: input.workspaceId,
|
||||
},
|
||||
});
|
||||
const plan = await resolveRustBridgeQueryPlan({
|
||||
context: bridgeContext,
|
||||
envelope,
|
||||
});
|
||||
const result = await executeRustBridgeQueryTransport<DocumentContentPayload | null>({
|
||||
client,
|
||||
plan,
|
||||
});
|
||||
|
||||
return {
|
||||
content: result?.content ?? null,
|
||||
revision:
|
||||
typeof result?.revision === "number" && Number.isInteger(result.revision)
|
||||
? result.revision
|
||||
: 0,
|
||||
conflictDetectionKey:
|
||||
typeof result?.conflict_detection_key === "string" && result.conflict_detection_key.trim()
|
||||
? result.conflict_detection_key
|
||||
: `${input.documentId}:0`,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
content: null,
|
||||
revision: null,
|
||||
conflictDetectionKey: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default async function DocumentPage({ params, searchParams }: DocumentPageProps) {
|
||||
const { id } = await params;
|
||||
const resolvedSearch = (await searchParams) ?? {};
|
||||
@@ -83,6 +166,15 @@ export default async function DocumentPage({ params, searchParams }: DocumentPag
|
||||
todoTotal: doc.todo_total ?? doc.todo_total_count ?? 0,
|
||||
todoDone: doc.todo_done ?? doc.todo_done_count ?? 0,
|
||||
};
|
||||
const initialDocumentContent = await fetchDocumentContentOnServer({
|
||||
documentId: doc.id,
|
||||
workspaceId: doc.workspace_id,
|
||||
});
|
||||
const initialPageSubtree = buildPageSubtreeProjection({
|
||||
documentId: doc.id,
|
||||
title: doc.title ?? "无标题",
|
||||
content: initialDocumentContent.content,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex h-screen flex-col">
|
||||
@@ -92,9 +184,10 @@ export default async function DocumentPage({ params, searchParams }: DocumentPag
|
||||
workspaceId={doc.workspace_id}
|
||||
title={doc.title ?? "无标题"}
|
||||
updatedAt={doc.updated_at}
|
||||
initialContent={null}
|
||||
initialContentRevision={null}
|
||||
initialConflictDetectionKey={null}
|
||||
initialContent={initialDocumentContent.content}
|
||||
initialContentRevision={initialDocumentContent.revision}
|
||||
initialConflictDetectionKey={initialDocumentContent.conflictDetectionKey}
|
||||
initialPageSubtree={initialPageSubtree}
|
||||
initialOptions={initialOptions}
|
||||
initialStats={initialStats}
|
||||
openTableId={openTableId}
|
||||
|
||||
@@ -43,6 +43,10 @@ type RequestPayload = {
|
||||
mindmapId?: string;
|
||||
selectedUids?: string[];
|
||||
documentBlocks?: unknown;
|
||||
node?: unknown;
|
||||
subtree?: unknown;
|
||||
outline?: unknown;
|
||||
evidence?: unknown;
|
||||
};
|
||||
options?: {
|
||||
searxng?: boolean;
|
||||
@@ -85,6 +89,18 @@ const makeRunId = () => {
|
||||
return `run_${Date.now()}_${Math.random().toString(16).slice(2, 10)}`;
|
||||
};
|
||||
|
||||
const serializeContextSnapshot = (label: string, value: unknown, limit: number) => {
|
||||
if (value === undefined) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const text = JSON.stringify(value);
|
||||
return `${label}=${text.slice(0, limit)}`;
|
||||
} catch {
|
||||
return `${label}=provided`;
|
||||
}
|
||||
};
|
||||
|
||||
const clampSteps = (raw: unknown) => {
|
||||
const parsed = Number(raw ?? DEFAULT_AGENT_MAX_STEPS);
|
||||
if (!Number.isFinite(parsed)) return DEFAULT_AGENT_MAX_STEPS;
|
||||
@@ -157,12 +173,19 @@ const buildHermesInstructions = (
|
||||
lines.push(`selectedUids=${selectedUids.join(",")}`);
|
||||
}
|
||||
if (payload.context?.documentBlocks !== undefined) {
|
||||
try {
|
||||
const snapshot = JSON.stringify(payload.context.documentBlocks);
|
||||
lines.push(`documentBlocksSnapshot=${snapshot.slice(0, 4000)}`);
|
||||
} catch {
|
||||
lines.push("documentBlocksSnapshot=provided");
|
||||
}
|
||||
lines.push(serializeContextSnapshot("documentBlocksSnapshot", payload.context.documentBlocks, 4000) ?? "documentBlocksSnapshot=provided");
|
||||
}
|
||||
if (payload.context?.node !== undefined) {
|
||||
lines.push(serializeContextSnapshot("kernelNode", payload.context.node, 1800) ?? "kernelNode=provided");
|
||||
}
|
||||
if (payload.context?.subtree !== undefined) {
|
||||
lines.push(serializeContextSnapshot("kernelSubtree", payload.context.subtree, 5000) ?? "kernelSubtree=provided");
|
||||
}
|
||||
if (payload.context?.outline !== undefined) {
|
||||
lines.push(serializeContextSnapshot("kernelOutline", payload.context.outline, 2500) ?? "kernelOutline=provided");
|
||||
}
|
||||
if (payload.context?.evidence !== undefined) {
|
||||
lines.push(serializeContextSnapshot("kernelEvidence", payload.context.evidence, 2500) ?? "kernelEvidence=provided");
|
||||
}
|
||||
if (attachments.length > 0) {
|
||||
lines.push(
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { StandaloneMindmapView } from "@/components/editor/blocks/MindmapBlock";
|
||||
import type { MindmapProjection } from "@/lib/mindmap/mindmap-projection";
|
||||
|
||||
type MindmapPageClientProps = {
|
||||
docId: string;
|
||||
mindmapId: string;
|
||||
initialProjection: MindmapProjection | null;
|
||||
};
|
||||
|
||||
export default function MindmapPageClient({
|
||||
docId,
|
||||
mindmapId,
|
||||
initialProjection,
|
||||
}: MindmapPageClientProps) {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-white">
|
||||
<StandaloneMindmapView
|
||||
docId={docId}
|
||||
mindmapId={mindmapId}
|
||||
initialProjection={initialProjection}
|
||||
onExitFullscreen={() => router.push(`/documents/${docId}`)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,47 +1,110 @@
|
||||
"use client";
|
||||
import { headers } from "next/headers";
|
||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { buildDocumentBridgeContext, buildDocumentQueryEnvelope } from "@/lib/documents/bridge";
|
||||
import { executeRustBridgeQueryTransport, resolveRustBridgeQueryPlan } from "@/lib/documents/rust-runtime";
|
||||
import {
|
||||
buildMindmapProjection,
|
||||
defaultMindmapData,
|
||||
type MindmapProjection,
|
||||
} from "@/lib/mindmap/mindmap-projection";
|
||||
import MindmapPageClient from "./mindmap-page-client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import type { BlockNoteEditor } from "@blocknote/core";
|
||||
import type { CustomBlockSchema } from "@/components/editor/schema";
|
||||
import { MindmapBlockView, defaultMindmapData } from "@/components/editor/blocks/MindmapBlock";
|
||||
type MindmapRouteQueryResult = {
|
||||
data?: unknown;
|
||||
meta?: unknown;
|
||||
};
|
||||
|
||||
const editorStub = {
|
||||
updateBlock: () => {
|
||||
/* 独立全屏页中跳过 BlockNote 持久化(思维导图数据由自身 API 管理) */
|
||||
},
|
||||
} as unknown as BlockNoteEditor<CustomBlockSchema>;
|
||||
async function fetchMindmapProjectionOnServer(input: {
|
||||
docId: string;
|
||||
mindmapId: string;
|
||||
}): Promise<MindmapProjection | null> {
|
||||
if (!isConvexEnabled()) {
|
||||
return buildMindmapProjection({
|
||||
documentId: input.docId,
|
||||
mindmapId: input.mindmapId,
|
||||
data: defaultMindmapData,
|
||||
meta: null,
|
||||
});
|
||||
}
|
||||
|
||||
export default function MindmapFullscreenPage({
|
||||
}: Record<string, never>) {
|
||||
const router = useRouter();
|
||||
const params = useParams<{ docId?: string; mindmapId?: string }>();
|
||||
const docId = params?.docId ?? "";
|
||||
const mindmapId = params?.mindmapId ?? "";
|
||||
try {
|
||||
const headerList = await headers();
|
||||
const requestHeaders = new Headers();
|
||||
[
|
||||
"cookie",
|
||||
"authorization",
|
||||
"x-request-id",
|
||||
"x-trace-id",
|
||||
"x-session-id",
|
||||
"x-source-channel",
|
||||
"x-source-client",
|
||||
"user-agent",
|
||||
].forEach((name) => {
|
||||
const value = headerList.get(name);
|
||||
if (value) {
|
||||
requestHeaders.set(name, value);
|
||||
}
|
||||
});
|
||||
|
||||
const stubBlock = useMemo(
|
||||
() =>
|
||||
({
|
||||
id: mindmapId,
|
||||
type: "mindmap",
|
||||
props: {
|
||||
docId,
|
||||
data: defaultMindmapData,
|
||||
},
|
||||
content: [],
|
||||
children: [],
|
||||
}) as any,
|
||||
[docId, mindmapId],
|
||||
);
|
||||
const request = new Request("http://mnote.local/mindmap/projection", {
|
||||
method: "GET",
|
||||
headers: requestHeaders,
|
||||
});
|
||||
const { client } = await getAuthedConvexClient();
|
||||
const context = await buildDocumentBridgeContext({
|
||||
request,
|
||||
workspaceId: null,
|
||||
});
|
||||
const envelope = buildDocumentQueryEnvelope({
|
||||
name: "mindmaps.get",
|
||||
payload: {
|
||||
documentId: input.docId,
|
||||
mindmapId: input.mindmapId,
|
||||
workspaceId: null,
|
||||
},
|
||||
});
|
||||
const plan = await resolveRustBridgeQueryPlan({
|
||||
context,
|
||||
envelope,
|
||||
});
|
||||
const result = await executeRustBridgeQueryTransport<MindmapRouteQueryResult | null>({
|
||||
client,
|
||||
plan,
|
||||
});
|
||||
|
||||
return buildMindmapProjection({
|
||||
documentId: input.docId,
|
||||
mindmapId: input.mindmapId,
|
||||
data: result?.data ?? defaultMindmapData,
|
||||
meta: result?.meta ?? {
|
||||
requestId: context.requestId,
|
||||
traceId: context.traceId,
|
||||
workspaceId: context.workspaceId,
|
||||
documentId: input.docId,
|
||||
pageId: input.docId,
|
||||
mindmapId: input.mindmapId,
|
||||
attachmentId: input.mindmapId,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export default async function MindmapFullscreenPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ docId: string; mindmapId: string }>;
|
||||
}) {
|
||||
const { docId, mindmapId } = await params;
|
||||
const initialProjection = await fetchMindmapProjectionOnServer({ docId, mindmapId });
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-white">
|
||||
<MindmapBlockView
|
||||
block={stubBlock}
|
||||
editor={editorStub}
|
||||
fullscreen
|
||||
onExitFullscreen={() => router.push(`/documents/${docId}`)}
|
||||
/>
|
||||
</div>
|
||||
<MindmapPageClient
|
||||
docId={docId}
|
||||
mindmapId={mindmapId}
|
||||
initialProjection={initialProjection}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet";
|
||||
import { useAiAgentUiStore } from "@/store/ai-agent-ui";
|
||||
import { AiAgentPanel } from "./AiAgentPanel";
|
||||
|
||||
const AiAgentPanelIsland = dynamic(
|
||||
() => import("./AiAgentPanel").then((mod) => mod.AiAgentPanel),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => null,
|
||||
},
|
||||
);
|
||||
|
||||
export function GlobalAiAgentHost() {
|
||||
const open = useAiAgentUiStore((s) => s.globalAgentOpen);
|
||||
const activated = useAiAgentUiStore((s) => s.globalAgentActivated);
|
||||
const setOpen = useAiAgentUiStore((s) => s.setGlobalAgentOpen);
|
||||
|
||||
return (
|
||||
@@ -16,7 +25,7 @@ export function GlobalAiAgentHost() {
|
||||
className="w-[min(1500px,calc(100vw-24px))] max-w-none border-l-0 bg-transparent p-3 shadow-none sm:max-w-none"
|
||||
>
|
||||
<SheetTitle className="sr-only">MNOTE 全局 AI</SheetTitle>
|
||||
<AiAgentPanel onClose={() => setOpen(false)} />
|
||||
{activated ? <AiAgentPanelIsland onClose={() => setOpen(false)} /> : null}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -21,6 +21,10 @@ import { useCommentsUiStore } from "@/store/comments-ui";
|
||||
import { useAppPreferencesStore } from "@/store/app-preferences";
|
||||
import { useMoveEmbedPickerStore } from "@/store/move-embed-picker";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DocumentToc } from "@/components/editor/document-toc";
|
||||
import { DocumentReadView } from "@/components/editor/document-read-view";
|
||||
import { buildPageSubtreeProjection, extractPageBlocks, type PageSubtreeProjection } from "@/lib/documents/page-subtree";
|
||||
|
||||
const BlockNoteEditor = dynamic(
|
||||
() => import("@/components/editor/blocknote-editor").then((mod) => mod.BlockNoteEditor),
|
||||
@@ -40,6 +44,7 @@ export interface DocumentContentProps {
|
||||
initialContent: unknown;
|
||||
initialContentRevision?: number | null;
|
||||
initialConflictDetectionKey?: string | null;
|
||||
initialPageSubtree?: PageSubtreeProjection | null;
|
||||
initialOptions: PageOptionsState;
|
||||
initialStats: DocumentStats | null;
|
||||
openTableId?: string | null;
|
||||
@@ -64,6 +69,7 @@ const defaultOptions: PageOptionsState = {
|
||||
embedDefaultBlockId: null,
|
||||
};
|
||||
const defaultStats: DocumentStats = { wordCount: 0, characterCount: 0, blockCount: 0, todoTotal: 0, todoDone: 0 };
|
||||
const EDITOR_UNMOUNT_GRACE_MS = 1000;
|
||||
|
||||
export function DocumentContent({
|
||||
documentId,
|
||||
@@ -73,6 +79,7 @@ export function DocumentContent({
|
||||
initialContent,
|
||||
initialContentRevision = null,
|
||||
initialConflictDetectionKey = null,
|
||||
initialPageSubtree = null,
|
||||
initialOptions,
|
||||
initialStats,
|
||||
openTableId,
|
||||
@@ -82,6 +89,7 @@ export function DocumentContent({
|
||||
}: DocumentContentProps) {
|
||||
const setCurrentDocument = useCurrentDocumentStore((state) => state.setCurrent);
|
||||
const clearIfMatch = useCurrentDocumentStore((state) => state.clearIfMatch);
|
||||
const canEditDocument = !readOnly;
|
||||
const [options, setOptions] = useState<PageOptionsState>(initialOptions ?? defaultOptions);
|
||||
const [stats, setStats] = useState<DocumentStats>(initialStats ?? defaultStats);
|
||||
const [history, setHistory] = useState<DocumentSnapshot[]>([]);
|
||||
@@ -100,10 +108,15 @@ export function DocumentContent({
|
||||
const [contentError, setContentError] = useState<string | null>(null);
|
||||
const [contentReloadKey, setContentReloadKey] = useState(0);
|
||||
const [showContentLoadingIndicator, setShowContentLoadingIndicator] = useState(false);
|
||||
const [isEditing, setIsEditing] = useState(() => Boolean((openTableId ?? "").trim()) && !readOnly);
|
||||
const [keepEditorMounted, setKeepEditorMounted] = useState(() => Boolean((openTableId ?? "").trim()) && !readOnly);
|
||||
const contentLoadingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const editorUnmountTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const pendingOpenTableRef = useRef<string | null>(null);
|
||||
const latestBlocksRef = useRef<Json | null>(null);
|
||||
const pageRootRef = useRef<HTMLDivElement>(null);
|
||||
const readViewRootRef = useRef<HTMLDivElement>(null);
|
||||
const pendingRestoreSnapshotRef = useRef<DocumentSnapshot | null>(null);
|
||||
const lastCopyBlockedAtRef = useRef<number>(0);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -116,20 +129,22 @@ export function DocumentContent({
|
||||
useEffect(() => {
|
||||
if (!disableCopy) return;
|
||||
|
||||
const toElement = (node: Node | null): Element | null => {
|
||||
if (!node) return null;
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
return node.parentElement;
|
||||
}
|
||||
return node instanceof Element ? node : null;
|
||||
};
|
||||
|
||||
const isEventInsidePage = () => {
|
||||
const root = pageRootRef.current;
|
||||
if (!root) return false;
|
||||
const selection = typeof window !== "undefined" ? window.getSelection() : null;
|
||||
const anchor = selection?.anchorNode ?? null;
|
||||
const focus = selection?.focusNode ?? null;
|
||||
const anchorEl =
|
||||
anchor && "nodeType" in anchor && anchor.nodeType === Node.TEXT_NODE
|
||||
? anchor.parentElement
|
||||
: (anchor as any as Element | null);
|
||||
const focusEl =
|
||||
focus && "nodeType" in focus && focus.nodeType === Node.TEXT_NODE
|
||||
? focus.parentElement
|
||||
: (focus as any as Element | null);
|
||||
const anchorEl = toElement(anchor);
|
||||
const focusEl = toElement(focus);
|
||||
return Boolean((anchorEl && root.contains(anchorEl)) || (focusEl && root.contains(focusEl)));
|
||||
};
|
||||
|
||||
@@ -218,7 +233,43 @@ export function DocumentContent({
|
||||
useEffect(() => {
|
||||
setConflictDetectionKey(initialConflictDetectionKey);
|
||||
}, [initialConflictDetectionKey]);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
const nextBlocks = extractPageBlocks(content);
|
||||
latestBlocksRef.current = nextBlocks.length > 0 ? (nextBlocks as Json) : null;
|
||||
}, [content]);
|
||||
|
||||
useEffect(() => {
|
||||
const shouldForceEdit = Boolean((openTableId ?? "").trim()) && canEditDocument;
|
||||
if (shouldForceEdit) {
|
||||
setIsEditing(true);
|
||||
setKeepEditorMounted(true);
|
||||
}
|
||||
}, [canEditDocument, openTableId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditing) {
|
||||
if (editorUnmountTimerRef.current) {
|
||||
clearTimeout(editorUnmountTimerRef.current);
|
||||
editorUnmountTimerRef.current = null;
|
||||
}
|
||||
setKeepEditorMounted(true);
|
||||
return;
|
||||
}
|
||||
if (editorUnmountTimerRef.current) {
|
||||
clearTimeout(editorUnmountTimerRef.current);
|
||||
}
|
||||
editorUnmountTimerRef.current = setTimeout(() => {
|
||||
setKeepEditorMounted(false);
|
||||
editorUnmountTimerRef.current = null;
|
||||
}, EDITOR_UNMOUNT_GRACE_MS);
|
||||
return () => {
|
||||
if (editorUnmountTimerRef.current) {
|
||||
clearTimeout(editorUnmountTimerRef.current);
|
||||
editorUnmountTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [isEditing]);
|
||||
|
||||
useEffect(() => {
|
||||
let canceled = false;
|
||||
@@ -321,14 +372,14 @@ export function DocumentContent({
|
||||
}, 600);
|
||||
|
||||
const handleTitleChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
if (readOnly) return;
|
||||
if (!canEditDocument) return;
|
||||
const value = event.target.value;
|
||||
setPageTitle(value);
|
||||
debouncedPersistTitle(value);
|
||||
};
|
||||
|
||||
const handleTitleBlur = () => {
|
||||
if (readOnly) return;
|
||||
if (!canEditDocument) return;
|
||||
void persistTitle(pageTitle);
|
||||
};
|
||||
|
||||
@@ -409,7 +460,11 @@ export function DocumentContent({
|
||||
);
|
||||
|
||||
const handleSetEmbedDefaultToCursor = useCallback(() => {
|
||||
if (readOnly) return;
|
||||
if (!canEditDocument) return;
|
||||
if (!isEditing) {
|
||||
setIsEditing(true);
|
||||
return;
|
||||
}
|
||||
const blockId = editorBridge?.getCursorBlockId?.() ?? null;
|
||||
if (!blockId) {
|
||||
window.alert("未找到当前光标所在块,请先在编辑器中点击任意块");
|
||||
@@ -417,7 +472,7 @@ export function DocumentContent({
|
||||
}
|
||||
setOptionPatch({ embedDefaultBlockId: blockId });
|
||||
window.alert("已设置“嵌入默认位置”");
|
||||
}, [editorBridge, readOnly, setOptionPatch]);
|
||||
}, [canEditDocument, editorBridge, isEditing, setOptionPatch]);
|
||||
|
||||
const handleClearEmbedDefault = useCallback(() => {
|
||||
if (readOnly) return;
|
||||
@@ -492,12 +547,20 @@ export function DocumentContent({
|
||||
);
|
||||
|
||||
const handleUndo = useCallback(() => {
|
||||
if (!isEditing) {
|
||||
setIsEditing(true);
|
||||
return;
|
||||
}
|
||||
editorBridge?.undo?.();
|
||||
}, [editorBridge]);
|
||||
}, [editorBridge, isEditing]);
|
||||
|
||||
const handleRedo = useCallback(() => {
|
||||
if (!isEditing) {
|
||||
setIsEditing(true);
|
||||
return;
|
||||
}
|
||||
editorBridge?.redo?.();
|
||||
}, [editorBridge]);
|
||||
}, [editorBridge, isEditing]);
|
||||
|
||||
const handleDeletePage = useCallback(async () => {
|
||||
if (readOnly) return;
|
||||
@@ -586,16 +649,17 @@ export function DocumentContent({
|
||||
return;
|
||||
}
|
||||
const latest = history[0];
|
||||
if (!latest) {
|
||||
const exportBlocks = latest?.blocks ?? latestBlocksRef.current;
|
||||
if (!exportBlocks) {
|
||||
window.alert("暂无可导出的内容");
|
||||
return;
|
||||
}
|
||||
const payload = JSON.stringify(latest.blocks, null, 2);
|
||||
const payload = JSON.stringify(exportBlocks, null, 2);
|
||||
const blob = new Blob([payload], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = `${title ?? "未命名页面"}-${new Date(latest.timestamp).toISOString()}.json`;
|
||||
anchor.download = `${title ?? "未命名页面"}-${new Date().toISOString()}.json`;
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}, [disableDownload, history, title]);
|
||||
@@ -609,8 +673,40 @@ export function DocumentContent({
|
||||
options.smallText && "wolai-small-text",
|
||||
options.hideChildPages && "wolai-hide-child-pages",
|
||||
);
|
||||
const pageSubtree = useMemo(() => {
|
||||
if (initialPageSubtree && content === initialContent && pageTitle === (title ?? "无标题")) {
|
||||
return initialPageSubtree;
|
||||
}
|
||||
return buildPageSubtreeProjection({
|
||||
documentId,
|
||||
title: pageTitle,
|
||||
content,
|
||||
});
|
||||
}, [content, documentId, initialContent, initialPageSubtree, pageTitle, title]);
|
||||
const readViewTocEntries = useMemo(
|
||||
() =>
|
||||
pageSubtree.outline
|
||||
.filter((entry) => typeof entry.anchorBlockId === "string" && entry.anchorBlockId.trim())
|
||||
.map(({ anchorBlockId, level, numbering, title: entryTitle }) => ({
|
||||
id: anchorBlockId as string,
|
||||
level,
|
||||
numbering,
|
||||
title: entryTitle,
|
||||
})),
|
||||
[pageSubtree],
|
||||
);
|
||||
const inspectorCanUseEditorBridge = canEditDocument && isEditing;
|
||||
|
||||
const jumpToHeading = useCallback((headingId: string) => {
|
||||
const targetRoot = !isEditing ? readViewRootRef.current : pageRootRef.current;
|
||||
const target = targetRoot?.querySelector<HTMLElement>(`[data-id="${headingId}"]`);
|
||||
if (target) {
|
||||
target.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
}
|
||||
}, [isEditing]);
|
||||
|
||||
const handleSnapshot = useCallback((payload: { blocks: Json; stats: DocumentStats }) => {
|
||||
setContent(payload.blocks);
|
||||
latestBlocksRef.current = payload.blocks;
|
||||
setHistory((prev) => {
|
||||
const now = Date.now();
|
||||
@@ -647,42 +743,86 @@ export function DocumentContent({
|
||||
|
||||
const handleRestoreSnapshot = useCallback(
|
||||
(snapshot: DocumentSnapshot) => {
|
||||
if (!editorBridge) {
|
||||
window.alert("编辑器尚未准备好,无法恢复历史版本");
|
||||
if (!isEditing || !editorBridge) {
|
||||
pendingRestoreSnapshotRef.current = snapshot;
|
||||
setIsEditing(true);
|
||||
return;
|
||||
}
|
||||
editorBridge.replaceWithSnapshot(snapshot.blocks);
|
||||
setHistoryOpen(false);
|
||||
},
|
||||
[editorBridge],
|
||||
[editorBridge, isEditing],
|
||||
);
|
||||
|
||||
const handleEnterEditMode = useCallback(() => {
|
||||
if (!canEditDocument) return;
|
||||
setIsEditing(true);
|
||||
}, [canEditDocument]);
|
||||
|
||||
const handleExitEditMode = useCallback(() => {
|
||||
setIsEditing(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEditing) return;
|
||||
if (!editorBridge) return;
|
||||
const pendingSnapshot = pendingRestoreSnapshotRef.current;
|
||||
if (!pendingSnapshot) return;
|
||||
pendingRestoreSnapshotRef.current = null;
|
||||
editorBridge.replaceWithSnapshot(pendingSnapshot.blocks);
|
||||
setHistoryOpen(false);
|
||||
}, [editorBridge, isEditing]);
|
||||
return (
|
||||
<ImagePickerProvider documentId={documentId} workspaceId={workspaceId}>
|
||||
<div className={pageRootClass} ref={pageRootRef}>
|
||||
<div className="flex h-full flex-1 flex-col overflow-hidden">
|
||||
<div className="border-b border-wolai-border px-12 pb-6 pt-8">
|
||||
<div className="relative">
|
||||
<input
|
||||
value={pageTitle}
|
||||
onChange={handleTitleChange}
|
||||
onBlur={handleTitleBlur}
|
||||
onKeyDown={handleTitleKeyDown}
|
||||
placeholder="无标题"
|
||||
className="w-full border-none bg-transparent text-3xl font-semibold text-wolai-text-primary outline-none focus:ring-0"
|
||||
aria-label="页面标题"
|
||||
disabled={options.protectEditing || readOnly}
|
||||
spellCheck={spellCheck}
|
||||
/>
|
||||
<div className="flex items-start justify-between gap-6">
|
||||
<div className="min-w-0 flex-1">
|
||||
{isEditing && canEditDocument ? (
|
||||
<div className="relative">
|
||||
<input
|
||||
value={pageTitle}
|
||||
onChange={handleTitleChange}
|
||||
onBlur={handleTitleBlur}
|
||||
onKeyDown={handleTitleKeyDown}
|
||||
placeholder="无标题"
|
||||
className="w-full border-none bg-transparent text-3xl font-semibold text-wolai-text-primary outline-none focus:ring-0"
|
||||
aria-label="页面标题"
|
||||
disabled={options.protectEditing || readOnly}
|
||||
spellCheck={spellCheck}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<h1 className="break-words text-3xl font-semibold text-wolai-text-primary">
|
||||
{pageTitle || "无标题"}
|
||||
</h1>
|
||||
)}
|
||||
{readOnly ? (
|
||||
<p className="mt-1 text-sm text-gray-500">该页面为只读共享,无法编辑。</p>
|
||||
) : options.protectEditing && isEditing ? (
|
||||
<p className="mt-1 text-sm text-[#b91c1c]">当前页面已开启编辑保护,关闭后方可修改内容。</p>
|
||||
) : !isEditing && canEditDocument ? (
|
||||
<p className="mt-1 text-sm text-gray-500">当前为阅读态,编辑器仅在进入编辑后挂载。</p>
|
||||
) : null}
|
||||
<p className="text-sm text-wolai-text-secondary">最近更新:{formattedUpdatedAt}</p>
|
||||
</div>
|
||||
{canEditDocument && (
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{isEditing ? (
|
||||
<Button type="button" variant="outline" size="sm" onClick={handleExitEditMode}>
|
||||
返回阅读
|
||||
</Button>
|
||||
) : (
|
||||
<Button type="button" size="sm" onClick={handleEnterEditMode}>
|
||||
进入编辑
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{readOnly ? (
|
||||
<p className="mt-1 text-sm text-gray-500">该页面为只读共享,无法编辑。</p>
|
||||
) : options.protectEditing ? (
|
||||
<p className="mt-1 text-sm text-[#b91c1c]">当前页面已开启编辑保护,关闭后方可修改内容。</p>
|
||||
) : null}
|
||||
<p className="text-sm text-wolai-text-secondary">最近更新:{formattedUpdatedAt}</p>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto px-12 py-6">
|
||||
<div className="relative flex-1 overflow-y-auto px-12 py-6">
|
||||
{contentLoading ? (
|
||||
showContentLoadingIndicator ? (
|
||||
<div className="flex h-64 items-center justify-center text-sm text-gray-400">
|
||||
@@ -707,22 +847,45 @@ export function DocumentContent({
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<BlockNoteEditor
|
||||
documentId={documentId}
|
||||
workspaceId={workspaceId}
|
||||
initialContent={content}
|
||||
initialRevision={contentRevision}
|
||||
initialConflictDetectionKey={conflictDetectionKey}
|
||||
pageOptions={options}
|
||||
readOnly={readOnly}
|
||||
onStatsChange={handleStatsChange}
|
||||
onSnapshot={handleSnapshot}
|
||||
onCloseToc={closeToc}
|
||||
onPersistedMetaChange={({ revision, conflictDetectionKey: nextConflictDetectionKey }) => {
|
||||
setContentRevision(revision);
|
||||
setConflictDetectionKey(nextConflictDetectionKey);
|
||||
}}
|
||||
/>
|
||||
<div className="relative">
|
||||
{keepEditorMounted && (
|
||||
<div className={cn(!isEditing && "pointer-events-none absolute inset-0 opacity-0")} aria-hidden={!isEditing}>
|
||||
<BlockNoteEditor
|
||||
documentId={documentId}
|
||||
workspaceId={workspaceId}
|
||||
initialContent={content}
|
||||
initialRevision={contentRevision}
|
||||
initialConflictDetectionKey={conflictDetectionKey}
|
||||
pageOptions={options}
|
||||
readOnly={readOnly}
|
||||
onStatsChange={handleStatsChange}
|
||||
onSnapshot={handleSnapshot}
|
||||
onCloseToc={closeToc}
|
||||
onPersistedMetaChange={({ revision, conflictDetectionKey: nextConflictDetectionKey }) => {
|
||||
setContentRevision(revision);
|
||||
setConflictDetectionKey(nextConflictDetectionKey);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{!isEditing && (
|
||||
<div className="relative" ref={readViewRootRef}>
|
||||
<DocumentReadView
|
||||
content={content}
|
||||
documentId={documentId}
|
||||
options={options}
|
||||
pageSubtree={pageSubtree}
|
||||
className="mx-auto w-full max-w-[980px]"
|
||||
/>
|
||||
<DocumentToc
|
||||
entries={readViewTocEntries}
|
||||
visible={options.showToc}
|
||||
onJump={jumpToHeading}
|
||||
onClose={closeToc}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<PageBacklinksPanel
|
||||
className="mt-10"
|
||||
@@ -740,18 +903,18 @@ export function DocumentContent({
|
||||
onToggle={toggleOption}
|
||||
onSetPageFont={handleSetPageFont}
|
||||
onSetLayoutDensity={handleSetLayoutDensity}
|
||||
onSetEmbedDefaultToCursor={handleSetEmbedDefaultToCursor}
|
||||
onSetEmbedDefaultToCursor={inspectorCanUseEditorBridge ? handleSetEmbedDefaultToCursor : undefined}
|
||||
onClearEmbedDefault={handleClearEmbedDefault}
|
||||
onExport={handleExport}
|
||||
onOpenHistory={() => setHistoryOpen(true)}
|
||||
onOpenComments={() => openCommentsForPage({ workspaceId, documentId })}
|
||||
onUndo={handleUndo}
|
||||
onRedo={handleRedo}
|
||||
onDeletePage={handleDeletePage}
|
||||
onOpenMoveEmbedPicker={handleOpenMoveEmbed}
|
||||
onUndo={canEditDocument ? handleUndo : undefined}
|
||||
onRedo={canEditDocument ? handleRedo : undefined}
|
||||
onDeletePage={canEditDocument ? handleDeletePage : undefined}
|
||||
onOpenMoveEmbedPicker={canEditDocument ? handleOpenMoveEmbed : undefined}
|
||||
onCopyPageLink={handleCopyPageLink}
|
||||
onCopyPageReference={handleCopyPageReference}
|
||||
onAddToTemplates={handleAddToTemplates}
|
||||
onAddToTemplates={canEditDocument ? handleAddToTemplates : undefined}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -762,7 +925,11 @@ export function DocumentContent({
|
||||
onRestore={handleRestoreSnapshot}
|
||||
/>
|
||||
<DocumentCommentsDrawer />
|
||||
<DocumentAiAgentPanel documentId={documentId} getLatestBlocks={() => latestBlocksRef.current} />
|
||||
<DocumentAiAgentPanel
|
||||
documentId={documentId}
|
||||
getLatestBlocks={() => latestBlocksRef.current}
|
||||
getLatestPageSubtree={() => pageSubtree}
|
||||
/>
|
||||
</ImagePickerProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,615 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import type { CSSProperties, ReactNode } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { TocEntry } from "@/components/editor/document-toc";
|
||||
import {
|
||||
buildPageSubtreeProjection,
|
||||
clampHeadingLevel,
|
||||
extractPageBlocks,
|
||||
getInlineText,
|
||||
getPageBlockChildren,
|
||||
type PageOutlineEntry,
|
||||
type PageSubtreeBlock,
|
||||
type PageSubtreeInlineNode,
|
||||
type PageSubtreeProjection,
|
||||
} from "@/lib/documents/page-subtree";
|
||||
import type { PageOptionsState } from "@/types/page-options";
|
||||
|
||||
interface DocumentReadViewProps {
|
||||
content: unknown;
|
||||
documentId: string;
|
||||
options: PageOptionsState;
|
||||
pageSubtree?: PageSubtreeProjection | null;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const TODO_STATUS_LABELS: Record<string, string> = {
|
||||
todo: "未开始",
|
||||
doing: "进行中",
|
||||
done: "已完成",
|
||||
cancelled: "已取消",
|
||||
};
|
||||
|
||||
const LIST_INDENT_CLASS = [
|
||||
"",
|
||||
"ml-5",
|
||||
"ml-9",
|
||||
"ml-12",
|
||||
"ml-16",
|
||||
];
|
||||
|
||||
const toInlineNodes = (value: unknown): PageSubtreeInlineNode[] => {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
return value as PageSubtreeInlineNode[];
|
||||
};
|
||||
|
||||
const buildTextStyle = (styles: Record<string, unknown> | undefined): CSSProperties => {
|
||||
const style: CSSProperties = {};
|
||||
const textColor = styles?.textColor;
|
||||
const backgroundColor = styles?.backgroundColor;
|
||||
if (typeof textColor === "string" && textColor.trim()) {
|
||||
style.color = textColor;
|
||||
}
|
||||
if (typeof backgroundColor === "string" && backgroundColor.trim()) {
|
||||
style.backgroundColor = backgroundColor;
|
||||
}
|
||||
return style;
|
||||
};
|
||||
|
||||
const applyInlineMarks = (
|
||||
content: ReactNode,
|
||||
styles: Record<string, unknown> | undefined,
|
||||
key: string,
|
||||
): ReactNode => {
|
||||
let node = content;
|
||||
if (styles?.bold) {
|
||||
node = <strong key={`${key}-bold`}>{node}</strong>;
|
||||
}
|
||||
if (styles?.italic) {
|
||||
node = <em key={`${key}-italic`}>{node}</em>;
|
||||
}
|
||||
if (styles?.underline) {
|
||||
node = <u key={`${key}-underline`}>{node}</u>;
|
||||
}
|
||||
if (styles?.strike) {
|
||||
node = <s key={`${key}-strike`}>{node}</s>;
|
||||
}
|
||||
if (styles?.code) {
|
||||
node = (
|
||||
<code
|
||||
key={`${key}-code`}
|
||||
className="rounded bg-[#f4f4f5] px-1 py-0.5 font-mono text-[0.92em] text-[#d97706]"
|
||||
>
|
||||
{node}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
|
||||
const style = buildTextStyle(styles);
|
||||
if (Object.keys(style).length > 0) {
|
||||
node = (
|
||||
<span key={`${key}-style`} style={style}>
|
||||
{node}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return node;
|
||||
};
|
||||
|
||||
const renderInlineNode = (node: unknown, key: string): ReactNode => {
|
||||
if (typeof node === "string") {
|
||||
return node;
|
||||
}
|
||||
if (!node || typeof node !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const typedNode = node as PageSubtreeInlineNode;
|
||||
if (typedNode.type === "link") {
|
||||
const href = typeof typedNode.href === "string" && typedNode.href.trim() ? typedNode.href : "#";
|
||||
const textContent = renderInlineNodes(typedNode.content, `${key}-content`);
|
||||
return (
|
||||
<a
|
||||
key={key}
|
||||
href={href}
|
||||
target={href.startsWith("/") ? undefined : "_blank"}
|
||||
rel={href.startsWith("/") ? undefined : "noreferrer"}
|
||||
className="text-[#2563eb] underline underline-offset-2"
|
||||
>
|
||||
{textContent.length > 0 ? textContent : href}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
const text = typeof typedNode.text === "string" ? typedNode.text : "";
|
||||
return (
|
||||
<span key={key}>
|
||||
{applyInlineMarks(text, typedNode.styles, key)}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const renderInlineNodes = (nodes: unknown, keyPrefix: string): ReactNode[] => {
|
||||
return toInlineNodes(nodes).map((node, index) => renderInlineNode(node, `${keyPrefix}-${index}`));
|
||||
};
|
||||
|
||||
const buildHeadingNumberingMap = (blocks: PageSubtreeBlock[]): Map<string, string> => {
|
||||
const counters = [0, 0, 0, 0, 0];
|
||||
const numberingById = new Map<string, string>();
|
||||
|
||||
const walk = (targetBlocks: PageSubtreeBlock[]) => {
|
||||
targetBlocks.forEach((block) => {
|
||||
if (block.type === "heading") {
|
||||
const level = clampHeadingLevel(block.props?.level);
|
||||
counters[level - 1] += 1;
|
||||
for (let index = level; index < counters.length; index += 1) {
|
||||
counters[index] = 0;
|
||||
}
|
||||
if (block.id) {
|
||||
numberingById.set(
|
||||
block.id,
|
||||
counters
|
||||
.slice(0, level)
|
||||
.filter((value) => value > 0)
|
||||
.join("."),
|
||||
);
|
||||
}
|
||||
}
|
||||
const children = getPageBlockChildren(block.children);
|
||||
if (children.length > 0) {
|
||||
walk(children);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
walk(blocks);
|
||||
return numberingById;
|
||||
};
|
||||
|
||||
const buildHeadingNumberingMapFromOutline = (outline: PageOutlineEntry[]): Map<string, string> => {
|
||||
return new Map(
|
||||
outline
|
||||
.filter((entry) => entry.anchorBlockId)
|
||||
.map((entry) => [entry.anchorBlockId as string, entry.numbering]),
|
||||
);
|
||||
};
|
||||
|
||||
export const extractReadViewBlocks = (content: unknown): PageSubtreeBlock[] => extractPageBlocks(content);
|
||||
|
||||
export const buildReadViewTocEntries = (blocks: PageSubtreeBlock[]): TocEntry[] => {
|
||||
return buildPageSubtreeProjection({
|
||||
documentId: "preview",
|
||||
title: "预览",
|
||||
content: blocks,
|
||||
}).outline.map(({ id, level, numbering, title }) => ({
|
||||
id,
|
||||
level,
|
||||
numbering,
|
||||
title,
|
||||
}));
|
||||
};
|
||||
|
||||
const renderChildren = (
|
||||
block: PageSubtreeBlock,
|
||||
options: PageOptionsState,
|
||||
documentId: string,
|
||||
headingNumberingById: Map<string, string>,
|
||||
depth: number,
|
||||
) => {
|
||||
const children = getPageBlockChildren(block.children);
|
||||
if (children.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div className={cn("mt-2 space-y-1", LIST_INDENT_CLASS[Math.min(depth + 1, LIST_INDENT_CLASS.length - 1)])}>
|
||||
{renderBlocks(children, options, documentId, headingNumberingById, depth + 1)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderMediaBlock = (block: PageSubtreeBlock) => {
|
||||
const props = block.props ?? {};
|
||||
const assetType = String(props.assetType ?? "image");
|
||||
const fileUrl = typeof props.fileUrl === "string" ? props.fileUrl : "";
|
||||
const thumbnailUrl =
|
||||
typeof props.thumbnailUrl === "string" && props.thumbnailUrl.trim() ? props.thumbnailUrl : fileUrl;
|
||||
const fileName =
|
||||
typeof props.fileName === "string" && props.fileName.trim() ? props.fileName : "未命名资源";
|
||||
const caption = typeof props.caption === "string" ? props.caption.trim() : "";
|
||||
|
||||
if (!fileUrl) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-dashed border-[#d4d4d8] bg-[#fafafa] px-4 py-6 text-sm text-[#71717a]">
|
||||
资源链接不可用
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (assetType === "image") {
|
||||
return (
|
||||
<figure className="space-y-3">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={thumbnailUrl}
|
||||
alt={caption || fileName}
|
||||
className="max-h-[560px] w-auto max-w-full rounded-2xl border border-[#f1f5f9] object-contain shadow-sm"
|
||||
/>
|
||||
{(caption || fileName) && (
|
||||
<figcaption className="text-sm text-[#71717a]">{caption || fileName}</figcaption>
|
||||
)}
|
||||
</figure>
|
||||
);
|
||||
}
|
||||
|
||||
if (assetType === "video") {
|
||||
return (
|
||||
<figure className="space-y-3">
|
||||
<video controls className="max-h-[560px] w-full rounded-2xl border border-[#f1f5f9] bg-black">
|
||||
<source src={fileUrl} />
|
||||
</video>
|
||||
{(caption || fileName) && (
|
||||
<figcaption className="text-sm text-[#71717a]">{caption || fileName}</figcaption>
|
||||
)}
|
||||
</figure>
|
||||
);
|
||||
}
|
||||
|
||||
if (assetType === "audio") {
|
||||
return (
|
||||
<div className="space-y-3 rounded-2xl border border-[#e4e4e7] bg-[#fafafa] p-4">
|
||||
<div className="text-sm font-medium text-[#27272a]">{caption || fileName}</div>
|
||||
<audio controls className="w-full">
|
||||
<source src={fileUrl} />
|
||||
</audio>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<a
|
||||
href={fileUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="flex items-center justify-between rounded-2xl border border-[#e4e4e7] bg-[#fafafa] px-4 py-3 text-sm text-[#27272a] transition hover:border-[#cbd5e1] hover:bg-white"
|
||||
>
|
||||
<span className="truncate">{caption || fileName}</span>
|
||||
<span className="ml-4 shrink-0 text-xs text-[#71717a]">打开附件</span>
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
const renderBlock = (
|
||||
block: PageSubtreeBlock,
|
||||
options: PageOptionsState,
|
||||
documentId: string,
|
||||
headingNumberingById: Map<string, string>,
|
||||
depth: number,
|
||||
orderedIndex: number,
|
||||
): ReactNode => {
|
||||
const key = block.id ?? `${block.type ?? "block"}-${depth}-${orderedIndex}`;
|
||||
const props = block.props ?? {};
|
||||
const inlineContent = renderInlineNodes(block.content, key);
|
||||
const plainText = getInlineText(block.content).trim();
|
||||
const children = renderChildren(block, options, documentId, headingNumberingById, depth);
|
||||
|
||||
if (block.type === "pageReference" && Boolean(props.asChildPage) && options.hideChildPages) {
|
||||
return null;
|
||||
}
|
||||
|
||||
switch (block.type) {
|
||||
case "heading": {
|
||||
const level = clampHeadingLevel(props.level);
|
||||
const numbering = block.id ? headingNumberingById.get(block.id) ?? "" : "";
|
||||
const HeadingTag = (`h${level}` as "h1" | "h2" | "h3" | "h4" | "h5");
|
||||
return (
|
||||
<div key={key} className="space-y-2">
|
||||
<HeadingTag
|
||||
data-id={block.id}
|
||||
id={block.id}
|
||||
className={cn(
|
||||
"scroll-mt-24 font-semibold tracking-tight text-[#18181b]",
|
||||
level === 1 && "text-[2rem]",
|
||||
level === 2 && "text-[1.6rem]",
|
||||
level === 3 && "text-[1.3rem]",
|
||||
level >= 4 && "text-[1.08rem]",
|
||||
)}
|
||||
>
|
||||
{options.showHeadingNumbers && numbering ? (
|
||||
<span className="mr-2 text-[#94a3b8]">{numbering}</span>
|
||||
) : null}
|
||||
{inlineContent.length > 0 ? inlineContent : "未命名标题"}
|
||||
</HeadingTag>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
case "bulletListItem":
|
||||
return (
|
||||
<div key={key} className={cn("flex gap-3", LIST_INDENT_CLASS[Math.min(depth, LIST_INDENT_CLASS.length - 1)])}>
|
||||
<span className="mt-2 text-sm text-[#64748b]">•</span>
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<div className="leading-7 text-[#27272a]">{inlineContent}</div>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
case "numberedListItem":
|
||||
return (
|
||||
<div key={key} className={cn("flex gap-3", LIST_INDENT_CLASS[Math.min(depth, LIST_INDENT_CLASS.length - 1)])}>
|
||||
<span className="mt-1.5 min-w-5 text-right text-sm font-medium text-[#64748b]">{orderedIndex}.</span>
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<div className="leading-7 text-[#27272a]">{inlineContent}</div>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
case "checkListItem": {
|
||||
const checked = Boolean(props.checked);
|
||||
return (
|
||||
<div key={key} className={cn("flex gap-3", LIST_INDENT_CLASS[Math.min(depth, LIST_INDENT_CLASS.length - 1)])}>
|
||||
<span className="mt-1.5 text-lg leading-none text-[#2563eb]">{checked ? "☑" : "☐"}</span>
|
||||
<div className={cn("min-w-0 flex-1 space-y-2 leading-7 text-[#27272a]", checked && "text-[#71717a] line-through")}>
|
||||
<div>{inlineContent}</div>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
case "quote":
|
||||
return (
|
||||
<blockquote
|
||||
key={key}
|
||||
className="border-l-4 border-[#dbeafe] bg-[#f8fbff] px-4 py-3 text-[#334155]"
|
||||
>
|
||||
<div className="leading-7">{inlineContent}</div>
|
||||
{children}
|
||||
</blockquote>
|
||||
);
|
||||
case "codeBlock":
|
||||
return (
|
||||
<div key={key} className="space-y-2">
|
||||
<pre className="overflow-x-auto rounded-2xl bg-[#0f172a] p-4 text-sm text-[#e2e8f0]">
|
||||
<code>{plainText}</code>
|
||||
</pre>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
case "pageReference": {
|
||||
const pageId = typeof props.pageId === "string" ? props.pageId : "";
|
||||
const title = typeof props.title === "string" && props.title.trim() ? props.title : "未命名页面";
|
||||
return (
|
||||
<div key={key} className="space-y-2">
|
||||
<Link
|
||||
href={pageId ? `/documents/${pageId}` : "#"}
|
||||
className="inline-flex items-center gap-2 rounded-xl border border-[#e4e4e7] bg-[#fafafa] px-3 py-2 text-sm font-medium text-[#27272a] transition hover:border-[#cbd5e1] hover:bg-white"
|
||||
>
|
||||
<span className="text-[#94a3b8]">页面</span>
|
||||
<span>{title}</span>
|
||||
</Link>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
case "blockReference": {
|
||||
const sourceDocumentId = typeof props.sourceDocumentId === "string" ? props.sourceDocumentId : "";
|
||||
const targetBlockId = typeof props.targetBlockId === "string" ? props.targetBlockId : "";
|
||||
const href = sourceDocumentId ? `/documents/${sourceDocumentId}${targetBlockId ? `#${targetBlockId}` : ""}` : "#";
|
||||
return (
|
||||
<div key={key} className="space-y-2 rounded-2xl border border-dashed border-[#cbd5e1] bg-[#fafafa] px-4 py-3">
|
||||
<Link href={href} className="text-sm font-medium text-[#2563eb] underline underline-offset-2">
|
||||
打开引用块
|
||||
</Link>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
case "advancedTodo": {
|
||||
const status = String(props.status ?? "todo");
|
||||
const faded = status === "done" || status === "cancelled";
|
||||
return (
|
||||
<div key={key} className="space-y-2 rounded-2xl border border-[#e4e4e7] bg-white px-4 py-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-full px-2.5 py-1 text-xs font-medium",
|
||||
status === "done" && "bg-[#dcfce7] text-[#166534]",
|
||||
status === "doing" && "bg-[#dbeafe] text-[#1d4ed8]",
|
||||
status === "cancelled" && "bg-[#f3f4f6] text-[#6b7280]",
|
||||
status === "todo" && "bg-[#fef3c7] text-[#92400e]",
|
||||
)}
|
||||
>
|
||||
{TODO_STATUS_LABELS[status] ?? "未开始"}
|
||||
</span>
|
||||
<div className={cn("min-w-0 flex-1 leading-7 text-[#27272a]", faded && "text-[#71717a] line-through")}>
|
||||
{inlineContent}
|
||||
</div>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
case "progressMeter": {
|
||||
const percent = Math.min(100, Math.max(0, Number(props.percent ?? 0) || 0));
|
||||
const summary = typeof props.summary === "string" && props.summary.trim() ? props.summary : "暂无条目";
|
||||
return (
|
||||
<div key={key} className="space-y-3 rounded-2xl border border-[#dbeafe] bg-[#f8fbff] px-4 py-4">
|
||||
<div className="flex items-center justify-between gap-3 text-sm">
|
||||
<span className="font-medium text-[#1e3a8a]">{summary}</span>
|
||||
<span className="text-[#64748b]">{percent}%</span>
|
||||
</div>
|
||||
<div className="h-2 overflow-hidden rounded-full bg-[#dbeafe]">
|
||||
<div className="h-full rounded-full bg-[#2563eb]" style={{ width: `${percent}%` }} />
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
case "media":
|
||||
return (
|
||||
<div key={key} className="space-y-2">
|
||||
{renderMediaBlock(block)}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
case "onlineTable": {
|
||||
const tableId = typeof props.tableId === "string" ? props.tableId : "";
|
||||
return (
|
||||
<div key={key} className="space-y-2 rounded-2xl border border-[#e4e4e7] bg-[#fafafa] px-4 py-4">
|
||||
<div className="text-sm font-medium text-[#27272a]">
|
||||
{typeof props.title === "string" && props.title.trim() ? props.title : "在线表格"}
|
||||
</div>
|
||||
<Link
|
||||
href={tableId ? `/tables/${tableId}/view` : "#"}
|
||||
className="inline-flex items-center text-sm text-[#2563eb] underline underline-offset-2"
|
||||
>
|
||||
打开表格
|
||||
</Link>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
case "mindmap": {
|
||||
const mindmapId = typeof block.id === "string" ? block.id : "";
|
||||
const docId = typeof props.docId === "string" && props.docId.trim() ? props.docId : documentId;
|
||||
return (
|
||||
<div key={key} className="space-y-2 rounded-2xl border border-[#e4e4e7] bg-[#fafafa] px-4 py-4">
|
||||
<div className="text-sm font-medium text-[#27272a]">思维导图</div>
|
||||
<Link
|
||||
href={docId && mindmapId ? `/mindmap/${docId}/${mindmapId}` : "#"}
|
||||
className="inline-flex items-center text-sm text-[#2563eb] underline underline-offset-2"
|
||||
>
|
||||
打开思维导图
|
||||
</Link>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
case "paragraph":
|
||||
return (
|
||||
<div key={key} className="space-y-2">
|
||||
<p className="min-h-7 whitespace-pre-wrap break-words leading-7 text-[#27272a]">
|
||||
{inlineContent.length > 0 ? inlineContent : <span className="text-[#d4d4d8]"> </span>}
|
||||
</p>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
if (inlineContent.length === 0 && !children) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div key={key} className="space-y-2">
|
||||
{inlineContent.length > 0 ? (
|
||||
<div className="whitespace-pre-wrap break-words leading-7 text-[#27272a]">{inlineContent}</div>
|
||||
) : null}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const renderBlocks = (
|
||||
blocks: PageSubtreeBlock[],
|
||||
options: PageOptionsState,
|
||||
documentId: string,
|
||||
headingNumberingById: Map<string, string>,
|
||||
depth: number,
|
||||
) => {
|
||||
let orderedIndex = 0;
|
||||
|
||||
return blocks.map((block, index) => {
|
||||
orderedIndex = block.type === "numberedListItem" ? orderedIndex + 1 : 0;
|
||||
return renderBlock(block, options, documentId, headingNumberingById, depth, orderedIndex || index + 1);
|
||||
});
|
||||
};
|
||||
|
||||
function DocumentReadStructurePanel({ pageSubtree }: { pageSubtree: PageSubtreeProjection }) {
|
||||
const outlineEntries = pageSubtree.outline.slice(0, 10);
|
||||
const evidenceEntries = pageSubtree.evidence.slice(0, 5);
|
||||
|
||||
return (
|
||||
<section className="rounded-2xl border border-[#dbe4f0] bg-[#f8fbff] p-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="text-sm font-semibold text-[#1e293b]">页面子树 / Kernel Outline</div>
|
||||
<span className="rounded-full bg-white px-2 py-1 text-[11px] text-[#64748b]">
|
||||
节点 {pageSubtree.subtree.nodes.length}
|
||||
</span>
|
||||
<span className="rounded-full bg-white px-2 py-1 text-[11px] text-[#64748b]">
|
||||
标题 {pageSubtree.stats.headingCount}
|
||||
</span>
|
||||
<span className="rounded-full bg-white px-2 py-1 text-[11px] text-[#64748b]">
|
||||
证据 {pageSubtree.stats.evidenceCount}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-3 grid gap-4 lg:grid-cols-[minmax(0,1.2fr)_minmax(0,1fr)]">
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-medium uppercase tracking-[0.18em] text-[#64748b]">大纲</div>
|
||||
{outlineEntries.length > 0 ? (
|
||||
<ul className="space-y-1.5 text-sm text-[#334155]">
|
||||
{outlineEntries.map((entry) => (
|
||||
<li key={entry.nodeId} className={cn(entry.level > 1 && "pl-4", entry.level > 2 && "pl-7", entry.level > 3 && "pl-10")}>
|
||||
<span className="mr-2 font-mono text-[11px] text-[#94a3b8]">{entry.numbering}</span>
|
||||
<span>{entry.title || "未命名标题"}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<div className="text-sm text-[#64748b]">当前页面还没有标题型 subtree。</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-medium uppercase tracking-[0.18em] text-[#64748b]">证据</div>
|
||||
{evidenceEntries.length > 0 ? (
|
||||
<ul className="space-y-2">
|
||||
{evidenceEntries.map((entry) => (
|
||||
<li key={entry.id} className="rounded-xl border border-white/70 bg-white/80 px-3 py-2 text-sm text-[#334155]">
|
||||
<div className="text-[11px] uppercase tracking-[0.12em] text-[#94a3b8]">{entry.kind}</div>
|
||||
<div className="mt-1 line-clamp-2">{entry.snippet}</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<div className="text-sm text-[#64748b]">还没有可用证据片段。</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function DocumentReadView({ content, documentId, options, pageSubtree, className }: DocumentReadViewProps) {
|
||||
const blocks = extractReadViewBlocks(content);
|
||||
const resolvedPageSubtree =
|
||||
pageSubtree ??
|
||||
buildPageSubtreeProjection({
|
||||
documentId,
|
||||
title: null,
|
||||
content,
|
||||
});
|
||||
const headingNumberingById =
|
||||
resolvedPageSubtree.outline.length > 0
|
||||
? buildHeadingNumberingMapFromOutline(resolvedPageSubtree.outline)
|
||||
: buildHeadingNumberingMap(blocks);
|
||||
|
||||
if (blocks.length === 0) {
|
||||
return (
|
||||
<div className={cn("space-y-4", className)}>
|
||||
{options.showStructure ? <DocumentReadStructurePanel pageSubtree={resolvedPageSubtree} /> : null}
|
||||
<div className="flex min-h-[40vh] items-center justify-center rounded-2xl border border-dashed border-[#e4e4e7] bg-[#fafafa] px-6 py-10 text-sm text-[#71717a]">
|
||||
页面暂无正文内容
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-4", className)}>
|
||||
{options.showStructure ? <DocumentReadStructurePanel pageSubtree={resolvedPageSubtree} /> : null}
|
||||
{renderBlocks(blocks, options, documentId, headingNumberingById, 0)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,37 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { useEffect, useState } from "react";
|
||||
import type { DocumentContentProps } from "@/components/editor/document-content";
|
||||
|
||||
const DocumentContent = dynamic(
|
||||
() => import("@/components/editor/document-content").then((mod) => mod.DocumentContent),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-2 text-sm text-gray-500">
|
||||
正在载入编辑器...
|
||||
</div>
|
||||
),
|
||||
},
|
||||
);
|
||||
import { DocumentContent } from "@/components/editor/document-content";
|
||||
|
||||
export function DocumentShell(props: DocumentContentProps) {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
/* eslint-disable react-hooks/set-state-in-effect */
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
/* eslint-enable react-hooks/set-state-in-effect */
|
||||
|
||||
if (!mounted) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-2 text-sm text-gray-500">
|
||||
正在载入编辑器...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <DocumentContent {...props} />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,677 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Bot, Settings, Wrench } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { AiBridgePanel } from "@/components/ai-agent/AiBridgePanel";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { clamp } from "@/lib/constants";
|
||||
import { useAppPreferencesStore } from "@/store/app-preferences";
|
||||
import { useOnlyOfficeAiBridgeStore } from "@/store/onlyoffice-ai-bridge";
|
||||
import { parseSseChunks, readAiPanelPrefs, type AiProvider, writeAiPanelPrefs } from "@/components/ai-agent/panelShared";
|
||||
|
||||
type AgentMessage = { role: "user" | "assistant"; content: string };
|
||||
|
||||
type ToolLog =
|
||||
| { type: "tool_call"; id: string; tool: string; args: Record<string, unknown> }
|
||||
| { type: "tool_result"; id: string; tool: string; ok: boolean; ms: number; result: unknown }
|
||||
| { type: "info"; message: string }
|
||||
| { type: "error"; message: string };
|
||||
|
||||
type PanelPage = "chat" | "tools" | "settings";
|
||||
|
||||
type AgentAttachment = { id: string; title: string; fileUrl: string; mimeType?: string | null };
|
||||
|
||||
const CHANNEL = "mnote_onlyoffice_agent_tools_v1";
|
||||
|
||||
const DEFAULT_MESSAGES: AgentMessage[] = [
|
||||
{
|
||||
role: "assistant",
|
||||
content:
|
||||
"你好,我是 OnlyOffice AI Agent。\n- 先选中一段文字,再说“改写/补全/翻译/润色/删除/插入”\n- 我会通过 oo_* 工具读取/替换选区\n- 需要引用资料时可联网检索或用 LightRAG/文档检索",
|
||||
},
|
||||
];
|
||||
|
||||
const ONLINE_MODELS = [
|
||||
"",
|
||||
"gemini-2.5-flash",
|
||||
"gemini-2.5-pro",
|
||||
"gemini-3-pro-preview",
|
||||
"gemini-3-flash-preview",
|
||||
] as const;
|
||||
|
||||
const OLLAMA_QWEN3_30B = "qwen3:30b-a3b-instruct-2507-q4_K_M";
|
||||
|
||||
const isRecord = (v: unknown): v is Record<string, unknown> =>
|
||||
typeof v === "object" && v !== null && !Array.isArray(v);
|
||||
|
||||
const blobToDataUrl = (blob: Blob) =>
|
||||
new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(String(reader.result ?? ""));
|
||||
reader.onerror = () => reject(new Error("读取图片失败(FileReader)"));
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
|
||||
export function OnlyOfficeAiAgentPanelRuntime({
|
||||
openFile,
|
||||
initialOpen = false,
|
||||
}: {
|
||||
openFile: { id: string; title: string; fileUrl: string; mimeType?: string | null };
|
||||
initialOpen?: boolean;
|
||||
}) {
|
||||
const flightMode = useAppPreferencesStore((s) => s.flightMode);
|
||||
const bridgePluginReady = useOnlyOfficeAiBridgeStore((s) => s.pluginReady);
|
||||
const bridgeTargetOrigin = useOnlyOfficeAiBridgeStore((s) => s.targetOrigin);
|
||||
const bridgeTargetWindow = useOnlyOfficeAiBridgeStore((s) => s.targetWindow);
|
||||
const [open, setOpen] = useState(initialOpen);
|
||||
const [page, setPage] = useState<PanelPage>("chat");
|
||||
|
||||
const [messages, setMessages] = useState<AgentMessage[]>(() => DEFAULT_MESSAGES);
|
||||
const [input, setInput] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [toolLogs, setToolLogs] = useState<ToolLog[]>([]);
|
||||
|
||||
const [networkOn, setNetworkOn] = useState(() => !flightMode);
|
||||
const [toolAuto, setToolAuto] = useState(true);
|
||||
const [aiProvider, setAiProvider] = useState<AiProvider>("online");
|
||||
const [aiModel, setAiModel] = useState<string>("");
|
||||
const [maxSteps, setMaxSteps] = useState<number>(10);
|
||||
|
||||
// Codex:每个面板对话维护一个 session,支持连续对话与“暂停(类似 ESC)”
|
||||
const [codexSessionId, setCodexSessionId] = useState<string | null>(null);
|
||||
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const pluginTargetRef = useRef<{ win: Window | null; origin: string }>({ win: null, origin: "*" });
|
||||
const pendingPluginCallsRef = useRef<
|
||||
Map<string, { resolve: (v: unknown) => void; reject: (e: Error) => void; timeoutId: number }>
|
||||
>(new Map());
|
||||
|
||||
const attachments = useMemo<AgentAttachment[]>(
|
||||
() => [
|
||||
{
|
||||
id: openFile.id,
|
||||
title: openFile.title,
|
||||
fileUrl: openFile.fileUrl,
|
||||
mimeType: openFile.mimeType ?? null,
|
||||
},
|
||||
],
|
||||
[openFile.fileUrl, openFile.id, openFile.mimeType, openFile.title],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (flightMode) {
|
||||
setNetworkOn(false);
|
||||
}
|
||||
}, [flightMode]);
|
||||
|
||||
useEffect(() => {
|
||||
const prefs = readAiPanelPrefs("onlyoffice_ai", { provider: "online", model: "", maxSteps: 10 });
|
||||
setAiProvider(prefs.provider);
|
||||
setAiModel(prefs.model);
|
||||
setMaxSteps(clamp(Math.floor(prefs.maxSteps), 1, 24));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
writeAiPanelPrefs("onlyoffice_ai", {
|
||||
provider: aiProvider,
|
||||
model: aiModel,
|
||||
maxSteps: clamp(Math.floor(maxSteps), 1, 24),
|
||||
});
|
||||
}, [aiProvider, aiModel, maxSteps]);
|
||||
|
||||
useEffect(() => {
|
||||
pluginTargetRef.current = {
|
||||
win: bridgeTargetWindow,
|
||||
origin: bridgeTargetOrigin || "*",
|
||||
};
|
||||
}, [bridgeTargetOrigin, bridgeTargetWindow]);
|
||||
|
||||
useEffect(() => {
|
||||
const onMessage = (ev: MessageEvent) => {
|
||||
const data = ev.data as unknown;
|
||||
if (!isRecord(data)) return;
|
||||
if (data.channel !== CHANNEL) return;
|
||||
|
||||
const type = String(data.type ?? "").trim();
|
||||
if (type === "ready") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "result") {
|
||||
const callId = String(data.callId ?? "").trim();
|
||||
if (!callId) return;
|
||||
const pending = pendingPluginCallsRef.current.get(callId);
|
||||
if (!pending) return;
|
||||
pendingPluginCallsRef.current.delete(callId);
|
||||
window.clearTimeout(pending.timeoutId);
|
||||
|
||||
const ok = Boolean(data.ok);
|
||||
if (ok) {
|
||||
pending.resolve("result" in data ? (data as Record<string, unknown>).result : null);
|
||||
} else {
|
||||
pending.reject(new Error(String((data as Record<string, unknown>).error ?? "插件执行失败")));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("message", onMessage);
|
||||
return () => window.removeEventListener("message", onMessage);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const pendingPluginCalls = pendingPluginCallsRef.current;
|
||||
return () => {
|
||||
abortRef.current?.abort();
|
||||
abortRef.current = null;
|
||||
pendingPluginCalls.forEach(({ reject, timeoutId }) => {
|
||||
window.clearTimeout(timeoutId);
|
||||
reject(new Error("OnlyOffice AI 面板已卸载"));
|
||||
});
|
||||
pendingPluginCalls.clear();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const callPlugin = async (callId: string, tool: string, args: Record<string, unknown>) => {
|
||||
const target = pluginTargetRef.current;
|
||||
if (!target.win) throw new Error("插件未就绪(未收到 ready),请稍等或刷新文档");
|
||||
|
||||
const payload = { channel: CHANNEL, type: "call", callId, tool, args };
|
||||
const result = await new Promise<unknown>((resolve, reject) => {
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
pendingPluginCallsRef.current.delete(callId);
|
||||
reject(new Error("插件调用超时"));
|
||||
}, 60_000);
|
||||
|
||||
pendingPluginCallsRef.current.set(callId, { resolve, reject, timeoutId });
|
||||
try {
|
||||
target.win!.postMessage(payload, target.origin || "*");
|
||||
} catch (e) {
|
||||
window.clearTimeout(timeoutId);
|
||||
pendingPluginCallsRef.current.delete(callId);
|
||||
reject(e instanceof Error ? e : new Error(String(e)));
|
||||
}
|
||||
});
|
||||
return result;
|
||||
};
|
||||
|
||||
const postClientToolResult = async ({
|
||||
requestId,
|
||||
callId,
|
||||
ok,
|
||||
result,
|
||||
error,
|
||||
}: {
|
||||
requestId: string;
|
||||
callId: string;
|
||||
ok: boolean;
|
||||
result?: unknown;
|
||||
error?: string;
|
||||
}) => {
|
||||
await fetch("/api/ai-agent/client-tool-result", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ requestId, callId, ok, result, error }),
|
||||
});
|
||||
};
|
||||
|
||||
const resolveImageRefToDataUrl = async (imageRef: string) => {
|
||||
const s = String(imageRef ?? "").trim();
|
||||
if (!s) throw new Error("缺少 imageRef");
|
||||
|
||||
// 1) 优先当作附件 id
|
||||
const match = attachments.find((a) => a.id === s) ?? null;
|
||||
const url = match ? match.fileUrl : s;
|
||||
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error(`图片下载失败:HTTP ${res.status}`);
|
||||
const blob = await res.blob();
|
||||
return await blobToDataUrl(blob);
|
||||
};
|
||||
|
||||
const handleClientToolCall = async (payloadText: string) => {
|
||||
let data: unknown = null;
|
||||
try {
|
||||
data = JSON.parse(payloadText || "null");
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const obj = isRecord(data) ? data : ({} as Record<string, unknown>);
|
||||
const requestId = String(obj.requestId ?? "").trim();
|
||||
const callId = String(obj.callId ?? "").trim();
|
||||
const tool = String(obj.tool ?? "").trim();
|
||||
const args = isRecord(obj.args) ? (obj.args as Record<string, unknown>) : {};
|
||||
if (!requestId || !callId || !tool) return;
|
||||
|
||||
try {
|
||||
let result: unknown = null;
|
||||
if (tool === "oo_insert_image") {
|
||||
const imageRef = String(args.imageRef ?? "").trim();
|
||||
const src = await resolveImageRefToDataUrl(imageRef);
|
||||
const width = Number(args.width ?? 0);
|
||||
const height = Number(args.height ?? 0);
|
||||
result = await callPlugin(callId, tool, { ...args, src, width, height });
|
||||
} else {
|
||||
result = await callPlugin(callId, tool, args);
|
||||
}
|
||||
await postClientToolResult({ requestId, callId, ok: true, result });
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
await postClientToolResult({ requestId, callId, ok: false, error: msg });
|
||||
}
|
||||
};
|
||||
|
||||
const stop = () => {
|
||||
abortRef.current?.abort();
|
||||
abortRef.current = null;
|
||||
setLoading(false);
|
||||
if (aiProvider === "codex") {
|
||||
setToolLogs((prev) => [...prev, { type: "info", message: "已暂停(可继续对话)" }]);
|
||||
}
|
||||
};
|
||||
|
||||
const send = async () => {
|
||||
const text = input.trim();
|
||||
if (!text) return;
|
||||
if (loading) return;
|
||||
|
||||
const codexSessionIdForRequest =
|
||||
aiProvider === "codex" ? String(codexSessionId ?? "").trim() || null : null;
|
||||
|
||||
const nextMessages: AgentMessage[] = [...messages, { role: "user", content: text }];
|
||||
setMessages(nextMessages);
|
||||
setInput("");
|
||||
setLoading(true);
|
||||
setToolLogs([]);
|
||||
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/ai-agent/run", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
signal: controller.signal,
|
||||
body: JSON.stringify({
|
||||
stream: true,
|
||||
maxSteps,
|
||||
scope: "onlyoffice",
|
||||
messages: nextMessages.slice(-20),
|
||||
attachments,
|
||||
toolChoice: {
|
||||
mode: toolAuto ? "auto" : "manual",
|
||||
toolSets: [
|
||||
"toolset.readonly",
|
||||
"toolset.rag_read",
|
||||
"toolset.media_read",
|
||||
"toolset.docs_read",
|
||||
"toolset.onlyoffice_editor",
|
||||
],
|
||||
},
|
||||
options: {
|
||||
searxng: networkOn,
|
||||
ai: {
|
||||
provider: aiProvider,
|
||||
...(aiProvider === "codex" && codexSessionIdForRequest ? { sessionId: codexSessionIdForRequest } : {}),
|
||||
...(aiProvider !== "codex" && String(aiModel || "").trim() ? { model: String(aiModel || "").trim() } : {}),
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const j = (await res.json().catch(() => null)) as unknown;
|
||||
const err =
|
||||
typeof j === "object" && j && "error" in j ? String((j as Record<string, unknown>).error ?? "") : "";
|
||||
throw new Error(err || `HTTP ${res.status}`);
|
||||
}
|
||||
|
||||
await parseSseChunks(res, (event, dataText) => {
|
||||
if (event === "codex_session") {
|
||||
try {
|
||||
const d = JSON.parse(dataText || "null") as unknown;
|
||||
const obj = isRecord(d) ? d : ({} as Record<string, unknown>);
|
||||
const sid = String(obj.sessionId ?? "").trim();
|
||||
if (sid) {
|
||||
setCodexSessionId(sid);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (event === "assistant_message") {
|
||||
try {
|
||||
const d = JSON.parse(dataText || "null") as unknown;
|
||||
const t = isRecord(d) && "text" in d ? String(d.text ?? "") : "";
|
||||
if (t) setMessages((prev) => [...prev, { role: "assistant", content: t }]);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (event === "client_tool_call") {
|
||||
void handleClientToolCall(dataText);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event === "tool_call") {
|
||||
try {
|
||||
const d = JSON.parse(dataText || "null") as unknown;
|
||||
const obj = isRecord(d) ? d : ({} as Record<string, unknown>);
|
||||
setToolLogs((prev) => [
|
||||
...prev,
|
||||
{
|
||||
type: "tool_call",
|
||||
id: String(obj.id ?? ""),
|
||||
tool: String(obj.tool ?? ""),
|
||||
args: (isRecord(obj.args) ? obj.args : {}) as Record<string, unknown>,
|
||||
},
|
||||
]);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (event === "tool_result") {
|
||||
try {
|
||||
const d = JSON.parse(dataText || "null") as unknown;
|
||||
const obj = isRecord(d) ? d : ({} as Record<string, unknown>);
|
||||
setToolLogs((prev) => [
|
||||
...prev,
|
||||
{
|
||||
type: "tool_result",
|
||||
id: String(obj.id ?? ""),
|
||||
tool: String(obj.tool ?? ""),
|
||||
ok: Boolean(obj.ok),
|
||||
ms: Number(obj.ms ?? 0),
|
||||
result: "result" in obj ? obj.result : null,
|
||||
},
|
||||
]);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (event === "error") {
|
||||
if (controller.signal.aborted && aiProvider === "codex") return;
|
||||
try {
|
||||
const d = JSON.parse(dataText || "null") as unknown;
|
||||
const msg = isRecord(d) && "message" in d ? String(d.message ?? "") : "";
|
||||
setToolLogs((prev) => [...prev, { type: "error", message: msg || "未知错误" }]);
|
||||
} catch {
|
||||
setToolLogs((prev) => [...prev, { type: "error", message: "未知错误" }]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
if (controller.signal.aborted && aiProvider === "codex") return;
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
setToolLogs((prev) => [...prev, { type: "error", message: msg }]);
|
||||
} finally {
|
||||
abortRef.current = null;
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const canSend = useMemo(() => input.trim().length > 0 && !loading, [input, loading]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="fixed bottom-4 right-4 z-[60]">
|
||||
<Button
|
||||
className="shadow"
|
||||
onClick={() => {
|
||||
setOpen((v) => !v);
|
||||
if (!open) setPage("chat");
|
||||
}}
|
||||
>
|
||||
<Bot className="mr-2 h-4 w-4" />
|
||||
AI
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{open ? (
|
||||
<div className="fixed inset-y-0 right-0 z-[70] w-[min(960px,calc(100vw-24px))] p-3">
|
||||
<AiBridgePanel
|
||||
title={page === "chat" ? "OnlyOffice AI" : page === "tools" ? "OnlyOffice 工具" : "OnlyOffice 设置"}
|
||||
subtitle="OnlyOffice AI"
|
||||
status={loading ? "运行中" : "待命"}
|
||||
onClose={() => setOpen(false)}
|
||||
scrollBody={false}
|
||||
className="h-[calc(100vh-24px)] min-h-0 rounded-[24px] border-white/10 shadow-none"
|
||||
secondaryActions={
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => setPage("chat")}
|
||||
className="rounded-xl border border-white/10 bg-white/[0.03] text-white/80 hover:bg-white/[0.08] hover:text-white"
|
||||
>
|
||||
<Bot className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => setPage("tools")}
|
||||
className="rounded-xl border border-white/10 bg-white/[0.03] text-white/80 hover:bg-white/[0.08] hover:text-white"
|
||||
>
|
||||
<Wrench className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => setPage("settings")}
|
||||
className="rounded-xl border border-white/10 bg-white/[0.03] text-white/80 hover:bg-white/[0.08] hover:text-white"
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{page === "tools" ? (
|
||||
<div className="space-y-3 p-3 text-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="font-medium">联网检索</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={networkOn}
|
||||
onChange={(e) => setNetworkOn(e.target.checked)}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="font-medium">自动工具</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={toolAuto}
|
||||
onChange={(e) => setToolAuto(e.target.checked)}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
<div className="rounded border p-2 text-xs text-muted-foreground">
|
||||
<div>插件状态:{bridgePluginReady ? "已连接" : "未连接(等待 ready)"}</div>
|
||||
<div>说明:oo_* 工具依赖该插件执行“选区读写”。</div>
|
||||
</div>
|
||||
|
||||
<details open className="rounded border p-2">
|
||||
<summary className="cursor-pointer select-none text-sm font-medium">工具日志(可折叠)</summary>
|
||||
<div className="mt-2 space-y-2">
|
||||
{toolLogs.length === 0 ? <div className="text-muted-foreground">暂无工具日志</div> : null}
|
||||
{toolLogs.map((l, idx) => {
|
||||
if (l.type === "error") {
|
||||
return (
|
||||
<div key={idx} className="rounded border border-red-200 bg-red-50 p-2 text-red-700">
|
||||
错误:{l.message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (l.type === "info") {
|
||||
return (
|
||||
<div key={idx} className="rounded border bg-muted p-2 text-muted-foreground">
|
||||
{l.message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (l.type === "tool_call") {
|
||||
return (
|
||||
<div key={idx} className="rounded border p-2">
|
||||
<div className="text-xs text-muted-foreground">tool_call · {l.id}</div>
|
||||
<div className="font-medium">{l.tool}</div>
|
||||
<pre className="mt-1 whitespace-pre-wrap text-xs">{JSON.stringify(l.args, null, 2)}</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div key={idx} className="rounded border p-2">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
tool_result · {l.id} · {l.ok ? "ok" : "fail"} · {l.ms}ms
|
||||
</div>
|
||||
<div className="font-medium">{l.tool}</div>
|
||||
<pre className="mt-1 whitespace-pre-wrap text-xs">{JSON.stringify(l.result, null, 2)}</pre>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{page === "settings" ? (
|
||||
<div className="space-y-3 p-3 text-sm">
|
||||
<label className="flex items-center justify-between gap-2">
|
||||
<span className="font-medium">最大步数</span>
|
||||
<input
|
||||
className="w-[96px] rounded border px-2 py-1 text-xs"
|
||||
type="number"
|
||||
min={1}
|
||||
max={24}
|
||||
step={1}
|
||||
value={maxSteps}
|
||||
onChange={(e) => {
|
||||
const v = Number(e.target.value);
|
||||
if (!Number.isFinite(v)) return;
|
||||
setMaxSteps(clamp(Math.floor(v), 1, 24));
|
||||
}}
|
||||
disabled={loading}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center justify-between gap-2">
|
||||
<span className="font-medium">AI 提供方</span>
|
||||
<select
|
||||
className="rounded border px-2 py-1 text-xs"
|
||||
value={aiProvider}
|
||||
onChange={(e) => {
|
||||
const v = String(e.target.value || "").trim();
|
||||
if (v === "online" || v === "local" || v === "ollama" || v === "codex") setAiProvider(v);
|
||||
else setAiProvider("online");
|
||||
}}
|
||||
disabled={loading}
|
||||
>
|
||||
<option value="online">在线</option>
|
||||
<option value="local">本地</option>
|
||||
<option value="ollama">Ollama</option>
|
||||
<option value="codex">Codex</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center justify-between gap-2">
|
||||
<span className="font-medium">模型</span>
|
||||
{aiProvider === "codex" ? (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
消息开头加 <code className="rounded bg-muted px-1 py-0.5">#chat</code> /{" "}
|
||||
<code className="rounded bg-muted px-1 py-0.5">#test</code> /{" "}
|
||||
<code className="rounded bg-muted px-1 py-0.5">#dev</code>(默认 <code className="rounded bg-muted px-1 py-0.5">#chat</code>)。
|
||||
</div>
|
||||
) : aiProvider === "online" ? (
|
||||
<select
|
||||
className="w-[220px] rounded border px-2 py-1 text-xs"
|
||||
value={ONLINE_MODELS.includes(aiModel as any) ? aiModel : ""}
|
||||
onChange={(e) => setAiModel(e.target.value)}
|
||||
disabled={loading}
|
||||
>
|
||||
{ONLINE_MODELS.map((m) => (
|
||||
<option key={m} value={m}>
|
||||
{m || "默认"}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : aiProvider === "ollama" ? (
|
||||
<select
|
||||
className="w-[320px] rounded border px-2 py-1 text-xs"
|
||||
value={aiModel === "" || aiModel === OLLAMA_QWEN3_30B ? aiModel : ""}
|
||||
onChange={(e) => setAiModel(e.target.value)}
|
||||
disabled={loading}
|
||||
>
|
||||
<option value="">默认:{OLLAMA_QWEN3_30B}</option>
|
||||
<option value={OLLAMA_QWEN3_30B}>{OLLAMA_QWEN3_30B}</option>
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
className="w-[320px] rounded border px-2 py-1 text-xs"
|
||||
value={aiModel}
|
||||
onChange={(e) => setAiModel(e.target.value)}
|
||||
placeholder="默认(ai.local.md/环境变量)"
|
||||
disabled={loading}
|
||||
list="oo-local-model-suggestions"
|
||||
/>
|
||||
)}
|
||||
</label>
|
||||
<datalist id="oo-local-model-suggestions">
|
||||
<option value={OLLAMA_QWEN3_30B} />
|
||||
</datalist>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{page === "chat" ? (
|
||||
<div className="flex h-[calc(100vh-96px)] flex-col">
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="space-y-3 p-3 text-sm">
|
||||
{messages.map((m, idx) => (
|
||||
<div key={idx} className="space-y-1">
|
||||
<div className="text-xs text-muted-foreground">{m.role === "user" ? "用户" : "AI"}</div>
|
||||
<div className="whitespace-pre-wrap">{m.content}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<div className="border-t p-3">
|
||||
<div className="flex gap-2">
|
||||
<Textarea
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder="输入你的需求(Enter 发送,Shift+Enter 换行)"
|
||||
className="min-h-[72px] flex-1"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
if (canSend) void send();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button disabled={!canSend} onClick={() => void send()}>
|
||||
发送
|
||||
</Button>
|
||||
<Button variant="secondary" disabled={!loading} onClick={stop}>
|
||||
{aiProvider === "codex" ? "暂停" : "停止"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</AiBridgePanel>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export { OnlyOfficeAiAgentPanelRuntime as OnlyOfficeAiAgentPanel };
|
||||
@@ -1,658 +1,70 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Bot, Settings, Wrench } from "lucide-react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Bot } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { AiBridgePanel } from "@/components/ai-agent/AiBridgePanel";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { clamp } from "@/lib/constants";
|
||||
import { useAppPreferencesStore } from "@/store/app-preferences";
|
||||
import { parseSseChunks, readAiPanelPrefs, type AiProvider, writeAiPanelPrefs } from "@/components/ai-agent/panelShared";
|
||||
import { useOnlyOfficeAiBridgeStore } from "@/store/onlyoffice-ai-bridge";
|
||||
|
||||
type AgentMessage = { role: "user" | "assistant"; content: string };
|
||||
|
||||
type ToolLog =
|
||||
| { type: "tool_call"; id: string; tool: string; args: Record<string, unknown> }
|
||||
| { type: "tool_result"; id: string; tool: string; ok: boolean; ms: number; result: unknown }
|
||||
| { type: "info"; message: string }
|
||||
| { type: "error"; message: string };
|
||||
|
||||
type PanelPage = "chat" | "tools" | "settings";
|
||||
|
||||
type AgentAttachment = { id: string; title: string; fileUrl: string; mimeType?: string | null };
|
||||
type OnlyOfficeAiAgentPanelProps = {
|
||||
openFile: { id: string; title: string; fileUrl: string; mimeType?: string | null };
|
||||
};
|
||||
|
||||
const CHANNEL = "mnote_onlyoffice_agent_tools_v1";
|
||||
|
||||
const DEFAULT_MESSAGES: AgentMessage[] = [
|
||||
const OnlyOfficeAiAgentPanelRuntime = dynamic<OnlyOfficeAiAgentPanelProps & { initialOpen?: boolean }>(
|
||||
() => import("./OnlyOfficeAiAgentPanel.runtime").then((mod) => mod.OnlyOfficeAiAgentPanelRuntime),
|
||||
{
|
||||
role: "assistant",
|
||||
content:
|
||||
"你好,我是 OnlyOffice AI Agent。\n- 先选中一段文字,再说“改写/补全/翻译/润色/删除/插入”\n- 我会通过 oo_* 工具读取/替换选区\n- 需要引用资料时可联网检索或用 LightRAG/文档检索",
|
||||
ssr: false,
|
||||
loading: () => null,
|
||||
},
|
||||
];
|
||||
);
|
||||
|
||||
const ONLINE_MODELS = [
|
||||
"",
|
||||
"gemini-2.5-flash",
|
||||
"gemini-2.5-pro",
|
||||
"gemini-3-pro-preview",
|
||||
"gemini-3-flash-preview",
|
||||
] as const;
|
||||
|
||||
const OLLAMA_QWEN3_30B = "qwen3:30b-a3b-instruct-2507-q4_K_M";
|
||||
|
||||
const isRecord = (v: unknown): v is Record<string, unknown> =>
|
||||
typeof v === "object" && v !== null && !Array.isArray(v);
|
||||
|
||||
const blobToDataUrl = (blob: Blob) =>
|
||||
new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(String(reader.result ?? ""));
|
||||
reader.onerror = () => reject(new Error("读取图片失败(FileReader)"));
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
|
||||
export function OnlyOfficeAiAgentPanel({
|
||||
openFile,
|
||||
}: {
|
||||
openFile: { id: string; title: string; fileUrl: string; mimeType?: string | null };
|
||||
}) {
|
||||
const flightMode = useAppPreferencesStore((s) => s.flightMode);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [page, setPage] = useState<PanelPage>("chat");
|
||||
|
||||
const [messages, setMessages] = useState<AgentMessage[]>(() => DEFAULT_MESSAGES);
|
||||
const [input, setInput] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [toolLogs, setToolLogs] = useState<ToolLog[]>([]);
|
||||
|
||||
const [networkOn, setNetworkOn] = useState(() => !flightMode);
|
||||
const [toolAuto, setToolAuto] = useState(true);
|
||||
const [aiProvider, setAiProvider] = useState<AiProvider>("online");
|
||||
const [aiModel, setAiModel] = useState<string>("");
|
||||
const [maxSteps, setMaxSteps] = useState<number>(10);
|
||||
|
||||
// Codex:每个面板对话维护一个 session,支持连续对话与“暂停(类似 ESC)”
|
||||
const [codexSessionId, setCodexSessionId] = useState<string | null>(null);
|
||||
|
||||
const [pluginReady, setPluginReady] = useState(false);
|
||||
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const pluginTargetRef = useRef<{ win: Window | null; origin: string }>({ win: null, origin: "*" });
|
||||
const pendingPluginCallsRef = useRef<
|
||||
Map<string, { resolve: (v: unknown) => void; reject: (e: Error) => void; timeoutId: number }>
|
||||
>(new Map());
|
||||
|
||||
const attachments = useMemo<AgentAttachment[]>(
|
||||
() => [
|
||||
{
|
||||
id: openFile.id,
|
||||
title: openFile.title,
|
||||
fileUrl: openFile.fileUrl,
|
||||
mimeType: openFile.mimeType ?? null,
|
||||
},
|
||||
],
|
||||
[openFile.fileUrl, openFile.id, openFile.mimeType, openFile.title],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (flightMode) {
|
||||
setNetworkOn(false);
|
||||
}
|
||||
}, [flightMode]);
|
||||
|
||||
useEffect(() => {
|
||||
const prefs = readAiPanelPrefs("onlyoffice_ai", { provider: "online", model: "", maxSteps: 10 });
|
||||
setAiProvider(prefs.provider);
|
||||
setAiModel(prefs.model);
|
||||
setMaxSteps(clamp(Math.floor(prefs.maxSteps), 1, 24));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
writeAiPanelPrefs("onlyoffice_ai", {
|
||||
provider: aiProvider,
|
||||
model: aiModel,
|
||||
maxSteps: clamp(Math.floor(maxSteps), 1, 24),
|
||||
});
|
||||
}, [aiProvider, aiModel, maxSteps]);
|
||||
// 轻量 host:常驻接住插件 ready 握手,真正的面板 runtime 在首次点击后再挂载。
|
||||
export function OnlyOfficeAiAgentPanel(props: OnlyOfficeAiAgentPanelProps) {
|
||||
const captureReady = useOnlyOfficeAiBridgeStore((state) => state.captureReady);
|
||||
const reset = useOnlyOfficeAiBridgeStore((state) => state.reset);
|
||||
const [activated, setActivated] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const onMessage = (ev: MessageEvent) => {
|
||||
const data = ev.data as unknown;
|
||||
if (!isRecord(data)) return;
|
||||
if (data.channel !== CHANNEL) return;
|
||||
if (!data || typeof data !== "object") return;
|
||||
|
||||
const type = String(data.type ?? "").trim();
|
||||
if (type === "ready") {
|
||||
// 记录插件窗口与来源,后续回发消息更稳
|
||||
pluginTargetRef.current = {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
win: (ev.source as any) && typeof (ev.source as any).postMessage === "function" ? ((ev.source as any) as Window) : null,
|
||||
origin: String(ev.origin || "*"),
|
||||
};
|
||||
setPluginReady(true);
|
||||
return;
|
||||
}
|
||||
const channel = "channel" in (data as Record<string, unknown>) ? String((data as Record<string, unknown>).channel ?? "") : "";
|
||||
const type = "type" in (data as Record<string, unknown>) ? String((data as Record<string, unknown>).type ?? "") : "";
|
||||
if (channel !== CHANNEL || type !== "ready") return;
|
||||
|
||||
if (type === "result") {
|
||||
const callId = String(data.callId ?? "").trim();
|
||||
if (!callId) return;
|
||||
const pending = pendingPluginCallsRef.current.get(callId);
|
||||
if (!pending) return;
|
||||
pendingPluginCallsRef.current.delete(callId);
|
||||
window.clearTimeout(pending.timeoutId);
|
||||
|
||||
const ok = Boolean(data.ok);
|
||||
if (ok) {
|
||||
pending.resolve("result" in data ? (data as Record<string, unknown>).result : null);
|
||||
} else {
|
||||
pending.reject(new Error(String((data as Record<string, unknown>).error ?? "插件执行失败")));
|
||||
}
|
||||
}
|
||||
const targetWindow =
|
||||
ev.source && typeof (ev.source as Window).postMessage === "function" ? (ev.source as Window) : null;
|
||||
captureReady({
|
||||
targetOrigin: String(ev.origin || "*"),
|
||||
targetWindow,
|
||||
});
|
||||
};
|
||||
|
||||
window.addEventListener("message", onMessage);
|
||||
return () => window.removeEventListener("message", onMessage);
|
||||
}, []);
|
||||
return () => {
|
||||
window.removeEventListener("message", onMessage);
|
||||
reset();
|
||||
};
|
||||
}, [captureReady, reset]);
|
||||
|
||||
const callPlugin = async (callId: string, tool: string, args: Record<string, unknown>) => {
|
||||
const target = pluginTargetRef.current;
|
||||
if (!target.win) throw new Error("插件未就绪(未收到 ready),请稍等或刷新文档");
|
||||
|
||||
const payload = { channel: CHANNEL, type: "call", callId, tool, args };
|
||||
const result = await new Promise<unknown>((resolve, reject) => {
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
pendingPluginCallsRef.current.delete(callId);
|
||||
reject(new Error("插件调用超时"));
|
||||
}, 60_000);
|
||||
|
||||
pendingPluginCallsRef.current.set(callId, { resolve, reject, timeoutId });
|
||||
try {
|
||||
target.win!.postMessage(payload, target.origin || "*");
|
||||
} catch (e) {
|
||||
window.clearTimeout(timeoutId);
|
||||
pendingPluginCallsRef.current.delete(callId);
|
||||
reject(e instanceof Error ? e : new Error(String(e)));
|
||||
}
|
||||
});
|
||||
return result;
|
||||
};
|
||||
|
||||
const postClientToolResult = async ({
|
||||
requestId,
|
||||
callId,
|
||||
ok,
|
||||
result,
|
||||
error,
|
||||
}: {
|
||||
requestId: string;
|
||||
callId: string;
|
||||
ok: boolean;
|
||||
result?: unknown;
|
||||
error?: string;
|
||||
}) => {
|
||||
await fetch("/api/ai-agent/client-tool-result", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ requestId, callId, ok, result, error }),
|
||||
});
|
||||
};
|
||||
|
||||
const resolveImageRefToDataUrl = async (imageRef: string) => {
|
||||
const s = String(imageRef ?? "").trim();
|
||||
if (!s) throw new Error("缺少 imageRef");
|
||||
|
||||
// 1) 优先当作附件 id
|
||||
const match = attachments.find((a) => a.id === s) ?? null;
|
||||
const url = match ? match.fileUrl : s;
|
||||
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error(`图片下载失败:HTTP ${res.status}`);
|
||||
const blob = await res.blob();
|
||||
return await blobToDataUrl(blob);
|
||||
};
|
||||
|
||||
const handleClientToolCall = async (payloadText: string) => {
|
||||
let data: unknown = null;
|
||||
try {
|
||||
data = JSON.parse(payloadText || "null");
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const obj = isRecord(data) ? data : ({} as Record<string, unknown>);
|
||||
const requestId = String(obj.requestId ?? "").trim();
|
||||
const callId = String(obj.callId ?? "").trim();
|
||||
const tool = String(obj.tool ?? "").trim();
|
||||
const args = isRecord(obj.args) ? (obj.args as Record<string, unknown>) : {};
|
||||
if (!requestId || !callId || !tool) return;
|
||||
|
||||
try {
|
||||
let result: unknown = null;
|
||||
if (tool === "oo_insert_image") {
|
||||
const imageRef = String(args.imageRef ?? "").trim();
|
||||
const src = await resolveImageRefToDataUrl(imageRef);
|
||||
const width = Number(args.width ?? 0);
|
||||
const height = Number(args.height ?? 0);
|
||||
result = await callPlugin(callId, tool, { ...args, src, width, height });
|
||||
} else {
|
||||
result = await callPlugin(callId, tool, args);
|
||||
}
|
||||
await postClientToolResult({ requestId, callId, ok: true, result });
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
await postClientToolResult({ requestId, callId, ok: false, error: msg });
|
||||
}
|
||||
};
|
||||
|
||||
const stop = () => {
|
||||
abortRef.current?.abort();
|
||||
abortRef.current = null;
|
||||
setLoading(false);
|
||||
if (aiProvider === "codex") {
|
||||
setToolLogs((prev) => [...prev, { type: "info", message: "已暂停(可继续对话)" }]);
|
||||
}
|
||||
};
|
||||
|
||||
const send = async () => {
|
||||
const text = input.trim();
|
||||
if (!text) return;
|
||||
if (loading) return;
|
||||
|
||||
const codexSessionIdForRequest =
|
||||
aiProvider === "codex" ? String(codexSessionId ?? "").trim() || null : null;
|
||||
|
||||
const nextMessages: AgentMessage[] = [...messages, { role: "user", content: text }];
|
||||
setMessages(nextMessages);
|
||||
setInput("");
|
||||
setLoading(true);
|
||||
setToolLogs([]);
|
||||
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/ai-agent/run", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
signal: controller.signal,
|
||||
body: JSON.stringify({
|
||||
stream: true,
|
||||
maxSteps,
|
||||
scope: "onlyoffice",
|
||||
messages: nextMessages.slice(-20),
|
||||
attachments,
|
||||
toolChoice: {
|
||||
mode: toolAuto ? "auto" : "manual",
|
||||
toolSets: [
|
||||
"toolset.readonly",
|
||||
"toolset.rag_read",
|
||||
"toolset.media_read",
|
||||
"toolset.docs_read",
|
||||
"toolset.onlyoffice_editor",
|
||||
],
|
||||
},
|
||||
options: {
|
||||
searxng: networkOn,
|
||||
ai: {
|
||||
provider: aiProvider,
|
||||
...(aiProvider === "codex" && codexSessionIdForRequest ? { sessionId: codexSessionIdForRequest } : {}),
|
||||
...(aiProvider !== "codex" && String(aiModel || "").trim() ? { model: String(aiModel || "").trim() } : {}),
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const j = (await res.json().catch(() => null)) as unknown;
|
||||
const err =
|
||||
typeof j === "object" && j && "error" in j ? String((j as Record<string, unknown>).error ?? "") : "";
|
||||
throw new Error(err || `HTTP ${res.status}`);
|
||||
}
|
||||
|
||||
await parseSseChunks(res, (event, dataText) => {
|
||||
if (event === "codex_session") {
|
||||
try {
|
||||
const d = JSON.parse(dataText || "null") as unknown;
|
||||
const obj = isRecord(d) ? d : ({} as Record<string, unknown>);
|
||||
const sid = String(obj.sessionId ?? "").trim();
|
||||
if (sid) {
|
||||
setCodexSessionId(sid);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (event === "assistant_message") {
|
||||
try {
|
||||
const d = JSON.parse(dataText || "null") as unknown;
|
||||
const t = isRecord(d) && "text" in d ? String(d.text ?? "") : "";
|
||||
if (t) setMessages((prev) => [...prev, { role: "assistant", content: t }]);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (event === "client_tool_call") {
|
||||
void handleClientToolCall(dataText);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event === "tool_call") {
|
||||
try {
|
||||
const d = JSON.parse(dataText || "null") as unknown;
|
||||
const obj = isRecord(d) ? d : ({} as Record<string, unknown>);
|
||||
setToolLogs((prev) => [
|
||||
...prev,
|
||||
{
|
||||
type: "tool_call",
|
||||
id: String(obj.id ?? ""),
|
||||
tool: String(obj.tool ?? ""),
|
||||
args: (isRecord(obj.args) ? obj.args : {}) as Record<string, unknown>,
|
||||
},
|
||||
]);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (event === "tool_result") {
|
||||
try {
|
||||
const d = JSON.parse(dataText || "null") as unknown;
|
||||
const obj = isRecord(d) ? d : ({} as Record<string, unknown>);
|
||||
setToolLogs((prev) => [
|
||||
...prev,
|
||||
{
|
||||
type: "tool_result",
|
||||
id: String(obj.id ?? ""),
|
||||
tool: String(obj.tool ?? ""),
|
||||
ok: Boolean(obj.ok),
|
||||
ms: Number(obj.ms ?? 0),
|
||||
result: "result" in obj ? obj.result : null,
|
||||
},
|
||||
]);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (event === "error") {
|
||||
if (controller.signal.aborted && aiProvider === "codex") return;
|
||||
try {
|
||||
const d = JSON.parse(dataText || "null") as unknown;
|
||||
const msg = isRecord(d) && "message" in d ? String(d.message ?? "") : "";
|
||||
setToolLogs((prev) => [...prev, { type: "error", message: msg || "未知错误" }]);
|
||||
} catch {
|
||||
setToolLogs((prev) => [...prev, { type: "error", message: "未知错误" }]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
if (controller.signal.aborted && aiProvider === "codex") return;
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
setToolLogs((prev) => [...prev, { type: "error", message: msg }]);
|
||||
} finally {
|
||||
abortRef.current = null;
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const canSend = useMemo(() => input.trim().length > 0 && !loading, [input, loading]);
|
||||
|
||||
return (
|
||||
<>
|
||||
if (!activated) {
|
||||
return (
|
||||
<div className="fixed bottom-4 right-4 z-[60]">
|
||||
<Button
|
||||
className="shadow"
|
||||
onClick={() => {
|
||||
setOpen((v) => !v);
|
||||
if (!open) setPage("chat");
|
||||
setActivated(true);
|
||||
}}
|
||||
>
|
||||
<Bot className="mr-2 h-4 w-4" />
|
||||
AI
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
{open ? (
|
||||
<div className="fixed inset-y-0 right-0 z-[70] w-[min(960px,calc(100vw-24px))] p-3">
|
||||
<AiBridgePanel
|
||||
title={page === "chat" ? "OnlyOffice AI" : page === "tools" ? "OnlyOffice 工具" : "OnlyOffice 设置"}
|
||||
subtitle="OnlyOffice AI"
|
||||
status={loading ? "运行中" : "待命"}
|
||||
onClose={() => setOpen(false)}
|
||||
scrollBody={false}
|
||||
className="h-[calc(100vh-24px)] min-h-0 rounded-[24px] border-white/10 shadow-none"
|
||||
secondaryActions={
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => setPage("chat")}
|
||||
className="rounded-xl border border-white/10 bg-white/[0.03] text-white/80 hover:bg-white/[0.08] hover:text-white"
|
||||
>
|
||||
<Bot className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => setPage("tools")}
|
||||
className="rounded-xl border border-white/10 bg-white/[0.03] text-white/80 hover:bg-white/[0.08] hover:text-white"
|
||||
>
|
||||
<Wrench className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => setPage("settings")}
|
||||
className="rounded-xl border border-white/10 bg-white/[0.03] text-white/80 hover:bg-white/[0.08] hover:text-white"
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{page === "tools" ? (
|
||||
<div className="space-y-3 p-3 text-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="font-medium">联网检索</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={networkOn}
|
||||
onChange={(e) => setNetworkOn(e.target.checked)}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="font-medium">自动工具</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={toolAuto}
|
||||
onChange={(e) => setToolAuto(e.target.checked)}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
<div className="rounded border p-2 text-xs text-muted-foreground">
|
||||
<div>插件状态:{pluginReady ? "已连接" : "未连接(等待 ready)"}</div>
|
||||
<div>说明:oo_* 工具依赖该插件执行“选区读写”。</div>
|
||||
</div>
|
||||
|
||||
<details open className="rounded border p-2">
|
||||
<summary className="cursor-pointer select-none text-sm font-medium">工具日志(可折叠)</summary>
|
||||
<div className="mt-2 space-y-2">
|
||||
{toolLogs.length === 0 ? <div className="text-muted-foreground">暂无工具日志</div> : null}
|
||||
{toolLogs.map((l, idx) => {
|
||||
if (l.type === "error") {
|
||||
return (
|
||||
<div key={idx} className="rounded border border-red-200 bg-red-50 p-2 text-red-700">
|
||||
错误:{l.message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (l.type === "info") {
|
||||
return (
|
||||
<div key={idx} className="rounded border bg-muted p-2 text-muted-foreground">
|
||||
{l.message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (l.type === "tool_call") {
|
||||
return (
|
||||
<div key={idx} className="rounded border p-2">
|
||||
<div className="text-xs text-muted-foreground">tool_call · {l.id}</div>
|
||||
<div className="font-medium">{l.tool}</div>
|
||||
<pre className="mt-1 whitespace-pre-wrap text-xs">{JSON.stringify(l.args, null, 2)}</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div key={idx} className="rounded border p-2">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
tool_result · {l.id} · {l.ok ? "ok" : "fail"} · {l.ms}ms
|
||||
</div>
|
||||
<div className="font-medium">{l.tool}</div>
|
||||
<pre className="mt-1 whitespace-pre-wrap text-xs">{JSON.stringify(l.result, null, 2)}</pre>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{page === "settings" ? (
|
||||
<div className="space-y-3 p-3 text-sm">
|
||||
<label className="flex items-center justify-between gap-2">
|
||||
<span className="font-medium">最大步数</span>
|
||||
<input
|
||||
className="w-[96px] rounded border px-2 py-1 text-xs"
|
||||
type="number"
|
||||
min={1}
|
||||
max={24}
|
||||
step={1}
|
||||
value={maxSteps}
|
||||
onChange={(e) => {
|
||||
const v = Number(e.target.value);
|
||||
if (!Number.isFinite(v)) return;
|
||||
setMaxSteps(clamp(Math.floor(v), 1, 24));
|
||||
}}
|
||||
disabled={loading}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center justify-between gap-2">
|
||||
<span className="font-medium">AI 提供方</span>
|
||||
<select
|
||||
className="rounded border px-2 py-1 text-xs"
|
||||
value={aiProvider}
|
||||
onChange={(e) => {
|
||||
const v = String(e.target.value || "").trim();
|
||||
if (v === "online" || v === "local" || v === "ollama" || v === "codex") setAiProvider(v);
|
||||
else setAiProvider("online");
|
||||
}}
|
||||
disabled={loading}
|
||||
>
|
||||
<option value="online">在线</option>
|
||||
<option value="local">本地</option>
|
||||
<option value="ollama">Ollama</option>
|
||||
<option value="codex">Codex</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center justify-between gap-2">
|
||||
<span className="font-medium">模型</span>
|
||||
{aiProvider === "codex" ? (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
消息开头加 <code className="rounded bg-muted px-1 py-0.5">#chat</code> /{" "}
|
||||
<code className="rounded bg-muted px-1 py-0.5">#test</code> /{" "}
|
||||
<code className="rounded bg-muted px-1 py-0.5">#dev</code>(默认 <code className="rounded bg-muted px-1 py-0.5">#chat</code>)。
|
||||
</div>
|
||||
) : aiProvider === "online" ? (
|
||||
<select
|
||||
className="w-[220px] rounded border px-2 py-1 text-xs"
|
||||
value={ONLINE_MODELS.includes(aiModel as any) ? aiModel : ""}
|
||||
onChange={(e) => setAiModel(e.target.value)}
|
||||
disabled={loading}
|
||||
>
|
||||
{ONLINE_MODELS.map((m) => (
|
||||
<option key={m} value={m}>
|
||||
{m || "默认"}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : aiProvider === "ollama" ? (
|
||||
<select
|
||||
className="w-[320px] rounded border px-2 py-1 text-xs"
|
||||
value={aiModel === "" || aiModel === OLLAMA_QWEN3_30B ? aiModel : ""}
|
||||
onChange={(e) => setAiModel(e.target.value)}
|
||||
disabled={loading}
|
||||
>
|
||||
<option value="">默认:{OLLAMA_QWEN3_30B}</option>
|
||||
<option value={OLLAMA_QWEN3_30B}>{OLLAMA_QWEN3_30B}</option>
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
className="w-[320px] rounded border px-2 py-1 text-xs"
|
||||
value={aiModel}
|
||||
onChange={(e) => setAiModel(e.target.value)}
|
||||
placeholder="默认(ai.local.md/环境变量)"
|
||||
disabled={loading}
|
||||
list="oo-local-model-suggestions"
|
||||
/>
|
||||
)}
|
||||
</label>
|
||||
<datalist id="oo-local-model-suggestions">
|
||||
<option value={OLLAMA_QWEN3_30B} />
|
||||
</datalist>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{page === "chat" ? (
|
||||
<div className="flex h-[calc(100vh-96px)] flex-col">
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="space-y-3 p-3 text-sm">
|
||||
{messages.map((m, idx) => (
|
||||
<div key={idx} className="space-y-1">
|
||||
<div className="text-xs text-muted-foreground">{m.role === "user" ? "用户" : "AI"}</div>
|
||||
<div className="whitespace-pre-wrap">{m.content}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<div className="border-t p-3">
|
||||
<div className="flex gap-2">
|
||||
<Textarea
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder="输入你的需求(Enter 发送,Shift+Enter 换行)"
|
||||
className="min-h-[72px] flex-1"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
if (canSend) void send();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button disabled={!canSend} onClick={() => void send()}>
|
||||
发送
|
||||
</Button>
|
||||
<Button variant="secondary" disabled={!loading} onClick={stop}>
|
||||
{aiProvider === "codex" ? "暂停" : "停止"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</AiBridgePanel>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
return <OnlyOfficeAiAgentPanelRuntime {...props} initialOpen />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { useEffect } from "react";
|
||||
import { useSearchPaletteStore } from "@/store/search-palette";
|
||||
|
||||
type SearchPaletteProps = {
|
||||
workspaceId: string | null;
|
||||
};
|
||||
|
||||
const SearchPaletteRuntime = dynamic<SearchPaletteProps>(
|
||||
() => import("./search-palette.runtime").then((mod) => mod.SearchPalette),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => null,
|
||||
},
|
||||
);
|
||||
|
||||
// 轻量 host:负责首开前热键与懒加载,重型搜索面板首次打开后再进入运行态。
|
||||
export function SearchPaletteHost({ workspaceId }: SearchPaletteProps) {
|
||||
const openSearch = useSearchPaletteStore((state) => state.openSearch);
|
||||
const openReference = useSearchPaletteStore((state) => state.openReference);
|
||||
const activated = useSearchPaletteStore((state) => state.activated);
|
||||
|
||||
useEffect(() => {
|
||||
if (activated) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const handleGlobalHotkey = (event: KeyboardEvent) => {
|
||||
if ((event.metaKey || event.ctrlKey) && !event.shiftKey && event.key.toLowerCase() === "p") {
|
||||
event.preventDefault();
|
||||
openSearch();
|
||||
}
|
||||
if ((event.metaKey || event.ctrlKey) && event.shiftKey && event.key.toLowerCase() === "r") {
|
||||
event.preventDefault();
|
||||
openReference();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleGlobalHotkey);
|
||||
return () => window.removeEventListener("keydown", handleGlobalHotkey);
|
||||
}, [activated, openReference, openSearch]);
|
||||
|
||||
if (!activated) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <SearchPaletteRuntime workspaceId={workspaceId} />;
|
||||
}
|
||||
@@ -0,0 +1,657 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { DragEvent as ReactDragEvent } from "react";
|
||||
import { useRouter, useSelectedLayoutSegments } from "next/navigation";
|
||||
import { CalendarClock, ChevronsUpDown, Copy, Layers, Search } from "lucide-react";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useSearchPaletteStore, type ReferenceInsertMode, type SearchPaletteMode } from "@/store/search-palette";
|
||||
import type { DocumentSearchResult, DocumentSearchRequest, DocumentSearchTimeRange } from "@/types/search";
|
||||
import { useDocumentSearch } from "@/hooks/use-document-search";
|
||||
import { useReferenceComposer } from "@/hooks/use-reference-composer";
|
||||
import { recordRecentPage } from "@/lib/search/record-recent";
|
||||
import { PageHoverCard } from "@/components/reference/page-hover-card";
|
||||
import { buildSearchRequest } from "@/lib/search/request";
|
||||
import { resolveSearchOpenMode } from "@/lib/search/shortcuts";
|
||||
|
||||
interface SearchPaletteProps {
|
||||
workspaceId: string | null;
|
||||
}
|
||||
|
||||
const TIME_RANGE_LABEL: Record<DocumentSearchTimeRange, string> = {
|
||||
any: "全部时间",
|
||||
"7d": "最近 7 天",
|
||||
"30d": "最近 30 天",
|
||||
};
|
||||
|
||||
const formatDate = (value: string | null) => {
|
||||
if (!value) return "未知时间";
|
||||
const date = new Date(value);
|
||||
return date.toLocaleString();
|
||||
};
|
||||
|
||||
export function SearchPalette({ workspaceId }: SearchPaletteProps) {
|
||||
const router = useRouter();
|
||||
const segments = useSelectedLayoutSegments();
|
||||
const activeDocumentId = segments?.[1] ?? null;
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const {
|
||||
open,
|
||||
mode,
|
||||
query,
|
||||
filters,
|
||||
timeRange,
|
||||
referenceMode,
|
||||
alias,
|
||||
recent,
|
||||
setRecent,
|
||||
openSearch,
|
||||
openReference,
|
||||
close,
|
||||
setQuery,
|
||||
toggleFilter,
|
||||
setTimeRange,
|
||||
setTimeField,
|
||||
setCustomRange,
|
||||
setReferenceMode,
|
||||
setAlias,
|
||||
rememberResult,
|
||||
} = useSearchPaletteStore();
|
||||
const [highlightedIndex, setHighlightedIndex] = useState(0);
|
||||
const { insertReference } = useReferenceComposer({
|
||||
workspaceId,
|
||||
sourcePageId: activeDocumentId ?? null,
|
||||
});
|
||||
const [resultTab, setResultTab] = useState<"all" | "recent">(recent.length > 0 ? "recent" : "all");
|
||||
const [referenceFiltersOpen, setReferenceFiltersOpen] = useState(mode === "reference");
|
||||
const updateCustomRange = useCallback(
|
||||
(patch: { from?: string; to?: string }) => {
|
||||
const next = { ...(filters.customRange ?? {}), ...patch };
|
||||
if (!next.from && !next.to) {
|
||||
setCustomRange(null);
|
||||
} else {
|
||||
setCustomRange(next);
|
||||
}
|
||||
},
|
||||
[filters.customRange, setCustomRange],
|
||||
);
|
||||
const clearCustomRange = useCallback(() => setCustomRange(null), [setCustomRange]);
|
||||
|
||||
const requestPayload = useMemo<DocumentSearchRequest | null>(
|
||||
() =>
|
||||
buildSearchRequest({
|
||||
workspaceId,
|
||||
activeDocumentId,
|
||||
query,
|
||||
filters,
|
||||
timeRange,
|
||||
}),
|
||||
[workspaceId, activeDocumentId, query, filters, timeRange],
|
||||
);
|
||||
|
||||
const searchQueryEnabled = Boolean(open && workspaceId);
|
||||
const { data, isLoading, isFetching, error } = useDocumentSearch(requestPayload, searchQueryEnabled);
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.recent) {
|
||||
setRecent(data.recent);
|
||||
}
|
||||
}, [data?.recent, setRecent]);
|
||||
/* eslint-disable react-hooks/set-state-in-effect */
|
||||
useEffect(() => {
|
||||
if (mode === "reference") {
|
||||
setReferenceFiltersOpen(true);
|
||||
} else {
|
||||
setReferenceFiltersOpen(false);
|
||||
}
|
||||
}, [mode]);
|
||||
/* eslint-enable react-hooks/set-state-in-effect */
|
||||
const searchResults = data?.results;
|
||||
const remoteResults = useMemo(() => searchResults ?? [], [searchResults]);
|
||||
const showRecent = !query.trim() && recent.length > 0;
|
||||
const enableRecentTab = showRecent;
|
||||
const effectiveTab = enableRecentTab ? resultTab : "all";
|
||||
const activeResults = effectiveTab === "recent" ? recent : remoteResults;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return undefined;
|
||||
}
|
||||
inputRef.current?.focus();
|
||||
const frame = requestAnimationFrame(() => setHighlightedIndex(0));
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (highlightedIndex >= activeResults.length) {
|
||||
const frame = requestAnimationFrame(() =>
|
||||
setHighlightedIndex(activeResults.length > 0 ? activeResults.length - 1 : 0),
|
||||
);
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}
|
||||
return undefined;
|
||||
}, [activeResults.length, highlightedIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleGlobalHotkey = (event: KeyboardEvent) => {
|
||||
if ((event.metaKey || event.ctrlKey) && !event.shiftKey && event.key.toLowerCase() === "p") {
|
||||
event.preventDefault();
|
||||
openSearch();
|
||||
}
|
||||
if ((event.metaKey || event.ctrlKey) && event.shiftKey && event.key.toLowerCase() === "r") {
|
||||
event.preventDefault();
|
||||
openReference();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handleGlobalHotkey);
|
||||
return () => window.removeEventListener("keydown", handleGlobalHotkey);
|
||||
}, [openSearch, openReference]);
|
||||
|
||||
const handleOpenResult = useCallback(
|
||||
(result: DocumentSearchResult, openMode: "main" | "new-window" | "sidebar") => {
|
||||
if (openMode === "new-window") {
|
||||
window.open(result.publicPath, "_blank", "noopener,noreferrer");
|
||||
} else if (openMode === "sidebar") {
|
||||
window.open(`${result.publicPath}?preview=sidebar`, "_blank", "noopener,noreferrer");
|
||||
} else {
|
||||
router.push(result.publicPath);
|
||||
}
|
||||
void recordRecentPage(workspaceId, result.id);
|
||||
rememberResult(result);
|
||||
close();
|
||||
},
|
||||
[router, workspaceId, rememberResult, close],
|
||||
);
|
||||
|
||||
const handleInsertReference = useCallback(
|
||||
(result: DocumentSearchResult, overrideMode?: ReferenceInsertMode) => {
|
||||
const run = async () => {
|
||||
try {
|
||||
const effectiveMode = overrideMode ?? referenceMode;
|
||||
await insertReference(result, {
|
||||
mode: effectiveMode,
|
||||
alias: effectiveMode === "inline" ? alias : undefined,
|
||||
});
|
||||
rememberResult(result);
|
||||
close();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
void run();
|
||||
},
|
||||
[alias, close, insertReference, referenceMode, rememberResult],
|
||||
);
|
||||
|
||||
const handleCopyReference = useCallback((result: DocumentSearchResult) => {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
const payload = `((${result.id}))`;
|
||||
if (navigator?.clipboard) {
|
||||
navigator.clipboard
|
||||
.writeText(payload)
|
||||
.then(() => window.alert("块引用已复制"))
|
||||
.catch(() => {
|
||||
window.prompt("复制失败,请手动复制引用内容", payload);
|
||||
});
|
||||
} else {
|
||||
window.prompt("复制块引用", payload);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
const handleKey = (event: KeyboardEvent) => {
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
setHighlightedIndex((prev) => Math.min(activeResults.length - 1, prev + 1));
|
||||
} else if (event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
setHighlightedIndex((prev) => Math.max(0, prev - 1));
|
||||
} else if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
if (activeResults.length === 0) {
|
||||
return;
|
||||
}
|
||||
const result = activeResults[highlightedIndex] ?? activeResults[0];
|
||||
if (!result) return;
|
||||
if (mode === "search") {
|
||||
const openMode = resolveSearchOpenMode(event);
|
||||
handleOpenResult(result, openMode);
|
||||
} else {
|
||||
handleInsertReference(result);
|
||||
}
|
||||
} else if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
close();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handleKey);
|
||||
return () => window.removeEventListener("keydown", handleKey);
|
||||
}, [open, activeResults, highlightedIndex, mode, handleOpenResult, handleInsertReference, close]);
|
||||
|
||||
const pending = isLoading || isFetching;
|
||||
const dialogTitle = mode === "search" ? "页面搜索面板" : "引用选择面板";
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(next) => !next && close()}>
|
||||
<DialogContent className="max-h-[92vh] w-[min(1000px,96vw)] !max-w-[min(1000px,96vw)] sm:!max-w-[min(1000px,96vw)] overflow-hidden border-none bg-white/95 p-0 shadow-xl">
|
||||
<DialogTitle className="sr-only">{dialogTitle}</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
输入关键词在当前工作区搜索页面标题、正文、思维导图、表格内容与附件文件名;可勾选“搜索附件内容”以纳入附件 OCR/解析文本。
|
||||
</DialogDescription>
|
||||
<div className="flex h-[min(78vh,720px)] min-h-[560px] flex-col">
|
||||
<div className="border-b border-[#eef2ff] p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative flex-1">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={query}
|
||||
onChange={(event) => {
|
||||
const nextValue = event.target.value;
|
||||
if (nextValue.trim().length > 0 && resultTab !== "all") {
|
||||
setResultTab("all");
|
||||
}
|
||||
setQuery(nextValue);
|
||||
}}
|
||||
placeholder={mode === "search" ? "搜索页面标题、正文或附件文件名..." : "选择要引用的页面"}
|
||||
className="h-11 w-full rounded-xl border border-[#e2e8f0] bg-white pl-9 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="gap-2 text-xs text-gray-600">
|
||||
<CalendarClock className="h-4 w-4" />
|
||||
{TIME_RANGE_LABEL[timeRange]}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-40">
|
||||
{(Object.keys(TIME_RANGE_LABEL) as DocumentSearchTimeRange[]).map((range) => (
|
||||
<DropdownMenuItem
|
||||
key={range}
|
||||
onClick={() => setTimeRange(range)}
|
||||
className={cn(range === timeRange && "bg-[#eef2ff] text-[#2563eb]")}
|
||||
>
|
||||
{TIME_RANGE_LABEL[range]}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="text-xs text-gray-600">
|
||||
{filters.timeField === "updated" ? "按编辑时间" : "按创建时间"}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-32">
|
||||
<DropdownMenuItem
|
||||
onClick={() => setTimeField("updated")}
|
||||
className={cn(filters.timeField === "updated" && "bg-[#eef2ff] text-[#2563eb]")}
|
||||
>
|
||||
按编辑时间
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => setTimeField("created")}
|
||||
className={cn(filters.timeField === "created" && "bg-[#eef2ff] text-[#2563eb]")}
|
||||
>
|
||||
按创建时间
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2 text-xs">
|
||||
<FilterToggle label="仅标题" active={filters.titleOnly} onClick={() => toggleFilter("titleOnly")} />
|
||||
<FilterToggle label="精确匹配" active={filters.exact} onClick={() => toggleFilter("exact")} />
|
||||
<FilterToggle
|
||||
label="当前页面"
|
||||
active={filters.onlyCurrentPage && Boolean(activeDocumentId)}
|
||||
disabled={!activeDocumentId}
|
||||
onClick={() => toggleFilter("onlyCurrentPage")}
|
||||
/>
|
||||
<FilterToggle
|
||||
label="搜索附件内容"
|
||||
active={filters.includeOcr}
|
||||
onClick={() => toggleFilter("includeOcr")}
|
||||
/>
|
||||
{mode === "reference" && (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="ml-auto flex items-center gap-1 rounded-full text-xs text-gray-600"
|
||||
onClick={() => setReferenceFiltersOpen((prev) => !prev)}
|
||||
>
|
||||
<ChevronsUpDown className="h-3 w-3" />
|
||||
{referenceFiltersOpen ? "隐藏引用筛选" : "展开引用筛选"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{mode === "reference" && referenceFiltersOpen && (
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs text-gray-500">
|
||||
<ReferenceModeToggle
|
||||
label="行内引用"
|
||||
active={referenceMode === "inline"}
|
||||
onClick={() => setReferenceMode("inline")}
|
||||
/>
|
||||
<ReferenceModeToggle
|
||||
label="嵌入块"
|
||||
active={referenceMode === "embed"}
|
||||
onClick={() => setReferenceMode("embed")}
|
||||
/>
|
||||
{referenceMode === "inline" && (
|
||||
<Input
|
||||
value={alias}
|
||||
onChange={(event) => setAlias(event.target.value)}
|
||||
placeholder="引用别名"
|
||||
className="h-8 w-40 text-xs"
|
||||
/>
|
||||
)}
|
||||
<span className="text-[11px] text-gray-400">引用模式将同步到 [[ / # 快捷键</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs text-gray-500">
|
||||
<span>自定义时间</span>
|
||||
<Input
|
||||
type="date"
|
||||
value={filters.customRange?.from ?? ""}
|
||||
onChange={(event) => updateCustomRange({ from: event.target.value || undefined })}
|
||||
className="h-8 w-36 text-xs"
|
||||
/>
|
||||
<Input
|
||||
type="date"
|
||||
value={filters.customRange?.to ?? ""}
|
||||
onChange={(event) => updateCustomRange({ to: event.target.value || undefined })}
|
||||
className="h-8 w-36 text-xs"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-xs text-gray-500"
|
||||
onClick={clearCustomRange}
|
||||
disabled={!filters.customRange?.from && !filters.customRange?.to}
|
||||
>
|
||||
清除
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{enableRecentTab && (
|
||||
<div className="flex items-center gap-2 border-b border-[#eef2ff] px-4 py-2 text-xs">
|
||||
<TabButton
|
||||
label="最近访问"
|
||||
active={resultTab === "recent"}
|
||||
onClick={() => setResultTab("recent")}
|
||||
disabled={!enableRecentTab}
|
||||
/>
|
||||
<TabButton label="全部页面" active={resultTab === "all"} onClick={() => setResultTab("all")} />
|
||||
</div>
|
||||
)}
|
||||
{pending ? (
|
||||
<div className="flex h-full items-center justify-center text-sm text-gray-500">
|
||||
检索中,请稍候...
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex h-full items-center justify-center text-sm text-red-500">
|
||||
{(error as Error).message}
|
||||
</div>
|
||||
) : activeResults.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center text-sm text-gray-400">
|
||||
{effectiveTab === "recent" ? "暂无最近访问记录" : "暂无匹配结果"}
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-full divide-y divide-[#f1f5f9] overflow-y-auto">
|
||||
{activeResults.map((result, index) => (
|
||||
<ResultRow
|
||||
key={`result-${result.id}-${index}-${resultTab}`}
|
||||
result={result}
|
||||
highlighted={highlightedIndex === index}
|
||||
mode={mode}
|
||||
onOpen={() => handleOpenResult(result, "main")}
|
||||
onReference={() => handleInsertReference(result)}
|
||||
onEmbed={() => handleInsertReference(result, "embed")}
|
||||
onCopy={() => handleCopyReference(result)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="border-t border-[#eef2ff] px-4 py-2 text-xs text-gray-500">
|
||||
{mode === "search" ? (
|
||||
<div className="flex items-center justify-between">
|
||||
<span>Enter 打开 · Ctrl/Cmd+Enter 新窗口 · Alt+Enter 右侧预览</span>
|
||||
<span>Esc 关闭</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-between">
|
||||
<span>
|
||||
{referenceMode === "inline" ? "插入行内引用(链接高亮)" : "插入一个嵌入的页面块"},可先输入别名
|
||||
</span>
|
||||
<span>Esc 关闭</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function ResultRow({
|
||||
result,
|
||||
highlighted,
|
||||
mode,
|
||||
onOpen,
|
||||
onReference,
|
||||
onEmbed,
|
||||
onCopy,
|
||||
}: {
|
||||
result: DocumentSearchResult;
|
||||
highlighted: boolean;
|
||||
mode: SearchPaletteMode;
|
||||
onOpen: () => void;
|
||||
onReference: () => void;
|
||||
onEmbed: () => void;
|
||||
onCopy: () => void;
|
||||
}) {
|
||||
const handleActivate = () => {
|
||||
if (mode === "search") {
|
||||
onOpen();
|
||||
} else {
|
||||
onReference();
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragStart = (event: ReactDragEvent<HTMLDivElement>) => {
|
||||
if (mode !== "reference") return;
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.setData("text/plain", `((${result.id}))`);
|
||||
event.dataTransfer.effectAllowed = "copy";
|
||||
}
|
||||
};
|
||||
|
||||
const content = (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={handleActivate}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
handleActivate();
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"cursor-pointer px-4 py-3 transition-colors hover:bg-[#eef2ff]",
|
||||
highlighted && "bg-[#e3ecff]",
|
||||
)}
|
||||
draggable={mode === "reference"}
|
||||
onDragStart={handleDragStart}
|
||||
>
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-gray-900">
|
||||
{result.title || "无标题"}
|
||||
{result.matchField !== "recent" && (
|
||||
<Badge variant="secondary" className="bg-[#edf2ff] text-xs text-[#2563eb]">
|
||||
{result.matchField === "title" ? "标题匹配" : "正文匹配"}
|
||||
</Badge>
|
||||
)}
|
||||
{result.hasOcr && (
|
||||
<Badge variant="outline" className="text-[10px] text-[#475569]">
|
||||
OCR
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-gray-500" dangerouslySetInnerHTML={{ __html: result.snippet }} />
|
||||
<div className="mt-2 text-[11px] text-gray-400">
|
||||
最近编辑:{formatDate(result.updatedAt)} · 创建时间:{formatDate(result.createdAt)}
|
||||
</div>
|
||||
{mode === "reference" && (
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2 text-[11px] text-gray-500">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 rounded-full px-3"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onReference();
|
||||
}}
|
||||
>
|
||||
行内引用
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="flex h-7 items-center gap-1 rounded-full px-3"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onEmbed();
|
||||
}}
|
||||
>
|
||||
<Layers className="h-3 w-3" />
|
||||
嵌入到...
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="flex h-7 items-center gap-1 rounded-full px-3"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onCopy();
|
||||
}}
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
复制块引用
|
||||
</Button>
|
||||
<span className="ml-auto text-[10px] text-gray-400">也可直接拖拽引用到编辑器</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<HoverCard openDelay={250}>
|
||||
<HoverCardTrigger asChild>{content}</HoverCardTrigger>
|
||||
<HoverCardContent align="start" className="w-[min(720px,90vw)] max-w-[720px]">
|
||||
<PageHoverCard result={result} onPreview={onOpen} />
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
);
|
||||
}
|
||||
|
||||
interface FilterToggleProps {
|
||||
label: string;
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
function FilterToggle({ label, active, onClick, disabled }: FilterToggleProps) {
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={active ? "default" : "ghost"}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"h-7 rounded-full border px-3 text-xs",
|
||||
active
|
||||
? "border-[#2563eb] bg-[#2563eb] text-white"
|
||||
: "border-transparent text-gray-500 hover:bg-gray-100",
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
interface TabButtonProps {
|
||||
label: string;
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
function TabButton({ label, active, onClick, disabled }: TabButtonProps) {
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={active ? "default" : "ghost"}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"rounded-full px-4 text-xs",
|
||||
active ? "bg-[#2563eb] text-white" : "text-gray-600 hover:bg-[#eef2ff]",
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
interface ReferenceModeToggleProps {
|
||||
label: string;
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
function ReferenceModeToggle({ label, active, onClick }: ReferenceModeToggleProps) {
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={active ? "default" : "ghost"}
|
||||
className={cn(
|
||||
"h-7 rounded-full border px-3 text-[11px]",
|
||||
active
|
||||
? "border-[#2563eb] bg-[#2563eb] text-white"
|
||||
: "border-transparent text-gray-500 hover:bg-gray-100",
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -1,657 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { DragEvent as ReactDragEvent } from "react";
|
||||
import { useRouter, useSelectedLayoutSegments } from "next/navigation";
|
||||
import { CalendarClock, ChevronsUpDown, Copy, Layers, Search } from "lucide-react";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useSearchPaletteStore, type ReferenceInsertMode, type SearchPaletteMode } from "@/store/search-palette";
|
||||
import type { DocumentSearchResult, DocumentSearchRequest, DocumentSearchTimeRange } from "@/types/search";
|
||||
import { useDocumentSearch } from "@/hooks/use-document-search";
|
||||
import { useReferenceComposer } from "@/hooks/use-reference-composer";
|
||||
import { recordRecentPage } from "@/lib/search/record-recent";
|
||||
import { PageHoverCard } from "@/components/reference/page-hover-card";
|
||||
import { buildSearchRequest } from "@/lib/search/request";
|
||||
import { resolveSearchOpenMode } from "@/lib/search/shortcuts";
|
||||
import { SearchPaletteHost } from "./SearchPaletteHost";
|
||||
|
||||
interface SearchPaletteProps {
|
||||
workspaceId: string | null;
|
||||
}
|
||||
|
||||
const TIME_RANGE_LABEL: Record<DocumentSearchTimeRange, string> = {
|
||||
any: "全部时间",
|
||||
"7d": "最近 7 天",
|
||||
"30d": "最近 30 天",
|
||||
};
|
||||
|
||||
const formatDate = (value: string | null) => {
|
||||
if (!value) return "未知时间";
|
||||
const date = new Date(value);
|
||||
return date.toLocaleString();
|
||||
};
|
||||
|
||||
export function SearchPalette({ workspaceId }: SearchPaletteProps) {
|
||||
const router = useRouter();
|
||||
const segments = useSelectedLayoutSegments();
|
||||
const activeDocumentId = segments?.[1] ?? null;
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const {
|
||||
open,
|
||||
mode,
|
||||
query,
|
||||
filters,
|
||||
timeRange,
|
||||
referenceMode,
|
||||
alias,
|
||||
recent,
|
||||
setRecent,
|
||||
openSearch,
|
||||
openReference,
|
||||
close,
|
||||
setQuery,
|
||||
toggleFilter,
|
||||
setTimeRange,
|
||||
setTimeField,
|
||||
setCustomRange,
|
||||
setReferenceMode,
|
||||
setAlias,
|
||||
rememberResult,
|
||||
} = useSearchPaletteStore();
|
||||
const [highlightedIndex, setHighlightedIndex] = useState(0);
|
||||
const { insertReference } = useReferenceComposer({
|
||||
workspaceId,
|
||||
sourcePageId: activeDocumentId ?? null,
|
||||
});
|
||||
const [resultTab, setResultTab] = useState<"all" | "recent">(recent.length > 0 ? "recent" : "all");
|
||||
const [referenceFiltersOpen, setReferenceFiltersOpen] = useState(mode === "reference");
|
||||
const updateCustomRange = useCallback(
|
||||
(patch: { from?: string; to?: string }) => {
|
||||
const next = { ...(filters.customRange ?? {}), ...patch };
|
||||
if (!next.from && !next.to) {
|
||||
setCustomRange(null);
|
||||
} else {
|
||||
setCustomRange(next);
|
||||
}
|
||||
},
|
||||
[filters.customRange, setCustomRange],
|
||||
);
|
||||
const clearCustomRange = useCallback(() => setCustomRange(null), [setCustomRange]);
|
||||
|
||||
const requestPayload = useMemo<DocumentSearchRequest | null>(
|
||||
() =>
|
||||
buildSearchRequest({
|
||||
workspaceId,
|
||||
activeDocumentId,
|
||||
query,
|
||||
filters,
|
||||
timeRange,
|
||||
}),
|
||||
[workspaceId, activeDocumentId, query, filters, timeRange],
|
||||
);
|
||||
|
||||
const searchQueryEnabled = Boolean(open && workspaceId);
|
||||
const { data, isLoading, isFetching, error } = useDocumentSearch(requestPayload, searchQueryEnabled);
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.recent) {
|
||||
setRecent(data.recent);
|
||||
}
|
||||
}, [data?.recent, setRecent]);
|
||||
/* eslint-disable react-hooks/set-state-in-effect */
|
||||
useEffect(() => {
|
||||
if (mode === "reference") {
|
||||
setReferenceFiltersOpen(true);
|
||||
} else {
|
||||
setReferenceFiltersOpen(false);
|
||||
}
|
||||
}, [mode]);
|
||||
/* eslint-enable react-hooks/set-state-in-effect */
|
||||
const searchResults = data?.results;
|
||||
const remoteResults = useMemo(() => searchResults ?? [], [searchResults]);
|
||||
const showRecent = !query.trim() && recent.length > 0;
|
||||
const enableRecentTab = showRecent;
|
||||
const effectiveTab = enableRecentTab ? resultTab : "all";
|
||||
const activeResults = effectiveTab === "recent" ? recent : remoteResults;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return undefined;
|
||||
}
|
||||
inputRef.current?.focus();
|
||||
const frame = requestAnimationFrame(() => setHighlightedIndex(0));
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (highlightedIndex >= activeResults.length) {
|
||||
const frame = requestAnimationFrame(() =>
|
||||
setHighlightedIndex(activeResults.length > 0 ? activeResults.length - 1 : 0),
|
||||
);
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}
|
||||
return undefined;
|
||||
}, [activeResults.length, highlightedIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleGlobalHotkey = (event: KeyboardEvent) => {
|
||||
if ((event.metaKey || event.ctrlKey) && !event.shiftKey && event.key.toLowerCase() === "p") {
|
||||
event.preventDefault();
|
||||
openSearch();
|
||||
}
|
||||
if ((event.metaKey || event.ctrlKey) && event.shiftKey && event.key.toLowerCase() === "r") {
|
||||
event.preventDefault();
|
||||
openReference();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handleGlobalHotkey);
|
||||
return () => window.removeEventListener("keydown", handleGlobalHotkey);
|
||||
}, [openSearch, openReference]);
|
||||
|
||||
const handleOpenResult = useCallback(
|
||||
(result: DocumentSearchResult, openMode: "main" | "new-window" | "sidebar") => {
|
||||
if (openMode === "new-window") {
|
||||
window.open(result.publicPath, "_blank", "noopener,noreferrer");
|
||||
} else if (openMode === "sidebar") {
|
||||
window.open(`${result.publicPath}?preview=sidebar`, "_blank", "noopener,noreferrer");
|
||||
} else {
|
||||
router.push(result.publicPath);
|
||||
}
|
||||
void recordRecentPage(workspaceId, result.id);
|
||||
rememberResult(result);
|
||||
close();
|
||||
},
|
||||
[router, workspaceId, rememberResult, close],
|
||||
);
|
||||
|
||||
const handleInsertReference = useCallback(
|
||||
(result: DocumentSearchResult, overrideMode?: ReferenceInsertMode) => {
|
||||
const run = async () => {
|
||||
try {
|
||||
const effectiveMode = overrideMode ?? referenceMode;
|
||||
await insertReference(result, {
|
||||
mode: effectiveMode,
|
||||
alias: effectiveMode === "inline" ? alias : undefined,
|
||||
});
|
||||
rememberResult(result);
|
||||
close();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
void run();
|
||||
},
|
||||
[alias, close, insertReference, referenceMode, rememberResult],
|
||||
);
|
||||
|
||||
const handleCopyReference = useCallback((result: DocumentSearchResult) => {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
const payload = `((${result.id}))`;
|
||||
if (navigator?.clipboard) {
|
||||
navigator.clipboard
|
||||
.writeText(payload)
|
||||
.then(() => window.alert("块引用已复制"))
|
||||
.catch(() => {
|
||||
window.prompt("复制失败,请手动复制引用内容", payload);
|
||||
});
|
||||
} else {
|
||||
window.prompt("复制块引用", payload);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
const handleKey = (event: KeyboardEvent) => {
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
setHighlightedIndex((prev) => Math.min(activeResults.length - 1, prev + 1));
|
||||
} else if (event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
setHighlightedIndex((prev) => Math.max(0, prev - 1));
|
||||
} else if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
if (activeResults.length === 0) {
|
||||
return;
|
||||
}
|
||||
const result = activeResults[highlightedIndex] ?? activeResults[0];
|
||||
if (!result) return;
|
||||
if (mode === "search") {
|
||||
const openMode = resolveSearchOpenMode(event);
|
||||
handleOpenResult(result, openMode);
|
||||
} else {
|
||||
handleInsertReference(result);
|
||||
}
|
||||
} else if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
close();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handleKey);
|
||||
return () => window.removeEventListener("keydown", handleKey);
|
||||
}, [open, activeResults, highlightedIndex, mode, handleOpenResult, handleInsertReference, close]);
|
||||
|
||||
const pending = isLoading || isFetching;
|
||||
const dialogTitle = mode === "search" ? "页面搜索面板" : "引用选择面板";
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(next) => !next && close()}>
|
||||
<DialogContent className="max-h-[92vh] w-[min(1000px,96vw)] !max-w-[min(1000px,96vw)] sm:!max-w-[min(1000px,96vw)] overflow-hidden border-none bg-white/95 p-0 shadow-xl">
|
||||
<DialogTitle className="sr-only">{dialogTitle}</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
输入关键词在当前工作区搜索页面标题、正文、思维导图、表格内容与附件文件名;可勾选“搜索附件内容”以纳入附件 OCR/解析文本。
|
||||
</DialogDescription>
|
||||
<div className="flex h-[min(78vh,720px)] min-h-[560px] flex-col">
|
||||
<div className="border-b border-[#eef2ff] p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative flex-1">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={query}
|
||||
onChange={(event) => {
|
||||
const nextValue = event.target.value;
|
||||
if (nextValue.trim().length > 0 && resultTab !== "all") {
|
||||
setResultTab("all");
|
||||
}
|
||||
setQuery(nextValue);
|
||||
}}
|
||||
placeholder={mode === "search" ? "搜索页面标题、正文或附件文件名..." : "选择要引用的页面"}
|
||||
className="h-11 w-full rounded-xl border border-[#e2e8f0] bg-white pl-9 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="gap-2 text-xs text-gray-600">
|
||||
<CalendarClock className="h-4 w-4" />
|
||||
{TIME_RANGE_LABEL[timeRange]}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-40">
|
||||
{(Object.keys(TIME_RANGE_LABEL) as DocumentSearchTimeRange[]).map((range) => (
|
||||
<DropdownMenuItem
|
||||
key={range}
|
||||
onClick={() => setTimeRange(range)}
|
||||
className={cn(range === timeRange && "bg-[#eef2ff] text-[#2563eb]")}
|
||||
>
|
||||
{TIME_RANGE_LABEL[range]}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="text-xs text-gray-600">
|
||||
{filters.timeField === "updated" ? "按编辑时间" : "按创建时间"}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-32">
|
||||
<DropdownMenuItem
|
||||
onClick={() => setTimeField("updated")}
|
||||
className={cn(filters.timeField === "updated" && "bg-[#eef2ff] text-[#2563eb]")}
|
||||
>
|
||||
按编辑时间
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => setTimeField("created")}
|
||||
className={cn(filters.timeField === "created" && "bg-[#eef2ff] text-[#2563eb]")}
|
||||
>
|
||||
按创建时间
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2 text-xs">
|
||||
<FilterToggle label="仅标题" active={filters.titleOnly} onClick={() => toggleFilter("titleOnly")} />
|
||||
<FilterToggle label="精确匹配" active={filters.exact} onClick={() => toggleFilter("exact")} />
|
||||
<FilterToggle
|
||||
label="当前页面"
|
||||
active={filters.onlyCurrentPage && Boolean(activeDocumentId)}
|
||||
disabled={!activeDocumentId}
|
||||
onClick={() => toggleFilter("onlyCurrentPage")}
|
||||
/>
|
||||
<FilterToggle
|
||||
label="搜索附件内容"
|
||||
active={filters.includeOcr}
|
||||
onClick={() => toggleFilter("includeOcr")}
|
||||
/>
|
||||
{mode === "reference" && (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="ml-auto flex items-center gap-1 rounded-full text-xs text-gray-600"
|
||||
onClick={() => setReferenceFiltersOpen((prev) => !prev)}
|
||||
>
|
||||
<ChevronsUpDown className="h-3 w-3" />
|
||||
{referenceFiltersOpen ? "隐藏引用筛选" : "展开引用筛选"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{mode === "reference" && referenceFiltersOpen && (
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs text-gray-500">
|
||||
<ReferenceModeToggle
|
||||
label="行内引用"
|
||||
active={referenceMode === "inline"}
|
||||
onClick={() => setReferenceMode("inline")}
|
||||
/>
|
||||
<ReferenceModeToggle
|
||||
label="嵌入块"
|
||||
active={referenceMode === "embed"}
|
||||
onClick={() => setReferenceMode("embed")}
|
||||
/>
|
||||
{referenceMode === "inline" && (
|
||||
<Input
|
||||
value={alias}
|
||||
onChange={(event) => setAlias(event.target.value)}
|
||||
placeholder="引用别名"
|
||||
className="h-8 w-40 text-xs"
|
||||
/>
|
||||
)}
|
||||
<span className="text-[11px] text-gray-400">引用模式将同步到 [[ / # 快捷键</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs text-gray-500">
|
||||
<span>自定义时间</span>
|
||||
<Input
|
||||
type="date"
|
||||
value={filters.customRange?.from ?? ""}
|
||||
onChange={(event) => updateCustomRange({ from: event.target.value || undefined })}
|
||||
className="h-8 w-36 text-xs"
|
||||
/>
|
||||
<Input
|
||||
type="date"
|
||||
value={filters.customRange?.to ?? ""}
|
||||
onChange={(event) => updateCustomRange({ to: event.target.value || undefined })}
|
||||
className="h-8 w-36 text-xs"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-xs text-gray-500"
|
||||
onClick={clearCustomRange}
|
||||
disabled={!filters.customRange?.from && !filters.customRange?.to}
|
||||
>
|
||||
清除
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{enableRecentTab && (
|
||||
<div className="flex items-center gap-2 border-b border-[#eef2ff] px-4 py-2 text-xs">
|
||||
<TabButton
|
||||
label="最近访问"
|
||||
active={resultTab === "recent"}
|
||||
onClick={() => setResultTab("recent")}
|
||||
disabled={!enableRecentTab}
|
||||
/>
|
||||
<TabButton label="全部页面" active={resultTab === "all"} onClick={() => setResultTab("all")} />
|
||||
</div>
|
||||
)}
|
||||
{pending ? (
|
||||
<div className="flex h-full items-center justify-center text-sm text-gray-500">
|
||||
检索中,请稍候...
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex h-full items-center justify-center text-sm text-red-500">
|
||||
{(error as Error).message}
|
||||
</div>
|
||||
) : activeResults.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center text-sm text-gray-400">
|
||||
{effectiveTab === "recent" ? "暂无最近访问记录" : "暂无匹配结果"}
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-full divide-y divide-[#f1f5f9] overflow-y-auto">
|
||||
{activeResults.map((result, index) => (
|
||||
<ResultRow
|
||||
key={`result-${result.id}-${index}-${resultTab}`}
|
||||
result={result}
|
||||
highlighted={highlightedIndex === index}
|
||||
mode={mode}
|
||||
onOpen={() => handleOpenResult(result, "main")}
|
||||
onReference={() => handleInsertReference(result)}
|
||||
onEmbed={() => handleInsertReference(result, "embed")}
|
||||
onCopy={() => handleCopyReference(result)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="border-t border-[#eef2ff] px-4 py-2 text-xs text-gray-500">
|
||||
{mode === "search" ? (
|
||||
<div className="flex items-center justify-between">
|
||||
<span>Enter 打开 · Ctrl/Cmd+Enter 新窗口 · Alt+Enter 右侧预览</span>
|
||||
<span>Esc 关闭</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-between">
|
||||
<span>
|
||||
{referenceMode === "inline" ? "插入行内引用(链接高亮)" : "插入一个嵌入的页面块"},可先输入别名
|
||||
</span>
|
||||
<span>Esc 关闭</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function ResultRow({
|
||||
result,
|
||||
highlighted,
|
||||
mode,
|
||||
onOpen,
|
||||
onReference,
|
||||
onEmbed,
|
||||
onCopy,
|
||||
}: {
|
||||
result: DocumentSearchResult;
|
||||
highlighted: boolean;
|
||||
mode: SearchPaletteMode;
|
||||
onOpen: () => void;
|
||||
onReference: () => void;
|
||||
onEmbed: () => void;
|
||||
onCopy: () => void;
|
||||
}) {
|
||||
const handleActivate = () => {
|
||||
if (mode === "search") {
|
||||
onOpen();
|
||||
} else {
|
||||
onReference();
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragStart = (event: ReactDragEvent<HTMLDivElement>) => {
|
||||
if (mode !== "reference") return;
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.setData("text/plain", `((${result.id}))`);
|
||||
event.dataTransfer.effectAllowed = "copy";
|
||||
}
|
||||
};
|
||||
|
||||
const content = (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={handleActivate}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
handleActivate();
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"cursor-pointer px-4 py-3 transition-colors hover:bg-[#eef2ff]",
|
||||
highlighted && "bg-[#e3ecff]",
|
||||
)}
|
||||
draggable={mode === "reference"}
|
||||
onDragStart={handleDragStart}
|
||||
>
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-gray-900">
|
||||
{result.title || "无标题"}
|
||||
{result.matchField !== "recent" && (
|
||||
<Badge variant="secondary" className="bg-[#edf2ff] text-xs text-[#2563eb]">
|
||||
{result.matchField === "title" ? "标题匹配" : "正文匹配"}
|
||||
</Badge>
|
||||
)}
|
||||
{result.hasOcr && (
|
||||
<Badge variant="outline" className="text-[10px] text-[#475569]">
|
||||
OCR
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-gray-500" dangerouslySetInnerHTML={{ __html: result.snippet }} />
|
||||
<div className="mt-2 text-[11px] text-gray-400">
|
||||
最近编辑:{formatDate(result.updatedAt)} · 创建时间:{formatDate(result.createdAt)}
|
||||
</div>
|
||||
{mode === "reference" && (
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2 text-[11px] text-gray-500">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 rounded-full px-3"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onReference();
|
||||
}}
|
||||
>
|
||||
行内引用
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="flex h-7 items-center gap-1 rounded-full px-3"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onEmbed();
|
||||
}}
|
||||
>
|
||||
<Layers className="h-3 w-3" />
|
||||
嵌入到...
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="flex h-7 items-center gap-1 rounded-full px-3"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onCopy();
|
||||
}}
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
复制块引用
|
||||
</Button>
|
||||
<span className="ml-auto text-[10px] text-gray-400">也可直接拖拽引用到编辑器</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<HoverCard openDelay={250}>
|
||||
<HoverCardTrigger asChild>{content}</HoverCardTrigger>
|
||||
<HoverCardContent align="start" className="w-[min(720px,90vw)] max-w-[720px]">
|
||||
<PageHoverCard result={result} onPreview={onOpen} />
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
);
|
||||
}
|
||||
|
||||
interface FilterToggleProps {
|
||||
label: string;
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
function FilterToggle({ label, active, onClick, disabled }: FilterToggleProps) {
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={active ? "default" : "ghost"}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"h-7 rounded-full border px-3 text-xs",
|
||||
active
|
||||
? "border-[#2563eb] bg-[#2563eb] text-white"
|
||||
: "border-transparent text-gray-500 hover:bg-gray-100",
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
interface TabButtonProps {
|
||||
label: string;
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
function TabButton({ label, active, onClick, disabled }: TabButtonProps) {
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={active ? "default" : "ghost"}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"rounded-full px-4 text-xs",
|
||||
active ? "bg-[#2563eb] text-white" : "text-gray-600 hover:bg-[#eef2ff]",
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
interface ReferenceModeToggleProps {
|
||||
label: string;
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
function ReferenceModeToggle({ label, active, onClick }: ReferenceModeToggleProps) {
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={active ? "default" : "ghost"}
|
||||
className={cn(
|
||||
"h-7 rounded-full border px-3 text-[11px]",
|
||||
active
|
||||
? "border-[#2563eb] bg-[#2563eb] text-white"
|
||||
: "border-transparent text-gray-500 hover:bg-gray-100",
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
// 轻量入口:供全局布局接线,真正的搜索运行态由 host 首次打开时按需加载。
|
||||
export const SearchPalette = SearchPaletteHost;
|
||||
|
||||
@@ -16,18 +16,18 @@ import { CSS } from "@dnd-kit/utilities";
|
||||
import { useVirtualizer, type VirtualItem } from "@tanstack/react-virtual";
|
||||
import { ChevronRight, GripVertical, MoreHorizontal, Plus } from "lucide-react";
|
||||
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
|
||||
import type { DocumentNode } from "@/lib/documents";
|
||||
import type { SidebarTreeNode } from "@/lib/kernel-sidebar";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { flattenDocumentTree } from "@/lib/sidebar-tree";
|
||||
|
||||
interface PrivateTreeProps {
|
||||
nodes: DocumentNode[];
|
||||
nodes: SidebarTreeNode[];
|
||||
expanded: Set<string>;
|
||||
activeId: string;
|
||||
onToggleExpand: (id: string) => void;
|
||||
onMove: (nodeId: string, parentId: string | null, index: number) => void;
|
||||
onCreateChild: (parentId: string | null) => void;
|
||||
onContextMenu: (event: React.MouseEvent, node: DocumentNode) => void;
|
||||
onContextMenu: (event: React.MouseEvent, node: SidebarTreeNode) => void;
|
||||
}
|
||||
|
||||
const ROW_HEIGHT = 36;
|
||||
@@ -172,7 +172,7 @@ function VirtualRow({
|
||||
}
|
||||
|
||||
interface SortableTreeRowProps {
|
||||
node: DocumentNode;
|
||||
node: SidebarTreeNode;
|
||||
depth: number;
|
||||
expanded: boolean;
|
||||
hasChildren: boolean;
|
||||
|
||||
@@ -33,11 +33,10 @@ import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Drawer, DrawerContent, DrawerHeader, DrawerTitle } from "@/components/ui/drawer";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { DocumentNode } from "@/lib/documents";
|
||||
import { buildDocumentTree } from "@/lib/documents";
|
||||
import type { SidebarTreeNode } from "@/lib/kernel-sidebar";
|
||||
import { useSidebarStore } from "@/store/sidebar";
|
||||
import type { SidebarInitialData, SidebarSectionId } from "@/components/sidebar/types";
|
||||
import { useConvexSidebarData } from "@/hooks/use-convex-sidebar-data";
|
||||
import { useSidebarData } from "@/hooks/use-sidebar-data";
|
||||
import { PrivateTree } from "@/components/sidebar/private-tree";
|
||||
import { buildSidebarSectionsFromTree, flattenDocumentTree } from "@/lib/sidebar-tree";
|
||||
import { useSearchPaletteStore } from "@/store/search-palette";
|
||||
@@ -127,7 +126,7 @@ interface SidebarProps {
|
||||
}
|
||||
|
||||
interface ContextMenuState {
|
||||
node: DocumentNode;
|
||||
node: SidebarTreeNode;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
@@ -138,8 +137,8 @@ export function Sidebar({ initialData }: SidebarProps) {
|
||||
|
||||
// Convex 模式专用组件 - 只调用 Convex hooks
|
||||
function SidebarConvex({ initialData }: SidebarProps) {
|
||||
const convexData = useConvexSidebarData(initialData.activeWorkspaceId);
|
||||
return <SidebarContent initialData={initialData} sidebarQuery={convexData} />;
|
||||
const sidebarData = useSidebarData(initialData);
|
||||
return <SidebarContent initialData={initialData} sidebarQuery={sidebarData} />;
|
||||
}
|
||||
|
||||
// 共享的 UI 内容组件 - 包含所有现有的 Sidebar 逻辑
|
||||
@@ -173,7 +172,7 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
const activeId = segments?.[1] ?? "";
|
||||
const editorBridge = useEditorBridgeStore((state) => state.bridge);
|
||||
|
||||
const [tree, setTree] = useState<DocumentNode[]>(() => buildDocumentTree(sidebarData.documents));
|
||||
const [tree, setTree] = useState<SidebarTreeNode[]>(() => sidebarData.kernelSidebarTree ?? []);
|
||||
const [filter, setFilter] = useState("");
|
||||
const [expanded, setExpanded] = useState<Set<string>>(() => collectNodeIds(tree));
|
||||
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
|
||||
@@ -227,7 +226,7 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
const [tableAssets, setTableAssets] = useState<MediaAsset[]>(sidebarData.tableAssets ?? []);
|
||||
const [moveEmbedOpen, setMoveEmbedOpen] = useState(false);
|
||||
const [moveEmbedMode, setMoveEmbedMode] = useState<MoveEmbedMode>("move");
|
||||
const [moveEmbedSource, setMoveEmbedSource] = useState<DocumentNode | null>(null);
|
||||
const [moveEmbedSource, setMoveEmbedSource] = useState<SidebarTreeNode | null>(null);
|
||||
const [assetMenu, setAssetMenu] = useState<{ asset: MediaAsset; x: number; y: number } | null>(
|
||||
null,
|
||||
);
|
||||
@@ -244,11 +243,11 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
|
||||
useEffect(() => {
|
||||
setTree(() => {
|
||||
const nextTree = buildDocumentTree(sidebarData.documents);
|
||||
const nextTree = sidebarData.kernelSidebarTree ?? [];
|
||||
setExpanded((expandedPrev) => collectNodeIds(nextTree, new Set(expandedPrev)));
|
||||
return nextTree;
|
||||
});
|
||||
}, [sidebarData.documents]);
|
||||
}, [sidebarData.kernelSidebarTree]);
|
||||
|
||||
useEffect(() => {
|
||||
setMediaAssets(sidebarData.mediaAssets ?? []);
|
||||
@@ -436,8 +435,8 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
const privateTree = useMemo(() => sections.find((section) => section.id === "private")?.nodes ?? [], [sections]);
|
||||
|
||||
const nodeById = useMemo(() => {
|
||||
const map = new Map<string, DocumentNode>();
|
||||
const walk = (nodes: DocumentNode[]) => {
|
||||
const map = new Map<string, SidebarTreeNode>();
|
||||
const walk = (nodes: SidebarTreeNode[]) => {
|
||||
nodes.forEach((node) => {
|
||||
map.set(node.id, node);
|
||||
if (node.children.length > 0) {
|
||||
@@ -450,10 +449,10 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
}, [tree]);
|
||||
|
||||
const publicGroupNodesByGroupId = useMemo(() => {
|
||||
const map = new Map<string, DocumentNode[]>();
|
||||
const map = new Map<string, SidebarTreeNode[]>();
|
||||
for (const g of groupPublicSummary) {
|
||||
const nodes: DocumentNode[] = [];
|
||||
const uniq = new Map<string, DocumentNode>();
|
||||
const nodes: SidebarTreeNode[] = [];
|
||||
const uniq = new Map<string, SidebarTreeNode>();
|
||||
for (const d of g.documents ?? []) {
|
||||
const node = nodeById.get(d.documentId);
|
||||
if (node) {
|
||||
@@ -631,23 +630,23 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
[router, setOpen],
|
||||
);
|
||||
|
||||
const handleCopyLink = useCallback(async (node: DocumentNode, includeTitle = false) => {
|
||||
const handleCopyLink = useCallback(async (node: SidebarTreeNode, includeTitle = false) => {
|
||||
const url = buildDocumentUrl(node.id);
|
||||
const payload = includeTitle ? `${node.title ?? "无标题"}\n${url}` : url;
|
||||
await copyText(payload, includeTitle ? "标题 + 链接已复制" : "页面链接已复制");
|
||||
}, []);
|
||||
|
||||
const handleCopyReference = useCallback(async (node: DocumentNode, mode: "inline" | "embed") => {
|
||||
const handleCopyReference = useCallback(async (node: SidebarTreeNode, mode: "inline" | "embed") => {
|
||||
const template = mode === "inline" ? `((${node.id}))` : `{{${node.id}}}`;
|
||||
await copyText(template, mode === "inline" ? "行内引用已复制" : "嵌入引用已复制");
|
||||
}, []);
|
||||
|
||||
const handleCopyId = useCallback(async (node: DocumentNode) => {
|
||||
const handleCopyId = useCallback(async (node: SidebarTreeNode) => {
|
||||
await copyText(node.id, "页面 ID 已复制");
|
||||
}, []);
|
||||
|
||||
const handleDuplicateDocument = useCallback(
|
||||
async (node: DocumentNode) => {
|
||||
async (node: SidebarTreeNode) => {
|
||||
const response = await fetch("/api/documents/duplicate", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -662,13 +661,13 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
[refreshTree],
|
||||
);
|
||||
|
||||
const openMoveEmbedPicker = useCallback((node: DocumentNode, nextMode: MoveEmbedMode) => {
|
||||
const openMoveEmbedPicker = useCallback((node: SidebarTreeNode, nextMode: MoveEmbedMode) => {
|
||||
setMoveEmbedSource(node);
|
||||
setMoveEmbedMode(nextMode);
|
||||
setMoveEmbedOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleEmbedPrompt = useCallback(async (node: DocumentNode) => {
|
||||
const handleEmbedPrompt = useCallback(async (node: SidebarTreeNode) => {
|
||||
openMoveEmbedPicker(node, "embed");
|
||||
}, [openMoveEmbedPicker]);
|
||||
|
||||
@@ -1380,19 +1379,26 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as DocumentNode;
|
||||
const nextNode: DocumentNode = {
|
||||
const payload = (await response.json()) as SidebarTreeNode;
|
||||
const nextNode: SidebarTreeNode = {
|
||||
...payload,
|
||||
access_scope: payload.access_scope ?? "private",
|
||||
is_template: payload.is_template ?? false,
|
||||
updated_at: payload.updated_at ?? payload.created_at,
|
||||
title: payload.title ?? "无标题",
|
||||
children: [],
|
||||
kernel: {
|
||||
nodeType: "page",
|
||||
depth: 0,
|
||||
position: payload.sort_order ?? null,
|
||||
childCount: 0,
|
||||
expandedByDefault: true,
|
||||
},
|
||||
};
|
||||
|
||||
setTree((prev) => {
|
||||
// 防止同一节点被插入多次(会导致重复 key / 列表出现“同一个页面两条记录”)
|
||||
const exists = (nodes: DocumentNode[]): boolean => {
|
||||
const exists = (nodes: SidebarTreeNode[]): boolean => {
|
||||
for (const node of nodes) {
|
||||
if (node.id === nextNode.id) return true;
|
||||
if (node.children.length > 0 && exists(node.children)) return true;
|
||||
@@ -1442,7 +1448,7 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
[refreshTree],
|
||||
);
|
||||
|
||||
const moveLocalNode = useCallback((currentTree: DocumentNode[], nodeId: string, parentId: string | null, index: number) => {
|
||||
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) {
|
||||
@@ -1730,7 +1736,7 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
);
|
||||
|
||||
const handleMovePrompt = useCallback(
|
||||
async (node: DocumentNode) => {
|
||||
async (node: SidebarTreeNode) => {
|
||||
openMoveEmbedPicker(node, "move");
|
||||
},
|
||||
[openMoveEmbedPicker],
|
||||
@@ -1753,7 +1759,7 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
);
|
||||
|
||||
const handleDeleteFromContextMenuNode = useCallback(
|
||||
async (node: DocumentNode) => {
|
||||
async (node: SidebarTreeNode) => {
|
||||
if (viewMode === "filesystem") {
|
||||
await handleDeleteFileTreeSelection();
|
||||
return;
|
||||
@@ -2140,7 +2146,7 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
}
|
||||
}, [router, signOut, signingOut]);
|
||||
|
||||
const openContextMenu = useCallback((event: React.MouseEvent, node: DocumentNode) => {
|
||||
const openContextMenu = useCallback((event: React.MouseEvent, node: SidebarTreeNode) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setContextMenu({
|
||||
@@ -2150,7 +2156,7 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
});
|
||||
}, []);
|
||||
|
||||
const openShareDialog = useCallback((node: DocumentNode) => {
|
||||
const openShareDialog = useCallback((node: SidebarTreeNode) => {
|
||||
setShareTarget({
|
||||
id: node.id,
|
||||
title: node.title ?? null,
|
||||
@@ -2264,7 +2270,7 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
</div>
|
||||
<div className="max-h-52 overflow-auto rounded-md border border-[#eff2f6] bg-white px-2 py-2">
|
||||
{(() => {
|
||||
const renderList = (nodes: DocumentNode[]) => {
|
||||
const renderList = (nodes: SidebarTreeNode[]) => {
|
||||
const flat = flattenDocumentTree(nodes, collectNodeIds(nodes));
|
||||
if (flat.length === 0) {
|
||||
return <div className="px-2 py-2 text-xs text-gray-400">暂无内容</div>;
|
||||
@@ -2842,7 +2848,7 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
interface SectionListProps {
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
nodes: DocumentNode[];
|
||||
nodes: SidebarTreeNode[];
|
||||
collapsed: boolean;
|
||||
onToggle: () => void;
|
||||
}
|
||||
@@ -2885,14 +2891,14 @@ function SectionList({ label, icon, nodes, collapsed, onToggle }: SectionListPro
|
||||
interface ContextMenuProps {
|
||||
contextMenu: ContextMenuState;
|
||||
onClose: () => void;
|
||||
onOpenRight: (node: DocumentNode) => void;
|
||||
onShare: (node: DocumentNode) => void;
|
||||
onMove: (node: DocumentNode) => void;
|
||||
onEmbed: (node: DocumentNode) => void;
|
||||
onCopyLink: (node: DocumentNode, withTitle?: boolean) => void;
|
||||
onCopyReference: (node: DocumentNode, mode: "inline" | "embed") => void;
|
||||
onCopyId: (node: DocumentNode) => void;
|
||||
onDuplicate: (node: DocumentNode) => void;
|
||||
onOpenRight: (node: SidebarTreeNode) => void;
|
||||
onShare: (node: SidebarTreeNode) => void;
|
||||
onMove: (node: SidebarTreeNode) => void;
|
||||
onEmbed: (node: SidebarTreeNode) => void;
|
||||
onCopyLink: (node: SidebarTreeNode, withTitle?: boolean) => void;
|
||||
onCopyReference: (node: SidebarTreeNode, mode: "inline" | "embed") => void;
|
||||
onCopyId: (node: SidebarTreeNode) => void;
|
||||
onDuplicate: (node: SidebarTreeNode) => void;
|
||||
onRename: () => void;
|
||||
onCreateChild: () => void;
|
||||
onConvertChild: () => void;
|
||||
@@ -3064,17 +3070,17 @@ function ContextMenu({
|
||||
);
|
||||
}
|
||||
|
||||
const cloneNodes = (nodes: DocumentNode[]): DocumentNode[] =>
|
||||
const cloneNodes = (nodes: SidebarTreeNode[]): SidebarTreeNode[] =>
|
||||
nodes.map((node) => ({
|
||||
...node,
|
||||
children: cloneNodes(node.children),
|
||||
}));
|
||||
|
||||
const removeNode = (
|
||||
nodes: DocumentNode[],
|
||||
nodes: SidebarTreeNode[],
|
||||
targetId: string,
|
||||
): { removed: DocumentNode | null; tree: DocumentNode[] } => {
|
||||
let removed: DocumentNode | null = null;
|
||||
): { removed: SidebarTreeNode | null; tree: SidebarTreeNode[] } => {
|
||||
let removed: SidebarTreeNode | null = null;
|
||||
const nextTree = nodes
|
||||
.map((node) => {
|
||||
if (removed) return node;
|
||||
@@ -3089,11 +3095,11 @@ const removeNode = (
|
||||
}
|
||||
return node;
|
||||
})
|
||||
.filter(Boolean) as DocumentNode[];
|
||||
.filter(Boolean) as SidebarTreeNode[];
|
||||
return { removed, tree: nextTree };
|
||||
};
|
||||
|
||||
const insertNode = (nodes: DocumentNode[], parentId: string | null, index: number, newNode: DocumentNode): DocumentNode[] => {
|
||||
const insertNode = (nodes: SidebarTreeNode[], parentId: string | null, index: number, newNode: SidebarTreeNode): SidebarTreeNode[] => {
|
||||
if (!parentId) {
|
||||
const next = [...nodes];
|
||||
next.splice(Math.min(index, next.length), 0, newNode);
|
||||
@@ -3110,11 +3116,11 @@ const insertNode = (nodes: DocumentNode[], parentId: string | null, index: numbe
|
||||
});
|
||||
};
|
||||
|
||||
const filterTree = (nodes: DocumentNode[], keyword: string): DocumentNode[] => {
|
||||
const filterTree = (nodes: SidebarTreeNode[], keyword: string): SidebarTreeNode[] => {
|
||||
if (!keyword) {
|
||||
return nodes;
|
||||
}
|
||||
const filtered: DocumentNode[] = [];
|
||||
const filtered: SidebarTreeNode[] = [];
|
||||
nodes.forEach((node) => {
|
||||
const childMatches = filterTree(node.children, keyword);
|
||||
const title = (node.title ?? "").toLowerCase();
|
||||
@@ -3125,7 +3131,7 @@ const filterTree = (nodes: DocumentNode[], keyword: string): DocumentNode[] => {
|
||||
return filtered;
|
||||
};
|
||||
|
||||
function collectNodeIds(nodes: DocumentNode[], bag: Set<string> = new Set()): Set<string> {
|
||||
function collectNodeIds(nodes: SidebarTreeNode[], bag: Set<string> = new Set()): Set<string> {
|
||||
nodes.forEach((node) => {
|
||||
bag.add(node.id);
|
||||
collectNodeIds(node.children, bag);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { DocumentRecord } from "@/lib/documents";
|
||||
import type { DocumentRecord } from "@/lib/documents";
|
||||
import type { WorkspaceSummary } from "@/lib/workspaces";
|
||||
import type { MediaAsset } from "@/types/media";
|
||||
import type { KernelSidebarProjection, SidebarTreeNode } from "@/lib/kernel-sidebar";
|
||||
|
||||
export type SidebarSectionId = "starred" | "public" | "shared" | "private" | "templates";
|
||||
|
||||
@@ -16,6 +17,8 @@ export interface SidebarInitialData {
|
||||
activeWorkspaceId: string;
|
||||
workspaces: WorkspaceSummary[];
|
||||
documents: DocumentRecord[];
|
||||
kernelSidebarProjection?: KernelSidebarProjection | null;
|
||||
kernelSidebarTree?: SidebarTreeNode[];
|
||||
trashedDocuments: TrashRecord[];
|
||||
trashedMediaAssets?: MediaAsset[];
|
||||
trashedMindmapAssets?: MediaAsset[];
|
||||
|
||||
@@ -0,0 +1,387 @@
|
||||
export type PageSubtreeInlineNode = {
|
||||
type?: string;
|
||||
text?: string;
|
||||
href?: string;
|
||||
content?: unknown;
|
||||
styles?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type PageSubtreeBlock = {
|
||||
id?: string;
|
||||
type?: string;
|
||||
props?: Record<string, unknown>;
|
||||
content?: unknown;
|
||||
children?: unknown;
|
||||
};
|
||||
|
||||
export type PageSubtreeNodeType =
|
||||
| "page"
|
||||
| "section"
|
||||
| "content_node"
|
||||
| "reference_anchor"
|
||||
| "mindmap";
|
||||
|
||||
export type PageSubtreeNode = {
|
||||
id: string;
|
||||
parentNodeId: string | null;
|
||||
nodeType: PageSubtreeNodeType;
|
||||
blockId: string | null;
|
||||
anchorBlockId: string | null;
|
||||
depth: number;
|
||||
metadata: {
|
||||
title: string | null;
|
||||
textSnippet: string | null;
|
||||
blockType: string | null;
|
||||
headingLevel: number | null;
|
||||
numbering: string | null;
|
||||
childCount: number;
|
||||
order: number;
|
||||
path: string[];
|
||||
};
|
||||
};
|
||||
|
||||
export type PageOutlineEntry = {
|
||||
id: string;
|
||||
nodeId: string;
|
||||
anchorBlockId: string | null;
|
||||
title: string;
|
||||
level: number;
|
||||
numbering: string;
|
||||
};
|
||||
|
||||
export type PageEvidenceItem = {
|
||||
id: string;
|
||||
nodeId: string;
|
||||
blockId: string | null;
|
||||
kind:
|
||||
| "page"
|
||||
| "heading"
|
||||
| "paragraph"
|
||||
| "list"
|
||||
| "todo"
|
||||
| "quote"
|
||||
| "code"
|
||||
| "media"
|
||||
| "reference"
|
||||
| "table"
|
||||
| "mindmap"
|
||||
| "text";
|
||||
snippet: string;
|
||||
};
|
||||
|
||||
export type PageSubtreeProjection = {
|
||||
projectionId: string;
|
||||
projection: "page_tree";
|
||||
rootNodeId: string;
|
||||
rootNode: PageSubtreeNode;
|
||||
subtree: {
|
||||
rootNodeId: string;
|
||||
nodes: PageSubtreeNode[];
|
||||
};
|
||||
outline: PageOutlineEntry[];
|
||||
evidence: PageEvidenceItem[];
|
||||
stats: {
|
||||
blockCount: number;
|
||||
headingCount: number;
|
||||
evidenceCount: number;
|
||||
maxDepth: number;
|
||||
};
|
||||
};
|
||||
|
||||
const SNIPPET_MAX_LENGTH = 220;
|
||||
|
||||
const pickFirstText = (...values: unknown[]) => {
|
||||
for (const value of values) {
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
return value.trim();
|
||||
}
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
const normalizeSnippet = (value: string) => value.replace(/\s+/g, " ").trim().slice(0, SNIPPET_MAX_LENGTH);
|
||||
|
||||
export const clampHeadingLevel = (value: unknown) => {
|
||||
const level = Number(value);
|
||||
if (Number.isNaN(level) || !Number.isFinite(level)) {
|
||||
return 1;
|
||||
}
|
||||
return Math.min(5, Math.max(1, Math.trunc(level)));
|
||||
};
|
||||
|
||||
export const extractPageBlocks = (content: unknown): PageSubtreeBlock[] => {
|
||||
if (Array.isArray(content)) {
|
||||
return content as PageSubtreeBlock[];
|
||||
}
|
||||
if (content && typeof content === "object") {
|
||||
const blocks = (content as { blocks?: unknown }).blocks;
|
||||
if (Array.isArray(blocks)) {
|
||||
return blocks as PageSubtreeBlock[];
|
||||
}
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
export const getPageBlockChildren = (value: unknown): PageSubtreeBlock[] => {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
return value as PageSubtreeBlock[];
|
||||
};
|
||||
|
||||
export const getInlineText = (value: unknown): string => {
|
||||
if (typeof value === "string") {
|
||||
return value;
|
||||
}
|
||||
if (!Array.isArray(value)) {
|
||||
return "";
|
||||
}
|
||||
return value
|
||||
.map((node) => {
|
||||
if (typeof node === "string") {
|
||||
return node;
|
||||
}
|
||||
if (!node || typeof node !== "object") {
|
||||
return "";
|
||||
}
|
||||
const typedNode = node as PageSubtreeInlineNode;
|
||||
if (typedNode.type === "link") {
|
||||
return getInlineText(typedNode.content);
|
||||
}
|
||||
return typeof typedNode.text === "string" ? typedNode.text : "";
|
||||
})
|
||||
.join("");
|
||||
};
|
||||
|
||||
const getBlockSnippet = (block: PageSubtreeBlock): string => {
|
||||
const props = block.props ?? {};
|
||||
const inlineText = normalizeSnippet(getInlineText(block.content));
|
||||
if (inlineText) {
|
||||
return inlineText;
|
||||
}
|
||||
return normalizeSnippet(
|
||||
pickFirstText(
|
||||
props.title,
|
||||
props.caption,
|
||||
props.summary,
|
||||
props.fileName,
|
||||
props.name,
|
||||
props.alt,
|
||||
props.status,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const getBlockDisplayTitle = (block: PageSubtreeBlock, snippet: string) => {
|
||||
const props = block.props ?? {};
|
||||
switch (block.type) {
|
||||
case "heading":
|
||||
return snippet || "未命名标题";
|
||||
case "pageReference":
|
||||
return pickFirstText(props.title, snippet, "页面引用");
|
||||
case "blockReference":
|
||||
return pickFirstText(props.title, snippet, "块引用");
|
||||
case "onlineTable":
|
||||
return pickFirstText(props.title, snippet, "在线表格");
|
||||
case "mindmap":
|
||||
return pickFirstText(props.title, snippet, "思维导图");
|
||||
case "media":
|
||||
return pickFirstText(props.caption, props.fileName, snippet, "附件");
|
||||
case "codeBlock":
|
||||
return snippet || "代码块";
|
||||
case "advancedTodo":
|
||||
return snippet || "任务";
|
||||
case "quote":
|
||||
return snippet || "引用";
|
||||
default:
|
||||
return snippet || null;
|
||||
}
|
||||
};
|
||||
|
||||
const getNodeType = (block: PageSubtreeBlock): PageSubtreeNodeType => {
|
||||
switch (block.type) {
|
||||
case "heading":
|
||||
return "section";
|
||||
case "blockReference":
|
||||
case "pageReference":
|
||||
return "reference_anchor";
|
||||
case "mindmap":
|
||||
return "mindmap";
|
||||
default:
|
||||
return "content_node";
|
||||
}
|
||||
};
|
||||
|
||||
const getEvidenceKind = (block: PageSubtreeBlock): PageEvidenceItem["kind"] => {
|
||||
switch (block.type) {
|
||||
case "heading":
|
||||
return "heading";
|
||||
case "paragraph":
|
||||
return "paragraph";
|
||||
case "bulletListItem":
|
||||
case "numberedListItem":
|
||||
case "checkListItem":
|
||||
return "list";
|
||||
case "advancedTodo":
|
||||
return "todo";
|
||||
case "quote":
|
||||
return "quote";
|
||||
case "codeBlock":
|
||||
return "code";
|
||||
case "media":
|
||||
return "media";
|
||||
case "pageReference":
|
||||
case "blockReference":
|
||||
return "reference";
|
||||
case "onlineTable":
|
||||
return "table";
|
||||
case "mindmap":
|
||||
return "mindmap";
|
||||
default:
|
||||
return "text";
|
||||
}
|
||||
};
|
||||
|
||||
export function buildPageSubtreeProjection(input: {
|
||||
documentId: string;
|
||||
title: string | null;
|
||||
content: unknown;
|
||||
}): PageSubtreeProjection {
|
||||
const documentId = String(input.documentId ?? "").trim();
|
||||
const rootNodeId = documentId || "page:unknown";
|
||||
const blocks = extractPageBlocks(input.content);
|
||||
const rootTitle = pickFirstText(input.title, "无标题");
|
||||
const rootNode: PageSubtreeNode = {
|
||||
id: rootNodeId,
|
||||
parentNodeId: null,
|
||||
nodeType: "page",
|
||||
blockId: null,
|
||||
anchorBlockId: null,
|
||||
depth: 0,
|
||||
metadata: {
|
||||
title: rootTitle,
|
||||
textSnippet: null,
|
||||
blockType: "page",
|
||||
headingLevel: null,
|
||||
numbering: null,
|
||||
childCount: blocks.length,
|
||||
order: 0,
|
||||
path: [rootNodeId],
|
||||
},
|
||||
};
|
||||
|
||||
const nodes: PageSubtreeNode[] = [rootNode];
|
||||
const outline: PageOutlineEntry[] = [];
|
||||
const evidence: PageEvidenceItem[] = [];
|
||||
const headingCounters = [0, 0, 0, 0, 0];
|
||||
const headingStack: Array<{ level: number; nodeId: string }> = [];
|
||||
let order = 0;
|
||||
let maxDepth = 0;
|
||||
|
||||
const walk = (items: PageSubtreeBlock[], parentBlockNodeId: string | null, depth: number, path: number[]) => {
|
||||
items.forEach((block, index) => {
|
||||
const blockId = typeof block.id === "string" && block.id.trim() ? block.id.trim() : null;
|
||||
const nodeId = blockId ? `block:${blockId}` : `block:auto:${[...path, index].join(".")}`;
|
||||
const snippet = getBlockSnippet(block);
|
||||
const headingLevel = block.type === "heading" ? clampHeadingLevel(block.props?.level) : null;
|
||||
let parentNodeId = parentBlockNodeId ?? headingStack.at(-1)?.nodeId ?? rootNodeId;
|
||||
let numbering: string | null = null;
|
||||
|
||||
if (headingLevel != null) {
|
||||
while (headingStack.length > 0 && headingStack[headingStack.length - 1]!.level >= headingLevel) {
|
||||
headingStack.pop();
|
||||
}
|
||||
parentNodeId = headingStack.at(-1)?.nodeId ?? parentBlockNodeId ?? rootNodeId;
|
||||
headingCounters[headingLevel - 1] += 1;
|
||||
for (let counterIndex = headingLevel; counterIndex < headingCounters.length; counterIndex += 1) {
|
||||
headingCounters[counterIndex] = 0;
|
||||
}
|
||||
numbering = headingCounters
|
||||
.slice(0, headingLevel)
|
||||
.filter((value) => value > 0)
|
||||
.join(".");
|
||||
}
|
||||
|
||||
order += 1;
|
||||
const children = getPageBlockChildren(block.children);
|
||||
const node: PageSubtreeNode = {
|
||||
id: nodeId,
|
||||
parentNodeId,
|
||||
nodeType: getNodeType(block),
|
||||
blockId,
|
||||
anchorBlockId: blockId,
|
||||
depth: depth + 1,
|
||||
metadata: {
|
||||
title: getBlockDisplayTitle(block, snippet),
|
||||
textSnippet: snippet || null,
|
||||
blockType: typeof block.type === "string" ? block.type : null,
|
||||
headingLevel,
|
||||
numbering,
|
||||
childCount: children.length,
|
||||
order,
|
||||
path: [rootNodeId, ...path.map(String), String(index)],
|
||||
},
|
||||
};
|
||||
|
||||
nodes.push(node);
|
||||
maxDepth = Math.max(maxDepth, node.depth);
|
||||
|
||||
if (headingLevel != null) {
|
||||
outline.push({
|
||||
id: blockId ?? node.id,
|
||||
nodeId: node.id,
|
||||
anchorBlockId: blockId,
|
||||
title: node.metadata.title ?? "未命名标题",
|
||||
level: headingLevel,
|
||||
numbering: numbering ?? "",
|
||||
});
|
||||
headingStack.push({ level: headingLevel, nodeId: node.id });
|
||||
}
|
||||
|
||||
if (snippet) {
|
||||
evidence.push({
|
||||
id: `evidence:${node.id}`,
|
||||
nodeId: node.id,
|
||||
blockId,
|
||||
kind: getEvidenceKind(block),
|
||||
snippet,
|
||||
});
|
||||
}
|
||||
|
||||
if (children.length > 0) {
|
||||
walk(children, node.id, depth + 1, [...path, index]);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
walk(blocks, null, 0, []);
|
||||
|
||||
if (rootTitle) {
|
||||
evidence.unshift({
|
||||
id: `evidence:${rootNodeId}`,
|
||||
nodeId: rootNodeId,
|
||||
blockId: null,
|
||||
kind: "page",
|
||||
snippet: rootTitle,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
projectionId: `page_subtree:${rootNodeId}`,
|
||||
projection: "page_tree",
|
||||
rootNodeId,
|
||||
rootNode,
|
||||
subtree: {
|
||||
rootNodeId,
|
||||
nodes,
|
||||
},
|
||||
outline,
|
||||
evidence,
|
||||
stats: {
|
||||
blockCount: Math.max(0, nodes.length - 1),
|
||||
headingCount: outline.length,
|
||||
evidenceCount: evidence.length,
|
||||
maxDepth,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import type { DocumentNode } from "@/lib/documents";
|
||||
import type { SidebarTreeNode } from "@/lib/kernel-sidebar";
|
||||
import type { MediaAsset } from "@/types/media";
|
||||
import type { FileTreeRow } from "./types";
|
||||
import { makeAssetFolderRowId, makeAssetRowId, makeDocRowId, makeIndexRowId } from "./types";
|
||||
@@ -12,7 +12,7 @@ export function buildVisibleRows({
|
||||
assetChildrenByAssetId,
|
||||
expandedAssetFolderIds,
|
||||
}: {
|
||||
nodes: DocumentNode[];
|
||||
nodes: SidebarTreeNode[];
|
||||
expanded: Set<string>;
|
||||
assetsByDoc: Record<string, MediaAsset[]>;
|
||||
assetChildrenByAssetId?: Record<string, MediaAsset[]>;
|
||||
@@ -21,7 +21,7 @@ export function buildVisibleRows({
|
||||
const rows: FileTreeRow[] = [];
|
||||
const visitedDocIds = new Set<string>();
|
||||
|
||||
const walk = (node: DocumentNode, depth: number) => {
|
||||
const walk = (node: SidebarTreeNode, depth: number) => {
|
||||
// 防御性处理:上游数据异常时(例如同一 docId 在树中重复出现),避免生成重复 rowId 导致 React key 冲突。
|
||||
// 同时也能避免潜在的“循环引用/重复引用”导致的递归问题。
|
||||
if (visitedDocIds.has(node.id)) return;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import type { DocumentNode } from "@/lib/documents";
|
||||
import type { SidebarTreeNode } from "@/lib/kernel-sidebar";
|
||||
import type { MediaAsset } from "@/types/media";
|
||||
|
||||
export type FileTreeRowKind = "doc" | "index" | "asset" | "asset-folder";
|
||||
@@ -60,7 +60,7 @@ export type FileTreeRow =
|
||||
depth: number;
|
||||
docId: string;
|
||||
parentDocId: string | null;
|
||||
node: DocumentNode;
|
||||
node: SidebarTreeNode;
|
||||
hasChildren: boolean;
|
||||
isExpanded: boolean;
|
||||
}
|
||||
@@ -70,7 +70,7 @@ export type FileTreeRow =
|
||||
depth: number;
|
||||
docId: string;
|
||||
parentDocId: string;
|
||||
node: DocumentNode;
|
||||
node: SidebarTreeNode;
|
||||
}
|
||||
| {
|
||||
kind: "asset-folder";
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
import type { DocumentRecord } from "@/lib/documents";
|
||||
|
||||
export type KernelSidebarProjectionEdge = {
|
||||
id: string;
|
||||
edgeType: "parent_of";
|
||||
workspaceId: string | null;
|
||||
fromNodeId: string;
|
||||
toNodeId: string;
|
||||
};
|
||||
|
||||
export type KernelSidebarProjectionItem = {
|
||||
nodeId: string;
|
||||
parentNodeId: string | null;
|
||||
nodeType: "page";
|
||||
title: string | null;
|
||||
depth: number;
|
||||
position: number | null;
|
||||
childCount: number;
|
||||
expandedByDefault: boolean;
|
||||
};
|
||||
|
||||
export type KernelSidebarProjection = {
|
||||
projectionId: string;
|
||||
projection: "sidebar_tree";
|
||||
rootNodeId: string | null;
|
||||
items: KernelSidebarProjectionItem[];
|
||||
edges: KernelSidebarProjectionEdge[];
|
||||
};
|
||||
|
||||
export type SidebarTreeNode = DocumentRecord & {
|
||||
children: SidebarTreeNode[];
|
||||
kernel?: {
|
||||
nodeType: "page";
|
||||
depth: number;
|
||||
position: number | null;
|
||||
childCount: number;
|
||||
expandedByDefault: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
function sortRecords(records: DocumentRecord[]) {
|
||||
return [...records].sort((a, b) => {
|
||||
const orderA = a.sort_order ?? Number.MAX_SAFE_INTEGER;
|
||||
const orderB = b.sort_order ?? Number.MAX_SAFE_INTEGER;
|
||||
if (orderA !== orderB) {
|
||||
return orderA - orderB;
|
||||
}
|
||||
return new Date(a.created_at).getTime() - new Date(b.created_at).getTime();
|
||||
});
|
||||
}
|
||||
|
||||
function dedupeRecords(records: DocumentRecord[]) {
|
||||
const seen = new Set<string>();
|
||||
const unique: DocumentRecord[] = [];
|
||||
for (let index = records.length - 1; index >= 0; index -= 1) {
|
||||
const record = records[index]!;
|
||||
if (seen.has(record.id)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(record.id);
|
||||
unique.push(record);
|
||||
}
|
||||
unique.reverse();
|
||||
return unique;
|
||||
}
|
||||
|
||||
function buildChildrenByParent(records: DocumentRecord[]) {
|
||||
const childrenByParentId = new Map<string | null, DocumentRecord[]>();
|
||||
const recordIds = new Set(records.map((record) => record.id));
|
||||
for (const record of records) {
|
||||
const parentId =
|
||||
record.parent_id && recordIds.has(record.parent_id) ? record.parent_id : null;
|
||||
const bucket = childrenByParentId.get(parentId) ?? [];
|
||||
bucket.push(record);
|
||||
childrenByParentId.set(parentId, bucket);
|
||||
}
|
||||
for (const [parentId, bucket] of childrenByParentId.entries()) {
|
||||
childrenByParentId.set(parentId, sortRecords(bucket));
|
||||
}
|
||||
return childrenByParentId;
|
||||
}
|
||||
|
||||
export function buildKernelSidebarProjection(
|
||||
records: DocumentRecord[],
|
||||
): KernelSidebarProjection {
|
||||
const uniqueRecords = dedupeRecords(records);
|
||||
const recordById = new Map(uniqueRecords.map((record) => [record.id, record]));
|
||||
const childrenByParentId = buildChildrenByParent(uniqueRecords);
|
||||
const items: KernelSidebarProjectionItem[] = [];
|
||||
const edges: KernelSidebarProjectionEdge[] = [];
|
||||
const visited = new Set<string>();
|
||||
|
||||
const walk = (parentId: string | null, depth: number) => {
|
||||
const children = childrenByParentId.get(parentId) ?? [];
|
||||
for (const child of children) {
|
||||
if (visited.has(child.id)) {
|
||||
continue;
|
||||
}
|
||||
visited.add(child.id);
|
||||
const childNodes = childrenByParentId.get(child.id) ?? [];
|
||||
items.push({
|
||||
nodeId: child.id,
|
||||
parentNodeId: parentId,
|
||||
nodeType: "page",
|
||||
title: child.title ?? "无标题",
|
||||
depth,
|
||||
position: child.sort_order ?? null,
|
||||
childCount: childNodes.length,
|
||||
expandedByDefault: true,
|
||||
});
|
||||
if (parentId && recordById.has(parentId)) {
|
||||
edges.push({
|
||||
id: `edge:${parentId}:${child.id}:parent_of`,
|
||||
edgeType: "parent_of",
|
||||
workspaceId: child.workspace_id ?? null,
|
||||
fromNodeId: parentId,
|
||||
toNodeId: child.id,
|
||||
});
|
||||
}
|
||||
walk(child.id, depth + 1);
|
||||
}
|
||||
};
|
||||
|
||||
walk(null, 0);
|
||||
|
||||
return {
|
||||
projectionId: "kernel_projection:sidebar_tree:workspace_root",
|
||||
projection: "sidebar_tree",
|
||||
rootNodeId: null,
|
||||
items,
|
||||
edges,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildSidebarTreeFromKernelProjection(input: {
|
||||
records: DocumentRecord[];
|
||||
projection: KernelSidebarProjection;
|
||||
}): SidebarTreeNode[] {
|
||||
const recordById = new Map(input.records.map((record) => [record.id, record]));
|
||||
const nodeMap = new Map<string, SidebarTreeNode>();
|
||||
const itemById = new Map(input.projection.items.map((item) => [item.nodeId, item]));
|
||||
|
||||
for (const item of input.projection.items) {
|
||||
const record = recordById.get(item.nodeId);
|
||||
nodeMap.set(item.nodeId, {
|
||||
access_scope: record?.access_scope ?? "private",
|
||||
id: item.nodeId,
|
||||
workspace_id: record?.workspace_id ?? "",
|
||||
title: record?.title ?? item.title ?? "无标题",
|
||||
parent_id: record?.parent_id ?? item.parentNodeId,
|
||||
sort_order: record?.sort_order ?? item.position,
|
||||
is_starred: record?.is_starred ?? false,
|
||||
is_template: record?.is_template ?? false,
|
||||
created_at: record?.created_at ?? "",
|
||||
updated_at: record?.updated_at ?? null,
|
||||
children: [],
|
||||
kernel: {
|
||||
nodeType: item.nodeType,
|
||||
depth: item.depth,
|
||||
position: item.position,
|
||||
childCount: item.childCount,
|
||||
expandedByDefault: item.expandedByDefault,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const roots: SidebarTreeNode[] = [];
|
||||
for (const item of input.projection.items) {
|
||||
const node = nodeMap.get(item.nodeId);
|
||||
if (!node) continue;
|
||||
const parentId = item.parentNodeId;
|
||||
if (parentId && nodeMap.has(parentId)) {
|
||||
nodeMap.get(parentId)!.children.push(node);
|
||||
continue;
|
||||
}
|
||||
roots.push(node);
|
||||
}
|
||||
|
||||
const sortTree = (nodes: SidebarTreeNode[]) => {
|
||||
nodes.sort((a, b) => {
|
||||
const orderA = a.kernel?.position ?? a.sort_order ?? Number.MAX_SAFE_INTEGER;
|
||||
const orderB = b.kernel?.position ?? b.sort_order ?? Number.MAX_SAFE_INTEGER;
|
||||
if (orderA !== orderB) {
|
||||
return orderA - orderB;
|
||||
}
|
||||
return new Date(a.created_at).getTime() - new Date(b.created_at).getTime();
|
||||
});
|
||||
nodes.forEach((node) => sortTree(node.children));
|
||||
};
|
||||
|
||||
sortTree(roots);
|
||||
|
||||
// 防御性处理:如果 projection 丢了节点,但 records 里还在,补到根节点,避免页面从主导航消失。
|
||||
const missingRoots = input.records
|
||||
.filter((record) => !itemById.has(record.id))
|
||||
.map((record) => ({
|
||||
...record,
|
||||
children: [],
|
||||
kernel: {
|
||||
nodeType: "page" as const,
|
||||
depth: 0,
|
||||
position: record.sort_order ?? null,
|
||||
childCount: 0,
|
||||
expandedByDefault: true,
|
||||
},
|
||||
}));
|
||||
if (missingRoots.length > 0) {
|
||||
roots.push(...missingRoots);
|
||||
sortTree(roots);
|
||||
}
|
||||
|
||||
return roots;
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
export type MindMapData = {
|
||||
data: Record<string, unknown>;
|
||||
children?: unknown[];
|
||||
};
|
||||
|
||||
export type MindmapRouteMeta = {
|
||||
requestId?: string;
|
||||
traceId?: string;
|
||||
workspaceId?: string | null;
|
||||
documentId?: string;
|
||||
pageId?: string;
|
||||
mindmapId?: string;
|
||||
attachmentId?: string;
|
||||
updatedAt?: string | null;
|
||||
};
|
||||
|
||||
export type MindmapProjectionNode = {
|
||||
uid: string;
|
||||
text: string;
|
||||
depth: number;
|
||||
childCount: number;
|
||||
};
|
||||
|
||||
export type MindmapProjection = {
|
||||
projectionId: string;
|
||||
projection: "mindmap_subtree";
|
||||
documentId: string;
|
||||
mindmapId: string;
|
||||
rootNodeId: string | null;
|
||||
title: string;
|
||||
nodeCount: number;
|
||||
nodes: MindmapProjectionNode[];
|
||||
data: MindMapData;
|
||||
meta: MindmapRouteMeta | null;
|
||||
};
|
||||
|
||||
export const defaultMindmapData: MindMapData = {
|
||||
data: { text: "中心主题" },
|
||||
children: [],
|
||||
};
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
|
||||
// simple-mind-map 在文本字段缺失时会直接崩溃,这里统一做兜底归一化。
|
||||
export const normalizeMindmapData = (input: unknown): unknown => {
|
||||
if (!input || typeof input !== "object") return defaultMindmapData;
|
||||
const root = (input as { root?: unknown }).root ?? input;
|
||||
|
||||
const walk = (node: unknown) => {
|
||||
if (!isRecord(node)) return;
|
||||
if (!isRecord(node.data)) {
|
||||
node.data = {};
|
||||
}
|
||||
const rawText = node.data.text;
|
||||
node.data.text = typeof rawText === "string" ? rawText : String(rawText ?? "");
|
||||
|
||||
const gen = node.data.generalization;
|
||||
const fixGen = (value: unknown) => {
|
||||
if (!isRecord(value)) return;
|
||||
const text = value.text;
|
||||
value.text = typeof text === "string" ? text : String(text ?? "");
|
||||
};
|
||||
|
||||
if (Array.isArray(gen)) gen.forEach(fixGen);
|
||||
else fixGen(gen);
|
||||
|
||||
if (Array.isArray(node.children)) {
|
||||
node.children.forEach(walk);
|
||||
}
|
||||
};
|
||||
|
||||
walk(root);
|
||||
return input;
|
||||
};
|
||||
|
||||
// 持久化/初始化统一使用根节点对象,避免 wrapper 误传给 simple-mind-map。
|
||||
export const canonicalizeMindmapData = (input: unknown): MindMapData => {
|
||||
const normalized = (normalizeMindmapData(input) ?? defaultMindmapData) as Record<string, unknown>;
|
||||
const root =
|
||||
normalized && typeof normalized === "object" && "root" in normalized
|
||||
? normalized.root
|
||||
: normalized;
|
||||
return (normalizeMindmapData(root) ?? defaultMindmapData) as MindMapData;
|
||||
};
|
||||
|
||||
export const extractMindmapTitle = (input: unknown): string => {
|
||||
const canonical = canonicalizeMindmapData(input);
|
||||
const text = canonical?.data?.text;
|
||||
if (typeof text === "string" && text.trim()) {
|
||||
return text.trim();
|
||||
}
|
||||
return "未命名导图";
|
||||
};
|
||||
|
||||
export const summarizeMindmapProjectionNodes = (input: unknown): MindmapProjectionNode[] => {
|
||||
const root = canonicalizeMindmapData(input);
|
||||
const queue: Array<{ node: MindMapData; depth: number }> = [{ node: root, depth: 0 }];
|
||||
const nodes: MindmapProjectionNode[] = [];
|
||||
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift();
|
||||
if (!current) break;
|
||||
const uidRaw = current.node?.data?.uid;
|
||||
const textRaw = current.node?.data?.text;
|
||||
const children = Array.isArray(current.node?.children) ? current.node.children : [];
|
||||
nodes.push({
|
||||
uid:
|
||||
typeof uidRaw === "string" && uidRaw.trim()
|
||||
? uidRaw
|
||||
: `depth:${current.depth}:index:${nodes.length}`,
|
||||
text: typeof textRaw === "string" && textRaw.trim() ? textRaw.trim() : "未命名节点",
|
||||
depth: current.depth,
|
||||
childCount: children.length,
|
||||
});
|
||||
children.forEach((child) => {
|
||||
queue.push({
|
||||
node: canonicalizeMindmapData(child),
|
||||
depth: current.depth + 1,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return nodes;
|
||||
};
|
||||
|
||||
export const buildMindmapProjection = (input: {
|
||||
documentId: string;
|
||||
mindmapId: string;
|
||||
data: unknown;
|
||||
meta?: unknown;
|
||||
}): MindmapProjection => {
|
||||
const data = canonicalizeMindmapData(input.data);
|
||||
const nodes = summarizeMindmapProjectionNodes(data);
|
||||
const meta = isRecord(input.meta) ? (input.meta as MindmapRouteMeta) : null;
|
||||
const rootNodeId = nodes[0]?.uid ?? null;
|
||||
|
||||
return {
|
||||
projectionId: `mindmap_projection:${input.documentId}:${input.mindmapId}`,
|
||||
projection: "mindmap_subtree",
|
||||
documentId: input.documentId,
|
||||
mindmapId: input.mindmapId,
|
||||
rootNodeId,
|
||||
title: extractMindmapTitle(data),
|
||||
nodeCount: nodes.length,
|
||||
nodes,
|
||||
data,
|
||||
meta,
|
||||
};
|
||||
};
|
||||
@@ -44,6 +44,13 @@ type SearchDocumentsRustResult = {
|
||||
hasOcr: boolean;
|
||||
publicPath: string;
|
||||
score: number;
|
||||
nodeId?: string | null;
|
||||
subtreeRootId?: string | null;
|
||||
evidence?: Array<{
|
||||
kind: string;
|
||||
nodeId?: string | null;
|
||||
snippet: string;
|
||||
}>;
|
||||
}>;
|
||||
enqueueAssetIds?: string[];
|
||||
};
|
||||
@@ -145,6 +152,15 @@ function mapRecentRowsToResults(input: {
|
||||
hasOcr: false,
|
||||
publicPath: `/documents/${item.id}`,
|
||||
score: 0,
|
||||
nodeId: item.id,
|
||||
subtreeRootId: item.id,
|
||||
evidence: [
|
||||
{
|
||||
kind: "recent",
|
||||
nodeId: item.id,
|
||||
snippet: item.title ?? "最近访问页面",
|
||||
},
|
||||
],
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -161,6 +177,18 @@ function mapRustResultsToResponse(
|
||||
hasOcr: Boolean(item.hasOcr),
|
||||
publicPath: item.publicPath,
|
||||
score: item.score,
|
||||
nodeId: item.nodeId ?? item.id,
|
||||
subtreeRootId: item.subtreeRootId ?? item.id,
|
||||
evidence:
|
||||
Array.isArray(item.evidence) && item.evidence.length > 0
|
||||
? item.evidence
|
||||
: [
|
||||
{
|
||||
kind: item.matchField,
|
||||
nodeId: item.nodeId ?? item.id,
|
||||
snippet: item.snippet || item.title || "命中文档",
|
||||
},
|
||||
],
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -136,6 +136,8 @@ describe("buildSidebarInitialData", () => {
|
||||
});
|
||||
|
||||
expect(payload.activeWorkspaceId).toBe("ws_1");
|
||||
expect(payload.kernelSidebarProjection?.projection).toBe("sidebar_tree");
|
||||
expect(payload.kernelSidebarTree?.map((item) => item.id)).toEqual(["doc_1"]);
|
||||
expect(payload.mindmapDocs).toEqual(["doc_1"]);
|
||||
expect(payload.mindmapAssetChildren).toEqual({
|
||||
mind_1: ["img_a", "img_b"],
|
||||
@@ -210,6 +212,24 @@ describe("buildSidebarInitialData", () => {
|
||||
updated_at: null,
|
||||
},
|
||||
],
|
||||
kernel_sidebar_projection: {
|
||||
projectionId: "kernel_projection:sidebar_tree:workspace_root",
|
||||
projection: "sidebar_tree",
|
||||
rootNodeId: null,
|
||||
items: [
|
||||
{
|
||||
nodeId: "doc_1",
|
||||
parentNodeId: null,
|
||||
nodeType: "page",
|
||||
title: "页面 1",
|
||||
depth: 0,
|
||||
position: 1,
|
||||
childCount: 0,
|
||||
expandedByDefault: true,
|
||||
},
|
||||
],
|
||||
edges: [],
|
||||
},
|
||||
trashed_documents: [],
|
||||
media_assets: [],
|
||||
trashed_media_assets: [],
|
||||
@@ -247,6 +267,46 @@ describe("buildSidebarInitialData", () => {
|
||||
updated_at: null,
|
||||
},
|
||||
],
|
||||
kernelSidebarProjection: {
|
||||
projectionId: "kernel_projection:sidebar_tree:workspace_root",
|
||||
projection: "sidebar_tree",
|
||||
rootNodeId: null,
|
||||
items: [
|
||||
{
|
||||
nodeId: "doc_1",
|
||||
parentNodeId: null,
|
||||
nodeType: "page",
|
||||
title: "页面 1",
|
||||
depth: 0,
|
||||
position: 1,
|
||||
childCount: 0,
|
||||
expandedByDefault: true,
|
||||
},
|
||||
],
|
||||
edges: [],
|
||||
},
|
||||
kernelSidebarTree: [
|
||||
{
|
||||
access_scope: "private",
|
||||
id: "doc_1",
|
||||
workspace_id: "ws_1",
|
||||
title: "页面 1",
|
||||
parent_id: null,
|
||||
sort_order: 1,
|
||||
is_starred: false,
|
||||
is_template: false,
|
||||
created_at: "2026-04-14T00:00:00Z",
|
||||
updated_at: null,
|
||||
children: [],
|
||||
kernel: {
|
||||
nodeType: "page",
|
||||
depth: 0,
|
||||
position: 1,
|
||||
childCount: 0,
|
||||
expandedByDefault: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
trashedDocuments: [],
|
||||
trashedMediaAssets: [],
|
||||
trashedMindmapAssets: [],
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import type { SidebarInitialData } from "@/components/sidebar/types";
|
||||
import type { DocumentRecord } from "@/lib/documents";
|
||||
import {
|
||||
buildKernelSidebarProjection,
|
||||
buildSidebarTreeFromKernelProjection,
|
||||
type KernelSidebarProjection,
|
||||
} from "@/lib/kernel-sidebar";
|
||||
import type { WorkspaceSummary } from "@/lib/workspaces";
|
||||
import type { MediaAsset } from "@/types/media";
|
||||
|
||||
@@ -46,6 +51,7 @@ export type SidebarDatasetListQueryResult = {
|
||||
active_workspace_id: string;
|
||||
workspaces: WorkspaceSummary[];
|
||||
documents: DocumentRecord[];
|
||||
kernel_sidebar_projection?: KernelSidebarProjection | null;
|
||||
trashed_documents: SidebarInitialData["trashedDocuments"];
|
||||
media_assets: MediaAsset[];
|
||||
trashed_media_assets: MediaAsset[];
|
||||
@@ -213,11 +219,13 @@ export function buildSidebarDatasetListQueryResult(
|
||||
input: SidebarDatasetInput,
|
||||
): SidebarDatasetListQueryResult {
|
||||
const derived = deriveSidebarDataset(input);
|
||||
const kernelSidebarProjection = buildKernelSidebarProjection(input.documents);
|
||||
|
||||
return {
|
||||
active_workspace_id: input.activeWorkspaceId,
|
||||
workspaces: [...input.workspaces],
|
||||
documents: [...input.documents],
|
||||
kernel_sidebar_projection: kernelSidebarProjection,
|
||||
trashed_documents: [...input.trashedDocuments],
|
||||
media_assets: [...(input.mediaAssets ?? [])],
|
||||
trashed_media_assets: [...(input.trashedMediaAssets ?? [])],
|
||||
@@ -233,10 +241,17 @@ export function buildSidebarDatasetListQueryResult(
|
||||
export function mapSidebarDatasetListQueryResultToInitialData(
|
||||
result: SidebarDatasetListQueryResult,
|
||||
): SidebarInitialData {
|
||||
const kernelSidebarProjection =
|
||||
result.kernel_sidebar_projection ?? buildKernelSidebarProjection(result.documents);
|
||||
return {
|
||||
activeWorkspaceId: result.active_workspace_id,
|
||||
workspaces: [...result.workspaces],
|
||||
documents: [...result.documents],
|
||||
kernelSidebarProjection,
|
||||
kernelSidebarTree: buildSidebarTreeFromKernelProjection({
|
||||
records: result.documents,
|
||||
projection: kernelSidebarProjection,
|
||||
}),
|
||||
trashedDocuments: [...result.trashed_documents],
|
||||
trashedMediaAssets: [...result.trashed_media_assets],
|
||||
trashedMindmapAssets: [...result.trashed_mindmap_assets],
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import type { DocumentRecord, DocumentNode } from "@/lib/documents";
|
||||
import type { DocumentRecord } from "@/lib/documents";
|
||||
import type { SidebarSectionId, TrashRecord } from "@/components/sidebar/types";
|
||||
import type { Database } from "@/types/supabase";
|
||||
import type { MediaAsset } from "@/types/media";
|
||||
import { buildDocumentTree } from "@/lib/documents";
|
||||
import {
|
||||
buildKernelSidebarProjection,
|
||||
buildSidebarTreeFromKernelProjection,
|
||||
type SidebarTreeNode,
|
||||
} from "@/lib/kernel-sidebar";
|
||||
|
||||
type TypedClient = {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -138,10 +142,10 @@ export async function fetchSidebarDataset(
|
||||
};
|
||||
}
|
||||
|
||||
type NodePredicate = (node: DocumentNode) => boolean;
|
||||
type NodePredicate = (node: SidebarTreeNode) => boolean;
|
||||
|
||||
function projectTree(nodes: DocumentNode[], predicate: NodePredicate): DocumentNode[] {
|
||||
const result: DocumentNode[] = [];
|
||||
function projectTree(nodes: SidebarTreeNode[], predicate: NodePredicate): SidebarTreeNode[] {
|
||||
const result: SidebarTreeNode[] = [];
|
||||
nodes.forEach((node) => {
|
||||
const projectedChildren = projectTree(node.children, predicate);
|
||||
if (predicate(node)) {
|
||||
@@ -157,12 +161,12 @@ function projectTree(nodes: DocumentNode[], predicate: NodePredicate): DocumentN
|
||||
}
|
||||
|
||||
export function flattenDocumentTree(
|
||||
nodes: DocumentNode[],
|
||||
nodes: SidebarTreeNode[],
|
||||
expanded: Set<string>,
|
||||
depth = 0,
|
||||
parentId: string | null = null,
|
||||
): Array<{ node: DocumentNode; depth: number; parentId: string | null }> {
|
||||
const list: Array<{ node: DocumentNode; depth: number; parentId: string | null }> = [];
|
||||
): Array<{ node: SidebarTreeNode; depth: number; parentId: string | null }> {
|
||||
const list: Array<{ node: SidebarTreeNode; depth: number; parentId: string | null }> = [];
|
||||
nodes.forEach((node) => {
|
||||
list.push({ node, depth, parentId });
|
||||
if (node.children.length > 0 && expanded.has(node.id)) {
|
||||
@@ -184,16 +188,19 @@ type SidebarSectionSnapshot = {
|
||||
id: SidebarSectionId;
|
||||
title: string;
|
||||
icon?: string;
|
||||
nodes: DocumentNode[];
|
||||
nodes: SidebarTreeNode[];
|
||||
};
|
||||
|
||||
export function buildSidebarSections(records: DocumentRecord[]): SidebarSectionSnapshot[] {
|
||||
const tree = buildDocumentTree(records);
|
||||
const tree = buildSidebarTreeFromKernelProjection({
|
||||
records,
|
||||
projection: buildKernelSidebarProjection(records),
|
||||
});
|
||||
return buildSidebarSectionsFromTree(tree);
|
||||
}
|
||||
|
||||
export function buildSidebarSectionsFromTree(
|
||||
tree: DocumentNode[],
|
||||
tree: SidebarTreeNode[],
|
||||
): SidebarSectionSnapshot[] {
|
||||
const sections: Array<{ id: SidebarSectionId; predicate: NodePredicate }> = [
|
||||
{ id: "starred", predicate: (node) => Boolean(node.is_starred) },
|
||||
|
||||
@@ -6,7 +6,9 @@ describe("useAiAgentUiStore", () => {
|
||||
useAiAgentUiStore.setState({
|
||||
documentAgentAvailable: false,
|
||||
documentAgentOpen: false,
|
||||
documentAgentActivated: false,
|
||||
globalAgentOpen: false,
|
||||
globalAgentActivated: false,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,8 +17,10 @@ describe("useAiAgentUiStore", () => {
|
||||
|
||||
state.toggleGlobalAgentOpen();
|
||||
expect(useAiAgentUiStore.getState().globalAgentOpen).toBe(true);
|
||||
expect(useAiAgentUiStore.getState().globalAgentActivated).toBe(true);
|
||||
|
||||
state.toggleGlobalAgentOpen();
|
||||
expect(useAiAgentUiStore.getState().globalAgentOpen).toBe(false);
|
||||
expect(useAiAgentUiStore.getState().globalAgentActivated).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,8 +4,10 @@ import { create } from "zustand";
|
||||
|
||||
type AiAgentUiState = {
|
||||
globalAgentOpen: boolean;
|
||||
globalAgentActivated: boolean;
|
||||
documentAgentAvailable: boolean;
|
||||
documentAgentOpen: boolean;
|
||||
documentAgentActivated: boolean;
|
||||
setGlobalAgentOpen: (open: boolean) => void;
|
||||
toggleGlobalAgentOpen: () => void;
|
||||
setDocumentAgentAvailable: (available: boolean) => void;
|
||||
@@ -15,18 +17,34 @@ type AiAgentUiState = {
|
||||
|
||||
export const useAiAgentUiStore = create<AiAgentUiState>((set, get) => ({
|
||||
globalAgentOpen: false,
|
||||
globalAgentActivated: false,
|
||||
documentAgentAvailable: false,
|
||||
documentAgentOpen: false,
|
||||
setGlobalAgentOpen: (open) => set({ globalAgentOpen: open }),
|
||||
documentAgentActivated: false,
|
||||
setGlobalAgentOpen: (open) =>
|
||||
set((state) => ({
|
||||
globalAgentOpen: open,
|
||||
globalAgentActivated: state.globalAgentActivated || open,
|
||||
})),
|
||||
toggleGlobalAgentOpen: () => {
|
||||
const s = get();
|
||||
set({ globalAgentOpen: !s.globalAgentOpen });
|
||||
set({
|
||||
globalAgentOpen: !s.globalAgentOpen,
|
||||
globalAgentActivated: s.globalAgentActivated || !s.globalAgentOpen,
|
||||
});
|
||||
},
|
||||
setDocumentAgentAvailable: (available) => set({ documentAgentAvailable: available }),
|
||||
setDocumentAgentOpen: (open) => set({ documentAgentOpen: open }),
|
||||
setDocumentAgentOpen: (open) =>
|
||||
set((state) => ({
|
||||
documentAgentOpen: open,
|
||||
documentAgentActivated: state.documentAgentActivated || open,
|
||||
})),
|
||||
toggleDocumentAgentOpen: () => {
|
||||
const s = get();
|
||||
if (!s.documentAgentAvailable) return;
|
||||
set({ documentAgentOpen: !s.documentAgentOpen });
|
||||
set({
|
||||
documentAgentOpen: !s.documentAgentOpen,
|
||||
documentAgentActivated: s.documentAgentActivated || !s.documentAgentOpen,
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
"use client";
|
||||
|
||||
import { create } from "zustand";
|
||||
|
||||
type OnlyOfficeAiBridgeState = {
|
||||
pluginReady: boolean;
|
||||
targetOrigin: string;
|
||||
targetWindow: Window | null;
|
||||
lastReadyAt: number | null;
|
||||
captureReady: (payload: { targetOrigin: string; targetWindow: Window | null }) => void;
|
||||
reset: () => void;
|
||||
};
|
||||
|
||||
export const useOnlyOfficeAiBridgeStore = create<OnlyOfficeAiBridgeState>((set) => ({
|
||||
pluginReady: false,
|
||||
targetOrigin: "*",
|
||||
targetWindow: null,
|
||||
lastReadyAt: null,
|
||||
captureReady: ({ targetOrigin, targetWindow }) =>
|
||||
set({
|
||||
pluginReady: Boolean(targetWindow),
|
||||
targetOrigin: targetOrigin || "*",
|
||||
targetWindow,
|
||||
lastReadyAt: Date.now(),
|
||||
}),
|
||||
reset: () =>
|
||||
set({
|
||||
pluginReady: false,
|
||||
targetOrigin: "*",
|
||||
targetWindow: null,
|
||||
lastReadyAt: null,
|
||||
}),
|
||||
}));
|
||||
@@ -14,6 +14,7 @@ export type FilterKey = "titleOnly" | "exact" | "onlyCurrentPage" | "includeOcr"
|
||||
|
||||
interface SearchPaletteState {
|
||||
open: boolean;
|
||||
activated: boolean;
|
||||
mode: SearchPaletteMode;
|
||||
query: string;
|
||||
filters: Omit<DocumentSearchFilters, "timeRange">;
|
||||
@@ -47,6 +48,7 @@ const defaultFilters: Omit<DocumentSearchFilters, "timeRange"> = {
|
||||
|
||||
export const useSearchPaletteStore = create<SearchPaletteState>((set) => ({
|
||||
open: false,
|
||||
activated: false,
|
||||
mode: "search",
|
||||
query: "",
|
||||
filters: { ...defaultFilters },
|
||||
@@ -58,12 +60,14 @@ export const useSearchPaletteStore = create<SearchPaletteState>((set) => ({
|
||||
openSearch: () =>
|
||||
set((state) => ({
|
||||
open: true,
|
||||
activated: true,
|
||||
mode: "search",
|
||||
query: state.query,
|
||||
})),
|
||||
openReference: (options) =>
|
||||
set({
|
||||
open: true,
|
||||
activated: true,
|
||||
mode: "reference",
|
||||
referenceMode: options?.referenceMode ?? "inline",
|
||||
alias: options?.aliasSeed ?? "",
|
||||
|
||||
@@ -34,6 +34,13 @@ export interface DocumentSearchResult {
|
||||
hasOcr: boolean;
|
||||
publicPath: string;
|
||||
score: number;
|
||||
nodeId?: string | null;
|
||||
subtreeRootId?: string | null;
|
||||
evidence?: Array<{
|
||||
kind: string;
|
||||
nodeId?: string | null;
|
||||
snippet: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface DocumentSearchResponse {
|
||||
|
||||
Reference in New Issue
Block a user