feat(kernel): complete tree-first graph tasks 074-080

This commit is contained in:
lix-2026
2026-04-16 22:01:51 +08:00
parent 2ff10fa86c
commit b1d5d97142
65 changed files with 11579 additions and 4606 deletions
+8 -1
View File
@@ -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;
+424
View File
@@ -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,
},
};
}
+3 -3
View File
@@ -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;
+3 -3
View File
@@ -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";
+213
View File
@@ -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: [],
+15
View File
@@ -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],
+18 -11
View File
@@ -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);
});
});
+22 -4
View File
@@ -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 ?? "",
+7
View File
@@ -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 {