import { beforeEach, describe, expect, it, vi } from "vitest";

const { mockBuildForwardHeaders } = vi.hoisted(() => ({
  mockBuildForwardHeaders: vi.fn(async () => new Headers({ "x-forwarded-for": "127.0.0.1" })),
}));

vi.mock("@/lib/runtime-config", () => ({
  getMnoteRuntimeConfig: () => ({ backendUrl: "http://backend.test" }),
}));

vi.mock("@/lib/server/forward-headers", () => ({
  buildForwardHeaders: mockBuildForwardHeaders,
}));

import { startDocumentAiOrchestratorRun } from "./document-ai-orchestrator";

describe("startDocumentAiOrchestratorRun", () => {
  beforeEach(() => {
    vi.restoreAllMocks();
    mockBuildForwardHeaders.mockClear();
    delete process.env.BACKEND_URL;
    delete process.env.BACKEND_INTERNAL_URL;
    delete process.env.MNOTE_AI_ORCHESTRATOR_API_KEY;
  });

  it("应把 leptos-tiptap 的 block 级 AI 上下文透传给 sidecar", async () => {
    const fetchMock = vi.fn(async () =>
      new Response('event: ready\ndata: {"ok":true}\n\n', {
        headers: { "Content-Type": "text/event-stream; charset=utf-8" },
      }),
    );
    vi.stubGlobal("fetch", fetchMock);

    await startDocumentAiOrchestratorRun({
      userId: "user-1",
      payload: {
        maxSteps: 8,
        messages: [{ role: "user", content: "改写当前块" }],
        context: {
          source: "leptos-tiptap-island",
          action: "ask_ai",
          documentId: "doc-1",
          workspaceId: "ws-1",
          selectedBlockId: "block-1",
          selectedUids: ["block-1"],
          selectedText: "旧段落",
          selection: { currentBlockId: "block-1", state: { from: 1, to: 4 } },
          tiptapDocument: { type: "doc", content: [{ type: "paragraph" }] },
          documentBlocks: { type: "doc", content: [{ type: "paragraph" }] },
        },
        options: {
          ai: {
            modelKey: "gpt-5.3-codex",
            profileId: "page_writer_polish",
            sessionId: "doc_ai:doc-1:session-1",
          },
        },
      },
    });


documentBlocks: { type: "doc", content: [{ type: "paragraph" }] },
        },
        options: {
          ai: {
            modelKey: "gpt-5.3-codex",
            profileId: "page_writer_polish",
            sessionId: "doc_ai:doc-1:session-1",
          },
        },
      },
    });

    expect(fetchMock).toHaveBeenCalledWith(
      "http://backend.test/api/v1/ai-agent/document/run",
      expect.objectContaining({ method: "POST" }),
    );
    const body = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body));
    expect(body.context).toMatchObject({
      source: "leptos-tiptap-island",
      action: "ask_ai",
      documentId: "doc-1",
      workspaceId: "ws-1",
      selectedBlockId: "block-1",
      selectedUids: ["block-1"],
      selectedText: "旧段落",
      selection: { currentBlockId: "block-1", state: { from: 1, to: 4 } },
      tiptapDocument: { type: "doc", content: [{ type: "paragraph" }] },
      documentBlocks: { type: "doc", content: [{ type: "paragraph" }] },
    });
  });
});


import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
import { buildForwardHeaders } from "@/lib/server/forward-headers";
import { pickLeptosTiptapRuntimePageOptions } from "@/lib/documents/page-option-semantics";
import type { DocumentAiCapabilityConfig } from "@/lib/ai-agent/document-config";
import type { PageOptionsState } from "@/types/page-options";

type AgentMessage = { role: "user" | "assistant"; content: string };

type RequestPayload = {
  maxSteps?: number;
  messages: AgentMessage[];
  context?: {
    source?: string;
    action?: string;
    documentId?: string;
    workspaceId?: string;
    selectedBlockId?: string;
    selectedBlockIndex?: number;
    selectedUids?: string[];
    selectedText?: string;
    selection?: unknown;
    tiptapDocument?: unknown;
    documentBlocks?: unknown;
    pageOptions?: PageOptionsState;
    node?: unknown;
    subtree?: unknown;
    outline?: unknown;
    evidence?: unknown;
  };
  options?: {
    ai?: {
      model?: string;
      modelKey?: string;
      profileId?: string;
      sessionId?: string;
    };
  };
};

function resolveBackendUrl(): string | null {
  const cfg = getMnoteRuntimeConfig();
  const backendUrl =
    process.env.BACKEND_INTERNAL_URL || process.env.BACKEND_URL || cfg.backendUrl;
  return backendUrl?.trim().replace(/\/+$/, "") || null;
}

function buildErrorMessage(payload: unknown, fallback: string): string {
  if (payload && typeof payload === "object" && "detail" in payload) {
    return String((payload as Record<string, unknown>).detail ?? fallback);
  }
  if (payload && typeof payload === "object" && "message" in payload) {
    return String((payload as Record<string, unknown>).message ?? fallback);
  }
  if (payload && typeof payload === "object" && "error" in payload) {
    return String((payload as Record<string, unknown>).error ?? fallback);
  }
  return fallback;
}

export async function startDocumentAiOrchestratorRun(input: {


return String((payload as Record<string, unknown>).detail ?? fallback);
  }
  if (payload && typeof payload === "object" && "message" in payload) {
    return String((payload as Record<string, unknown>).message ?? fallback);
  }
  if (payload && typeof payload === "object" && "error" in payload) {
    return String((payload as Record<string, unknown>).error ?? fallback);
  }
  return fallback;
}

export async function startDocumentAiOrchestratorRun(input: {
  request?: Request;
  userId: string;
  payload: RequestPayload;
}): Promise<Response> {
  const backendUrl = resolveBackendUrl();
  if (!backendUrl) {
    throw new Error("未配置 BACKEND_URL");
  }

  const headers = await buildForwardHeaders(input.request);
  headers.set("Content-Type", "application/json");
  const apiKey = (process.env.MNOTE_AI_ORCHESTRATOR_API_KEY || "").trim();
  if (apiKey) {
    headers.set("x-mnote-ai-key", apiKey);
  }

  const response = await fetch(`${backendUrl}/api/v1/ai-agent/document/run`, {
    method: "POST",
    headers,
    body: JSON.stringify({
      userId: input.userId,
      sessionId: String(input.payload.options?.ai?.sessionId ?? "").trim() || null,
      model: String(input.payload.options?.ai?.model ?? "").trim() || null,
      modelKey: String(input.payload.options?.ai?.modelKey ?? "").trim() || null,
      profileId: String(input.payload.options?.ai?.profileId ?? "").trim() || null,
      maxSteps: input.payload.maxSteps,
      messages: input.payload.messages,
      context: {
        source: input.payload.context?.source ?? null,
        action: input.payload.context?.action ?? null,
        documentId: input.payload.context?.documentId ?? null,
        workspaceId: input.payload.context?.workspaceId ?? null,
        selectedBlockId: input.payload.context?.selectedBlockId ?? null,
        selectedBlockIndex: input.payload.context?.selectedBlockIndex ?? null,
        selectedUids: input.payload.context?.selectedUids ?? null,
        selectedText: input.payload.context?.selectedText ?? null,
        selection: input.payload.context?.selection ?? null,
        tiptapDocument: input.payload.context?.tiptapDocument ?? null,
        documentBlocks: input.payload.context?.documentBlocks ?? null,
        node: input.payload.context?.node ?? null,
        subtree: input.payload.context?.subtree ?? null,
        outline: input.payload.context?.outline ?? null,
        evidence: input.payload.context?.evidence ?? null,
        pageOptions: input.payload.context?.pageOptions ?? null,
        editorRuntimePageOptions: input.payload.context?.pageOptions
          ? pickLeptosTiptapRuntimePageOptions(input.payload.context.pageOptions)
          : null,
      },


selection: input.payload.context?.selection ?? null,
        tiptapDocument: input.payload.context?.tiptapDocument ?? null,
        documentBlocks: input.payload.context?.documentBlocks ?? null,
        node: input.payload.context?.node ?? null,
        subtree: input.payload.context?.subtree ?? null,
        outline: input.payload.context?.outline ?? null,
        evidence: input.payload.context?.evidence ?? null,
        pageOptions: input.payload.context?.pageOptions ?? null,
        editorRuntimePageOptions: input.payload.context?.pageOptions
          ? pickLeptosTiptapRuntimePageOptions(input.payload.context.pageOptions)
          : null,
      },
    }),
    cache: "no-store",
  });

  if (!response.ok) {
    const payload = await response.json().catch(() => null);
    throw new Error(buildErrorMessage(payload, `AI orchestrator 请求失败：HTTP ${response.status}`));
  }

  if (!response.body) {
    throw new Error("AI orchestrator 未返回事件流");
  }

  return response;
}

export async function fetchDocumentAiOrchestratorConfig(input?: {
  request?: Request;
}): Promise<DocumentAiCapabilityConfig> {
  const backendUrl = resolveBackendUrl();
  if (!backendUrl) {
    throw new Error("未配置 BACKEND_URL");
  }

  const headers = await buildForwardHeaders(input?.request);
  const apiKey = (process.env.MNOTE_AI_ORCHESTRATOR_API_KEY || "").trim();
  if (apiKey) {
    headers.set("x-mnote-ai-key", apiKey);
  }

  const response = await fetch(`${backendUrl}/api/v1/ai-agent/document/config`, {
    method: "GET",
    headers,
    cache: "no-store",
  });

  if (!response.ok) {
    const payload = await response.json().catch(() => null);
    throw new Error(buildErrorMessage(payload, `AI orchestrator config 请求失败：HTTP ${response.status}`));
  }

  return (await response.json()) as DocumentAiCapabilityConfig;
}


import { headers } from "next/headers";
import { convexAuthNextjsToken } from "@convex-dev/auth/nextjs/server";
import { isDevAuthEnabled } from "@/lib/auth/devUser";
import { getAuthContext } from "@/lib/auth/authContext";

function copyHeaderIfPresent(target: Headers, source: Headers, name: string) {
  const value = source.get(name);
  if (value) {
    target.set(name, value);
  }
}

export async function buildForwardHeaders(request?: Request): Promise<Headers> {
  const source = request?.headers ?? new Headers(await headers());
  const forwarded = new Headers();

  copyHeaderIfPresent(forwarded, source, "cookie");
  copyHeaderIfPresent(forwarded, source, "authorization");
  copyHeaderIfPresent(forwarded, source, "x-request-id");
  copyHeaderIfPresent(forwarded, source, "x-trace-id");
  copyHeaderIfPresent(forwarded, source, "x-session-id");
  copyHeaderIfPresent(forwarded, source, "x-mnote-workspace-id");
  copyHeaderIfPresent(forwarded, source, "x-mnote-source-channel");
  copyHeaderIfPresent(forwarded, source, "x-mnote-source-client");
  copyHeaderIfPresent(forwarded, source, "x-mnote-actor-id");
  copyHeaderIfPresent(forwarded, source, "x-mnote-actor-type");
  copyHeaderIfPresent(forwarded, source, "user-agent");

  if (!forwarded.has("authorization") && !isDevAuthEnabled()) {
    const token = await convexAuthNextjsToken();
    if (token?.trim()) {
      forwarded.set("authorization", `Bearer ${token.trim()}`);
    }
  }

  if (!forwarded.has("x-mnote-source-channel")) {
    forwarded.set("x-mnote-source-channel", request ? "next_route" : "next_server_component");
  }
  if (!forwarded.has("x-mnote-source-client")) {
    forwarded.set("x-mnote-source-client", "wolai-frontend");
  }
  if (!forwarded.has("x-mnote-actor-id")) {
    try {
      const auth = await getAuthContext();
      if (auth.userId?.trim()) {
        forwarded.set("x-mnote-actor-id", auth.userId.trim());
        forwarded.set("x-mnote-actor-type", "user");
      }
    } catch {
      // 说明：未登录或当前上下文无法解析用户时，继续走已有 header / admin fallback。
    }
  }

  return forwarded;
}


import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ConvexHttpClient } from "convex/browser";
import { resolveKernelFileTreeProjection } from "./kernel-file-tree";

const mockBuildDocumentBridgeContextWithActor = vi.fn();
const mockBuildDocumentQueryEnvelope = vi.fn();
const mockExecuteRustBridgeQuery = vi.fn();

vi.mock("@/lib/documents/bridge", () => ({
  buildDocumentBridgeContextWithActor: (...args: unknown[]) =>
    mockBuildDocumentBridgeContextWithActor(...args),
  buildDocumentQueryEnvelope: (...args: unknown[]) => mockBuildDocumentQueryEnvelope(...args),
}));

vi.mock("@/lib/documents/rust-runtime", () => ({
  executeRustBridgeQuery: (...args: unknown[]) => mockExecuteRustBridgeQuery(...args),
}));

describe("resolveKernelFileTreeProjection", () => {
  beforeEach(() => {
    mockBuildDocumentBridgeContextWithActor.mockReset().mockReturnValue({
      requestId: "req_1",
      traceId: "trace_1",
    });
    mockBuildDocumentQueryEnvelope.mockReset().mockImplementation((input) => input);
    mockExecuteRustBridgeQuery.mockReset().mockResolvedValue({
      projectionId: "kernel_projection:file_tree:page_root",
      projection: "file_tree",
      rootNodeId: "page_root",
      items: [],
      edges: [],
    });
  });

  it("把搜索词传入 Rust kernel.project_view，而不是交给宿主裁剪", async () => {
    await resolveKernelFileTreeProjection({
      client: { query: vi.fn() } as unknown as ConvexHttpClient,
      request: new Request("http://127.0.0.1:3000/api/tree/projections/file"),
      workspaceId: "ws_1",
      actor: {
        actorType: "user",
        actorId: "user_1",
        sessionId: null,
      },
      dataset: {
        active_workspace_id: "ws_1",
        documents: [],
      },
      rootNodeId: "page_root",
      depth: 2,
      query: "  预算  ",
      maxResults: 12,
    });

    expect(mockBuildDocumentQueryEnvelope).toHaveBeenCalledWith({
      name: "kernel.project_view",
      payload: expect.objectContaining({
        projection: "file_tree",
        workspaceId: "ws_1",
        rootNodeId: "page_root",


rootNodeId: "page_root",
      depth: 2,
      query: "  预算  ",
      maxResults: 12,
    });

    expect(mockBuildDocumentQueryEnvelope).toHaveBeenCalledWith({
      name: "kernel.project_view",
      payload: expect.objectContaining({
        projection: "file_tree",
        workspaceId: "ws_1",
        rootNodeId: "page_root",
        depth: 2,
        query: "预算",
        maxResults: 12,
      }),
    });
    expect(mockExecuteRustBridgeQuery).toHaveBeenCalledTimes(1);
  });
});


import type { ConvexHttpClient } from "convex/browser";
import type { SidebarDatasetListQueryResult } from "@/lib/sidebar-data";
import type { KernelFileTreeProjection } from "@/lib/kernel-file-tree";
import {
  buildDocumentBridgeContextWithActor,
  buildDocumentQueryEnvelope,
  type BridgeActor,
} from "@/lib/documents/bridge";
import { executeRustBridgeQuery } from "@/lib/documents/rust-runtime";

export async function resolveKernelFileTreeProjection(input: {
  client: ConvexHttpClient;
  request: Request;
  workspaceId: string;
  actor: BridgeActor;
  dataset: SidebarDatasetListQueryResult;
  rootNodeId?: string | null;
  depth?: number | null;
  query?: string | null;
  maxResults?: number | null;
}): Promise<KernelFileTreeProjection> {
  const context = buildDocumentBridgeContextWithActor({
    request: input.request,
    actor: input.actor,
    workspaceId: input.workspaceId,
  });
  const query = input.query?.trim() || null;
  const maxResults =
    typeof input.maxResults === "number" && Number.isFinite(input.maxResults)
      ? Math.max(1, Math.floor(input.maxResults))
      : null;

  return executeRustBridgeQuery<KernelFileTreeProjection>({
    context,
    envelope: buildDocumentQueryEnvelope({
      name: "kernel.project_view",
      payload: {
        projection: "file_tree",
        workspaceId: input.workspaceId,
        rootNodeId: input.rootNodeId ?? null,
        depth: input.depth ?? null,
        query,
        maxResults,
        includeEdges: true,
        includeContent: false,
        nodeTypes: ["page"],
      },
    }),
    data: input.dataset as unknown as Record<string, unknown>,
  });
}

export function attachKernelFileTreeProjection<T extends SidebarDatasetListQueryResult>(input: {
  dataset: T;
  projection: KernelFileTreeProjection;
}): T {
  return {
    ...input.dataset,
    kernel_file_tree_projection: input.projection,
  };


data: input.dataset as unknown as Record<string, unknown>,
  });
}

export function attachKernelFileTreeProjection<T extends SidebarDatasetListQueryResult>(input: {
  dataset: T;
  projection: KernelFileTreeProjection;
}): T {
  return {
    ...input.dataset,
    kernel_file_tree_projection: input.projection,
  };
}


import "server-only";

import path from "path";

/**
 * 本地文件路径策略（服务端专用）
 *
 * 目标：
 * - 开发/传统模式：继续使用 `process.cwd()/public/*`（便于本地调试与静态访问）。
 * - 桌面端（Electron）：通过注入 `MNOTE_DATA_DIR=<安装目录>\\data`，把可变数据
 *   放到安装目录下（而不是 resources/app.asar），避免权限与覆盖问题。
 */

function cleanEnvValue(value: unknown): string | null {
  if (typeof value !== "string") return null;
  const v = value.trim();
  return v.length > 0 ? v : null;
}

export function getDocumentsBaseDir(): string {
  const dataDir = cleanEnvValue(process.env.MNOTE_DATA_DIR);
  if (dataDir) return path.join(dataDir, "documents");
  return path.join(process.cwd(), "public", "documents");
}

export function getLegacyMindmapsBaseDir(): string {
  const dataDir = cleanEnvValue(process.env.MNOTE_DATA_DIR);
  if (dataDir) return path.join(dataDir, "mindmaps");
  return path.join(process.cwd(), "public", "mindmaps");
}


import "server-only";

import path from "path";
import { promises as fs } from "fs";
import type { MediaAsset } from "@/types/media";
import { getDocumentsBaseDir, getLegacyMindmapsBaseDir } from "@/lib/server/local-paths";

const tryAccess = async (file: string) => {
  try {
    await fs.access(file);
    return true;
  } catch {
    return false;
  }
};

export async function detectLocalMindmapDocs(docIds: string[]): Promise<string[]> {
  const results: string[] = [];
  const preferredBaseDir = getDocumentsBaseDir();
  const legacyBaseDir = getLegacyMindmapsBaseDir();
  for (const id of docIds) {
    const folder = path.join(preferredBaseDir, id);
    const preferredLegacy = path.join(folder, "mindmap.json");
    const legacy = path.join(legacyBaseDir, id, "mindmap.json");
    let has = false;
    if ((await tryAccess(preferredLegacy)) || (await tryAccess(legacy))) {
      has = true;
    } else {
      try {
        const entries = await fs.readdir(folder);
        has = entries.some((name) => /^mindmap-.+\.json$/i.test(name));
      } catch {
        has = false;
      }
    }
    if (has) {
      results.push(id);
    }
  }
  return results;
}

export type LocalMindmapFile = {
  documentId: string;
  mindmapId: string;
  fileName: string;
  source: "preferred" | "legacy";
};

export async function detectLocalMindmapFiles(docIds: string[]): Promise<LocalMindmapFile[]> {
  const results: LocalMindmapFile[] = [];
  const preferredBaseDir = getDocumentsBaseDir();
  const legacyBaseDir = getLegacyMindmapsBaseDir();
  for (const id of docIds) {
    const folder = path.join(preferredBaseDir, id);
    try {
      const entries = await fs.readdir(folder);
      for (const name of entries) {
        if (name === "mindmap.json") {
          // mindmap.json 属于旧版单文件导图：mindmapId 必须包含 docId，避免不同页面间 ID 冲突


export async function detectLocalMindmapFiles(docIds: string[]): Promise<LocalMindmapFile[]> {
  const results: LocalMindmapFile[] = [];
  const preferredBaseDir = getDocumentsBaseDir();
  const legacyBaseDir = getLegacyMindmapsBaseDir();
  for (const id of docIds) {
    const folder = path.join(preferredBaseDir, id);
    try {
      const entries = await fs.readdir(folder);
      for (const name of entries) {
        if (name === "mindmap.json") {
          // mindmap.json 属于旧版单文件导图：mindmapId 必须包含 docId，避免不同页面间 ID 冲突
          results.push({
            documentId: id,
            mindmapId: `legacy-${id}`,
            fileName: name,
            source: "preferred",
          });
          continue;
        }
        const m = /^mindmap-(.+)\.json$/i.exec(name);
        if (m && m[1]) {
          results.push({ documentId: id, mindmapId: m[1], fileName: name, source: "preferred" });
        }
      }
    } catch {
      // ignore
    }

    // 旧目录（仅支持 mindmap.json）
    const legacy = path.join(legacyBaseDir, id, "mindmap.json");
    if (await tryAccess(legacy)) {
      results.push({
        documentId: id,
        mindmapId: `legacy-${id}`,
        fileName: "mindmap.json",
        source: "legacy",
      });
    }
  }
  return results;
}

function extractMindmapImageAssetIdsFromData(input: unknown): string[] {
  const root = (() => {
    if (!input || typeof input !== "object") return input;
    const record = input;
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    return (record && typeof record === "object" && "root" in (record as any)) ? (record as any).root : input;
  })();

  const ids: string[] = [];
  const seen = new Set<string>();

  const push = (value: unknown) => {
    if (typeof value !== "string") return;
    if (!value.startsWith("asset:")) return;
    const id = value.slice("asset:".length).trim();
    if (!id) return;
    if (seen.has(id)) return;


return (record && typeof record === "object" && "root" in (record as any)) ? (record as any).root : input;
  })();

  const ids: string[] = [];
  const seen = new Set<string>();

  const push = (value: unknown) => {
    if (typeof value !== "string") return;
    if (!value.startsWith("asset:")) return;
    const id = value.slice("asset:".length).trim();
    if (!id) return;
    if (seen.has(id)) return;
    seen.add(id);
    ids.push(id);
  };

  const get = (obj: unknown, key: string): unknown => {
    if (!obj || typeof obj !== "object") return undefined;
    return (obj as Record<string, unknown>)[key];
  };

  const walk = (node: unknown) => {
    if (!node || typeof node !== "object") return;

    const data = get(node, "data");
    const image = get(node, "image");

    // 常见：node.data.image = "asset:xxx"
    push(get(data, "image"));

    // 兼容：node.image = "asset:xxx"
    push(image);

    // 兼容：node.image.url = "asset:xxx"
    push(get(image, "url"));

    // 兼容：node.data.image.url = "asset:xxx"
    push(get(get(data, "image"), "url"));

    const children = get(node, "children");
    if (Array.isArray(children)) {
      children.forEach(walk);
    }
  };

  walk(root);
  return ids;
}

type TrashedMindmapMeta = {
  docId: string;
  mindmapId: string;
  originalFileName: string;
  originalPath: string;
  trashedFileName: string;
  deleted_at: string;
};

async function tryReadJsonFile<T>(file: string): Promise<T | null> {
  try {


type TrashedMindmapMeta = {
  docId: string;
  mindmapId: string;
  originalFileName: string;
  originalPath: string;
  trashedFileName: string;
  deleted_at: string;
};

async function tryReadJsonFile<T>(file: string): Promise<T | null> {
  try {
    const content = await fs.readFile(file, "utf8");
    return JSON.parse(content) as T;
  } catch {
    return null;
  }
}

export async function detectLocalMindmapImageAssetIdsByMindmapId(
  files: LocalMindmapFile[],
): Promise<Record<string, string[]>> {
  const mapping: Record<string, string[]> = {};
  const preferredBaseDir = getDocumentsBaseDir();
  const legacyBaseDir = getLegacyMindmapsBaseDir();

  for (const item of files) {
    const baseDir = item.source === "legacy" ? legacyBaseDir : preferredBaseDir;
    const filePath = path.join(baseDir, item.documentId, item.fileName);
    const data = await tryReadJsonFile<unknown>(filePath);
    if (!data) continue;
    const ids = extractMindmapImageAssetIdsFromData(data);
    if (ids.length > 0) {
      mapping[item.mindmapId] = ids;
    }
  }

  return mapping;
}

async function listTrashMetasForFolder(folder: string): Promise<TrashedMindmapMeta[]> {
  const trashDir = path.join(folder, ".trash");
  try {
    const entries = await fs.readdir(trashDir);
    const metas: TrashedMindmapMeta[] = [];
    for (const name of entries) {
      if (!name.endsWith(".deleted.json")) continue;
      const metaPath = path.join(trashDir, name);
      const meta = await tryReadJsonFile<TrashedMindmapMeta>(metaPath);
      if (meta?.docId && meta?.mindmapId && meta?.trashedFileName && meta?.deleted_at) {
        // 若原路径已存在（用户撤销/恢复），则不再展示为垃圾桶记录，避免堆积。
        try {
          await fs.access(meta.originalPath);
          await fs.rm(path.join(trashDir, meta.trashedFileName), { force: true });
          await fs.rm(metaPath, { force: true });
          continue;
        } catch {
          // ignore
        }
        metas.push(meta);


const meta = await tryReadJsonFile<TrashedMindmapMeta>(metaPath);
      if (meta?.docId && meta?.mindmapId && meta?.trashedFileName && meta?.deleted_at) {
        // 若原路径已存在（用户撤销/恢复），则不再展示为垃圾桶记录，避免堆积。
        try {
          await fs.access(meta.originalPath);
          await fs.rm(path.join(trashDir, meta.trashedFileName), { force: true });
          await fs.rm(metaPath, { force: true });
          continue;
        } catch {
          // ignore
        }
        metas.push(meta);
      }
    }
    return metas;
  } catch {
    return [];
  }
}

export async function detectLocalTrashedMindmapAssets(
  workspaceId: string,
  docIds: string[],
): Promise<MediaAsset[]> {
  const results: MediaAsset[] = [];
  const preferredBaseDir = getDocumentsBaseDir();
  const legacyBaseDir = getLegacyMindmapsBaseDir();

  for (const docId of docIds) {
    const preferredFolder = path.join(preferredBaseDir, docId);
    const legacyFolder = path.join(legacyBaseDir, docId);
    const metas = [
      ...(await listTrashMetasForFolder(preferredFolder)),
      ...(await listTrashMetasForFolder(legacyFolder)),
    ];

    metas.forEach((meta) => {
      results.push({
        id: meta.mindmapId,
        workspace_id: workspaceId,
        document_id: docId,
        asset_type: "mindmap",
        file_url: null,
        thumbnail_url: null,
        bucket: null,
        storage_path: null,
        file_name: meta.originalFileName,
        file_size: null,
        mime_type: "application/json",
        ocr_payload: undefined,
        ocr_strategy: null,
        ocr_text: null,
        ocr_status: null,
        deleted_at: meta.deleted_at,
        deleted_by: null,
        purged_at: null,
        signed_url: null,
        created_at: "",
        updated_at: "",
      });


mime_type: "application/json",
        ocr_payload: undefined,
        ocr_strategy: null,
        ocr_text: null,
        ocr_status: null,
        deleted_at: meta.deleted_at,
        deleted_by: null,
        purged_at: null,
        signed_url: null,
        created_at: "",
        updated_at: "",
      });
    });
  }

  results.sort((a, b) => (b.deleted_at ?? "").localeCompare(a.deleted_at ?? ""));
  return results;
}


import { EventEmitter } from "node:events";
import { PassThrough } from "node:stream";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { PageOptionsState } from "@/types/page-options";

const {
  mockSpawn,
  mockBuildDocumentBridgeContext,
  mockBuildDocumentCommandEnvelope,
  mockExecutePageWriteBridgeCommand,
} = vi.hoisted(() => ({
  mockSpawn: vi.fn(),
  mockBuildDocumentBridgeContext: vi.fn(),
  mockBuildDocumentCommandEnvelope: vi.fn(),
  mockExecutePageWriteBridgeCommand: vi.fn(),
}));

vi.mock("node:child_process", () => ({
  default: {
    spawn: mockSpawn,
  },
  spawn: mockSpawn,
}));

vi.mock("next/server", () => ({
  NextResponse: class NextResponse extends Response {
    static json(body: unknown, init?: ResponseInit) {
      return new Response(JSON.stringify(body), {
        ...init,
        headers: {
          "Content-Type": "application/json",
          ...(init?.headers ?? {}),
        },
      });
    }
  },
}));

vi.mock("@/lib/documents/bridge", () => ({
  buildDocumentBridgeContext: mockBuildDocumentBridgeContext,
  buildDocumentCommandEnvelope: mockBuildDocumentCommandEnvelope,
}));

vi.mock("@/lib/documents/page-write-command-adapter", () => ({
  executePageWriteBridgeCommand: mockExecutePageWriteBridgeCommand,
}));

import {
  extractPageOptionsPatchFromAiMessage,
  startMnoteCliAgentHostRun,
} from "./mnote-cli-agent-host";

function buildPageOptions(overrides: Partial<PageOptionsState> = {}): PageOptionsState {
  return {
    wideLayout: false,
    smallText: false,
    showHeadingNumbers: false,
    showToc: false,
    showStructure: false,
    protectEditing: false,


extractPageOptionsPatchFromAiMessage,
  startMnoteCliAgentHostRun,
} from "./mnote-cli-agent-host";

function buildPageOptions(overrides: Partial<PageOptionsState> = {}): PageOptionsState {
  return {
    wideLayout: false,
    smallText: false,
    showHeadingNumbers: false,
    showToc: false,
    showStructure: false,
    protectEditing: false,
    showWordCount: true,
    collapseBacklinks: false,
    pageFont: "default",
    layoutDensity: "normal",
    hideChildPages: false,
    showBlockRefCount: false,
    embedDefaultBlockId: null,
    ...overrides,
  };
}

function createMockChild(stdoutText: string) {
  const child = new EventEmitter() as EventEmitter & {
    stdout: PassThrough;
    stderr: PassThrough;
    kill: ReturnType<typeof vi.fn>;
  };
  child.stdout = new PassThrough();
  child.stderr = new PassThrough();
  child.kill = vi.fn();
  setTimeout(() => {
    child.stdout.end(stdoutText);
    child.stderr.end("");
    child.emit("close", 0);
  }, 0);
  return child;
}

describe("startMnoteCliAgentHostRun", () => {
  beforeEach(() => {
    mockSpawn.mockReset();
    mockBuildDocumentBridgeContext.mockReset();
    mockBuildDocumentCommandEnvelope.mockReset();
    mockExecutePageWriteBridgeCommand.mockReset();
    delete process.env.DEV_USER_ID;
    delete process.env.DEV_USER_EMAIL;
    delete process.env.DEV_USER_NAME;
  });

  it("启动 mnote-cli 时应把当前 Web 用户作为默认 CLI 写入身份下发", async () => {
    mockSpawn.mockImplementation(() => createMockChild('{"ok":true}\n'));

    const response = await startMnoteCliAgentHostRun({
      request: new Request("http://127.0.0.1:3000/api/ai-agent/run"),
      userId: "a4b72c17-49e3-46d3-8456-24a0a7044d64",
      userEmail: "dev@mnote.local",
      userName: "开发用户",
      payload: {


delete process.env.DEV_USER_NAME;
  });

  it("启动 mnote-cli 时应把当前 Web 用户作为默认 CLI 写入身份下发", async () => {
    mockSpawn.mockImplementation(() => createMockChild('{"ok":true}\n'));

    const response = await startMnoteCliAgentHostRun({
      request: new Request("http://127.0.0.1:3000/api/ai-agent/run"),
      userId: "a4b72c17-49e3-46d3-8456-24a0a7044d64",
      userEmail: "dev@mnote.local",
      userName: "开发用户",
      payload: {
        stream: false,
        messages: [{ role: "user", content: "写入测试页" }],
        context: {
          documentId: "doc-1",
          workspaceId: "ws_req_1778035004501_15",
        },
      },
    });

    expect(response.status).toBe(200);
    expect(mockSpawn).toHaveBeenCalledWith(
      "cargo",
      expect.arrayContaining([
        "--args-json",
        expect.stringContaining('"workspaceId":"ws_req_1778035004501_15"'),
      ]),
      expect.objectContaining({
        env: expect.objectContaining({
          DEV_USER_ID: "a4b72c17-49e3-46d3-8456-24a0a7044d64",
          DEV_USER_EMAIL: "dev@mnote.local",
          DEV_USER_NAME: "开发用户",
          MNOTE_CLI_ALLOW_CREATE_PAGE: "1",
          MNOTE_CLI_ALLOW_EDIT: "1",
        }),
      }),
    );
  });

  it("应把当前页面 workspaceId 传入 CLI argsJson，避免 agent 写入错误工作空间", async () => {
    mockSpawn.mockImplementation(() => createMockChild('{"ok":true}\n'));

    await startMnoteCliAgentHostRun({
      request: new Request("http://127.0.0.1:3000/api/ai-agent/run"),
      userId: "a4b72c17-49e3-46d3-8456-24a0a7044d64",
      userEmail: "dev@mnote.local",
      userName: "开发用户",
      payload: {
        stream: false,
        messages: [{ role: "user", content: "在当前空间写测试页" }],
        context: {
          documentId: "tree_1778036320856_1",
          workspaceId: "ws_req_1778035004501_15",
        },
      },
    });

    const args = mockSpawn.mock.calls[0]?.[1] as string[];
    const argsJson = args[args.indexOf("--args-json") + 1];


payload: {
        stream: false,
        messages: [{ role: "user", content: "在当前空间写测试页" }],
        context: {
          documentId: "tree_1778036320856_1",
          workspaceId: "ws_req_1778035004501_15",
        },
      },
    });

    const args = mockSpawn.mock.calls[0]?.[1] as string[];
    const argsJson = args[args.indexOf("--args-json") + 1];
    expect(JSON.parse(argsJson)).toMatchObject({
      documentId: "tree_1778036320856_1",
      workspaceId: "ws_req_1778035004501_15",
    });
  });

  it("应从自然语言页面设置请求中提取 wired patch", () => {
    expect(
      extractPageOptionsPatchFromAiMessage({
        messages: [{ role: "user", content: "请显示目录，改成紧凑排版，并切换成宋体" }],
        currentPageOptions: buildPageOptions(),
      }),
    ).toEqual({
      showToc: true,
      layoutDensity: "compact",
      pageFont: "song",
    });
  });

  it("命中页面设置 patch 时应直接发结构化 tool_result，而不是启动 cargo", async () => {
    mockBuildDocumentBridgeContext.mockResolvedValue({
      workspaceId: "ws-1",
      requestId: "req-1",
      traceId: "trace-1",
      actor: {
        actorType: "user",
        actorId: "user-1",
        sessionId: null,
      },
      source: {
        channel: "next-route",
        client: "wolai-frontend",
      },
      deploymentId: null,
      projectId: null,
      tenantId: null,
      authToken: null,
      idempotencyKey: null,
      validateOnly: false,
      dryRun: false,
    });
    mockBuildDocumentCommandEnvelope.mockImplementation((input) => ({
      ...input,
      commandId: "cmd-1",
      idempotencyKey: null,
      actor: input.context.actor,
      source: input.context.source,
      target: input.target ?? null,


authToken: null,
      idempotencyKey: null,
      validateOnly: false,
      dryRun: false,
    });
    mockBuildDocumentCommandEnvelope.mockImplementation((input) => ({
      ...input,
      commandId: "cmd-1",
      idempotencyKey: null,
      actor: input.context.actor,
      source: input.context.source,
      target: input.target ?? null,
      preflightData: null,
      reason: input.reason ?? null,
      refs: input.refs ?? [],
      dryRun: input.context.dryRun,
      validateOnly: input.context.validateOnly,
    }));
    mockExecutePageWriteBridgeCommand.mockResolvedValue({
      requestId: "req-1",
      traceId: "trace-1",
      commandId: "cmd-1",
      commandName: "page.layout.updateOptions",
      revision: null,
      conflictDetectionKey: null,
    });

    const response = await startMnoteCliAgentHostRun({
      request: new Request("http://127.0.0.1:3000/api/ai-agent/run"),
      userId: "user-1",
      payload: {
        stream: true,
        messages: [{ role: "user", content: "请显示目录，并切换成紧凑排版" }],
        context: {
          documentId: "doc-1",
          workspaceId: "ws-1",
          pageOptions: buildPageOptions(),
        },
      },
    });

    const text = await response.text();

    expect(mockSpawn).not.toHaveBeenCalled();
    expect(mockExecutePageWriteBridgeCommand).toHaveBeenCalledTimes(1);
    expect(text).toContain("event: tool_call");
    expect(text).toContain('"tool":"page_options_patch"');
    expect(text).toContain('"action":"update_page_options"');
    expect(text).toContain('"showToc":true');
    expect(text).toContain('"layoutDensity":"compact"');
  });
});


import { spawn } from "node:child_process";
import { access } from "node:fs/promises";
import path from "node:path";
import { NextResponse } from "next/server";
import type { PageOptionsState } from "@/types/page-options";
import { pickLeptosTiptapRuntimePageOptions } from "@/lib/documents/page-option-semantics";
import { PAGE_OPTION_SEMANTICS } from "@/lib/documents/page-option-semantics";
import {
  assertOptionsPatch,
  buildDocumentBridgeContext,
  buildDocumentCommandEnvelope,
} from "@/lib/documents/bridge";
import { PAGE_COMMAND_NAMES } from "@/lib/documents/page-command-contract";
import { executePageWriteBridgeCommand } from "@/lib/documents/page-write-command-adapter";

type AgentMessage = { role: "user" | "assistant"; content: string };

export type MnoteCliAgentRunPayload = {
  stream?: boolean;
  maxSteps?: number;
  scope?: string;
  messages: AgentMessage[];
  context?: {
    documentId?: string;
    workspaceId?: string;
    documentBlocks?: unknown;
    pageOptions?: PageOptionsState;
    node?: unknown;
    subtree?: unknown;
    outline?: unknown;
    evidence?: unknown;
    selectedUids?: string[];
  };
  options?: {
    ai?: {
      provider?: string;
      sessionId?: string;
      modelKey?: string;
      profileId?: string;
      model?: string;
    };
  };
};

type CliRunResult = {
  code: number | null;
  stdout: string;
  stderr: string;
};

const CLI_HOST_TIMEOUT_MS = 30_000;
const PAGE_OPTIONS_TOOL_NAME = "page_options_patch";

const toSseFrame = (event: string, data: unknown) => {
  const json = JSON.stringify(data ?? null);
  return `event: ${event}\ndata: ${json}\n\n`;
};

const AI_WRITABLE_BOOLEAN_PAGE_OPTIONS = [
  "wideLayout",


};

const CLI_HOST_TIMEOUT_MS = 30_000;
const PAGE_OPTIONS_TOOL_NAME = "page_options_patch";

const toSseFrame = (event: string, data: unknown) => {
  const json = JSON.stringify(data ?? null);
  return `event: ${event}\ndata: ${json}\n\n`;
};

const AI_WRITABLE_BOOLEAN_PAGE_OPTIONS = [
  "wideLayout",
  "smallText",
  "showHeadingNumbers",
  "showToc",
  "showWordCount",
  "collapseBacklinks",
  "hideChildPages",
] as const satisfies ReadonlyArray<keyof PageOptionsState>;

const AI_WRITABLE_PAGE_FONT_VALUES = ["default", "song", "kai"] as const;
const AI_WRITABLE_LAYOUT_DENSITY_VALUES = ["compact", "normal", "spacious"] as const;

function isPlainRecord(value: unknown): value is Record<string, unknown> {
  return typeof value === "object" && value !== null && !Array.isArray(value);
}

function normalizeAiWritablePageOptionsPatch(value: unknown): Partial<PageOptionsState> | null {
  if (!isPlainRecord(value)) {
    return null;
  }

  const patch: Partial<PageOptionsState> = {};
  for (const key of AI_WRITABLE_BOOLEAN_PAGE_OPTIONS) {
    if (PAGE_OPTION_SEMANTICS[key].runtimeSupport !== "wired") {
      continue;
    }
    const nextValue = value[key];
    if (typeof nextValue === "boolean") {
      patch[key] = nextValue;
    }
  }

  const rawPageFont = value.pageFont;
  if (
    PAGE_OPTION_SEMANTICS.pageFont.runtimeSupport === "wired" &&
    typeof rawPageFont === "string" &&
    (AI_WRITABLE_PAGE_FONT_VALUES as readonly string[]).includes(rawPageFont)
  ) {
    patch.pageFont = rawPageFont;
  }

  const rawLayoutDensity = value.layoutDensity;
  if (
    PAGE_OPTION_SEMANTICS.layoutDensity.runtimeSupport === "wired" &&
    typeof rawLayoutDensity === "string" &&
    (AI_WRITABLE_LAYOUT_DENSITY_VALUES as readonly string[]).includes(rawLayoutDensity)
  ) {
    patch.layoutDensity = rawLayoutDensity;
  }


) {
    patch.pageFont = rawPageFont;
  }

  const rawLayoutDensity = value.layoutDensity;
  if (
    PAGE_OPTION_SEMANTICS.layoutDensity.runtimeSupport === "wired" &&
    typeof rawLayoutDensity === "string" &&
    (AI_WRITABLE_LAYOUT_DENSITY_VALUES as readonly string[]).includes(rawLayoutDensity)
  ) {
    patch.layoutDensity = rawLayoutDensity;
  }

  const rawEmbedDefaultBlockId = value.embedDefaultBlockId;
  if (PAGE_OPTION_SEMANTICS.embedDefaultBlockId.runtimeSupport === "wired") {
    if (rawEmbedDefaultBlockId === null) {
      patch.embedDefaultBlockId = null;
    } else if (typeof rawEmbedDefaultBlockId === "string" && rawEmbedDefaultBlockId.trim()) {
      patch.embedDefaultBlockId = rawEmbedDefaultBlockId.trim();
    }
  }

  if (Object.keys(patch).length === 0) {
    return null;
  }
  assertOptionsPatch(patch);
  return patch;
}

function readLatestUserMessage(messages: AgentMessage[]): string {
  for (let index = messages.length - 1; index >= 0; index -= 1) {
    const message = messages[index];
    if (message?.role === "user" && String(message.content ?? "").trim()) {
      return String(message.content);
    }
  }
  return "";
}

function extractStructuredPageOptionsPatch(text: string): Partial<PageOptionsState> | null {
  const tagged = text.match(/<update_page_options>([\s\S]*?)<\/update_page_options>/i);
  const candidates = [tagged?.[1] ?? "", text];

  for (const candidate of candidates) {
    const trimmed = candidate.trim();
    if (!trimmed) {
      continue;
    }
    try {
      const parsed = JSON.parse(trimmed) as unknown;
      if (!isPlainRecord(parsed)) {
        continue;
      }
      const payload =
        String(parsed.action ?? "") === "update_page_options" && isPlainRecord(parsed.pageOptionsPatch)
          ? parsed.pageOptionsPatch
          : parsed;
      const normalized = normalizeAiWritablePageOptionsPatch(payload);
      if (normalized) {
        return normalized;


try {
      const parsed = JSON.parse(trimmed) as unknown;
      if (!isPlainRecord(parsed)) {
        continue;
      }
      const payload =
        String(parsed.action ?? "") === "update_page_options" && isPlainRecord(parsed.pageOptionsPatch)
          ? parsed.pageOptionsPatch
          : parsed;
      const normalized = normalizeAiWritablePageOptionsPatch(payload);
      if (normalized) {
        return normalized;
      }
    } catch {
      // ignore
    }
  }

  return null;
}

function extractNaturalLanguagePageOptionsPatch(text: string): Partial<PageOptionsState> | null {
  const normalizedText = text.trim();
  if (!normalizedText) {
    return null;
  }

  const patch: Partial<PageOptionsState> = {};

  if (/(显示|打开|开启).*(目录|标题目录)|显示标题目录/.test(normalizedText)) {
    patch.showToc = true;
  } else if (/(隐藏|关闭).*(目录|标题目录)|关闭标题目录/.test(normalizedText)) {
    patch.showToc = false;
  }

  if (/(开启|打开|启用|改成|切换成).*(宽版|宽布局|自适应宽度)|使用宽版/.test(normalizedText)) {
    patch.wideLayout = true;
  } else if (/(关闭|取消|恢复).*(宽版|宽布局|自适应宽度)|恢复标准宽度/.test(normalizedText)) {
    patch.wideLayout = false;
  }

  if (/(开启|打开|启用|改成|切换成).*(小字体)|使用小字体/.test(normalizedText)) {
    patch.smallText = true;
  } else if (/(关闭|取消|恢复).*(小字体)|恢复正常字体大小/.test(normalizedText)) {
    patch.smallText = false;
  }

  if (/(显示|打开|开启).*(标题编号|标题自动编号)/.test(normalizedText)) {
    patch.showHeadingNumbers = true;
  } else if (/(隐藏|关闭).*(标题编号|标题自动编号)/.test(normalizedText)) {
    patch.showHeadingNumbers = false;
  }

  if (/宋体/.test(normalizedText)) {
    patch.pageFont = "song";
  } else if (/楷体/.test(normalizedText)) {
    patch.pageFont = "kai";
  } else if (/(默认字体|恢复默认字体)/.test(normalizedText)) {
    patch.pageFont = "default";
  }


patch.showHeadingNumbers = true;
  } else if (/(隐藏|关闭).*(标题编号|标题自动编号)/.test(normalizedText)) {
    patch.showHeadingNumbers = false;
  }

  if (/宋体/.test(normalizedText)) {
    patch.pageFont = "song";
  } else if (/楷体/.test(normalizedText)) {
    patch.pageFont = "kai";
  } else if (/(默认字体|恢复默认字体)/.test(normalizedText)) {
    patch.pageFont = "default";
  }

  if (/紧凑/.test(normalizedText)) {
    patch.layoutDensity = "compact";
  } else if (/(宽松|疏朗)/.test(normalizedText)) {
    patch.layoutDensity = "spacious";
  } else if (/(默认间距|标准间距|正常间距)/.test(normalizedText)) {
    patch.layoutDensity = "normal";
  }

  return Object.keys(patch).length > 0 ? patch : null;
}

export function extractPageOptionsPatchFromAiMessage(input: {
  messages: AgentMessage[];
  currentPageOptions?: PageOptionsState | null;
}): Partial<PageOptionsState> | null {
  void input.currentPageOptions;
  const latestUserMessage = readLatestUserMessage(input.messages);
  return (
    extractStructuredPageOptionsPatch(latestUserMessage) ??
    extractNaturalLanguagePageOptionsPatch(latestUserMessage)
  );
}

function describePageOptionsPatch(patch: Partial<PageOptionsState>): string {
  const labels: string[] = [];
  if ("showToc" in patch) labels.push(patch.showToc ? "显示目录" : "隐藏目录");
  if ("wideLayout" in patch) labels.push(patch.wideLayout ? "启用宽版" : "关闭宽版");
  if ("smallText" in patch) labels.push(patch.smallText ? "启用小字体" : "关闭小字体");
  if ("showHeadingNumbers" in patch) {
    labels.push(patch.showHeadingNumbers ? "显示标题编号" : "隐藏标题编号");
  }
  if ("pageFont" in patch) {
    labels.push(
      patch.pageFont === "song" ? "切换为宋体" : patch.pageFont === "kai" ? "切换为楷体" : "恢复默认字体",
    );
  }
  if ("layoutDensity" in patch) {
    labels.push(
      patch.layoutDensity === "compact"
        ? "切换为紧凑排版"
        : patch.layoutDensity === "spacious"
          ? "切换为宽松排版"
          : "恢复标准排版",
    );
  }
  if ("embedDefaultBlockId" in patch) {
    labels.push(patch.embedDefaultBlockId ? "更新嵌入默认位置" : "清除嵌入默认位置");


}
  if ("layoutDensity" in patch) {
    labels.push(
      patch.layoutDensity === "compact"
        ? "切换为紧凑排版"
        : patch.layoutDensity === "spacious"
          ? "切换为宽松排版"
          : "恢复标准排版",
    );
  }
  if ("embedDefaultBlockId" in patch) {
    labels.push(patch.embedDefaultBlockId ? "更新嵌入默认位置" : "清除嵌入默认位置");
  }
  if ("showWordCount" in patch) labels.push(patch.showWordCount ? "显示字数统计" : "隐藏字数统计");
  if ("collapseBacklinks" in patch) {
    labels.push(patch.collapseBacklinks ? "折叠反向引用" : "展开反向引用");
  }
  if ("hideChildPages" in patch) labels.push(patch.hideChildPages ? "隐藏子页面" : "显示子页面");
  return labels.length > 0 ? `已更新页面设置：${labels.join("，")}。` : "已更新页面设置。";
}

async function pathExists(targetPath: string) {
  try {
    await access(targetPath);
    return true;
  } catch {
    return false;
  }
}

async function resolveRepoRoot() {
  let dir = path.resolve(process.cwd());
  for (let i = 0; i < 8; i += 1) {
    if (await pathExists(path.join(dir, "rust", "Cargo.toml"))) {
      return dir;
    }
    const parent = path.dirname(dir);
    if (parent === dir) break;
    dir = parent;
  }
  return path.resolve(process.cwd(), "..");
}

function buildCliArgs(input: { repoRoot: string; userId: string; payload: MnoteCliAgentRunPayload }) {
  const sessionId = String(input.payload.options?.ai?.sessionId ?? "").trim() || `ai-${Date.now()}`;
  const documentId = String(input.payload.context?.documentId ?? "").trim() || "current";
  const workspaceId = String(input.payload.context?.workspaceId ?? "").trim() || null;
  const argsJson = JSON.stringify({
    pageId: documentId,
    documentId,
    workspaceId,
    provider: input.payload.options?.ai?.provider ?? null,
    modelKey: input.payload.options?.ai?.modelKey ?? null,
    profileId: input.payload.options?.ai?.profileId ?? null,
    selectedUids: input.payload.context?.selectedUids ?? null,
    pageOptions: input.payload.context?.pageOptions ?? null,
    editorRuntimePageOptions: input.payload.context?.pageOptions
      ? pickLeptosTiptapRuntimePageOptions(input.payload.context.pageOptions)
      : null,
  });


pageId: documentId,
    documentId,
    workspaceId,
    provider: input.payload.options?.ai?.provider ?? null,
    modelKey: input.payload.options?.ai?.modelKey ?? null,
    profileId: input.payload.options?.ai?.profileId ?? null,
    selectedUids: input.payload.context?.selectedUids ?? null,
    pageOptions: input.payload.context?.pageOptions ?? null,
    editorRuntimePageOptions: input.payload.context?.pageOptions
      ? pickLeptosTiptapRuntimePageOptions(input.payload.context.pageOptions)
      : null,
  });

  return [
    "run",
    "--quiet",
    "--manifest-path",
    path.join(input.repoRoot, "rust", "Cargo.toml"),
    "-p",
    "mnote-cli",
    "--",
    "--json",
    "--validate-only",
    "--dry-run",
    "--actor-id",
    input.userId,
    "--actor-type",
    "user",
    "--session-id",
    sessionId,
    "--reason",
    "ai-agent-run:mnote-cli-host",
    "tool",
    "run",
    "--tool-name",
    "doc_get",
    "--kind",
    "query",
    "--mode",
    "explain-plan",
    "--args-json",
    argsJson,
  ];
}

async function runMnoteCli(input: {
  userId: string;
  userEmail?: string;
  userName?: string;
  payload: MnoteCliAgentRunPayload;
}): Promise<CliRunResult> {
  const repoRoot = await resolveRepoRoot();
  const args = buildCliArgs({ repoRoot, userId: input.userId, payload: input.payload });

  return new Promise<CliRunResult>((resolve, reject) => {
    const child = spawn("cargo", args, {
      cwd: repoRoot,
      env: {
        ...process.env,
        CARGO_TERM_COLOR: "never",


userName?: string;
  payload: MnoteCliAgentRunPayload;
}): Promise<CliRunResult> {
  const repoRoot = await resolveRepoRoot();
  const args = buildCliArgs({ repoRoot, userId: input.userId, payload: input.payload });

  return new Promise<CliRunResult>((resolve, reject) => {
    const child = spawn("cargo", args, {
      cwd: repoRoot,
      env: {
        ...process.env,
        CARGO_TERM_COLOR: "never",
        RUSTUP_TOOLCHAIN: process.env.RUSTUP_TOOLCHAIN?.trim() || "1.89.0",
        DEV_USER_ID: input.userId,
        DEV_USER_EMAIL: input.userEmail?.trim() || process.env.DEV_USER_EMAIL || "dev@mnote.local",
        DEV_USER_NAME: input.userName?.trim() || process.env.DEV_USER_NAME || "开发用户",
        MNOTE_CLI_ALLOW_CREATE_PAGE: process.env.MNOTE_CLI_ALLOW_CREATE_PAGE || "1",
        MNOTE_CLI_ALLOW_EDIT: process.env.MNOTE_CLI_ALLOW_EDIT || "1",
      },
      stdio: ["ignore", "pipe", "pipe"],
    });

    let stdout = "";
    let stderr = "";
    let timedOut = false;
    const timeout = setTimeout(() => {
      timedOut = true;
      child.kill("SIGKILL");
    }, CLI_HOST_TIMEOUT_MS);

    child.stdout.setEncoding("utf8");
    child.stderr.setEncoding("utf8");
    child.stdout.on("data", (chunk: string) => {
      stdout += chunk;
    });
    child.stderr.on("data", (chunk: string) => {
      stderr += chunk;
    });
    child.on("error", (error) => {
      clearTimeout(timeout);
      reject(error);
    });
    child.on("close", (code) => {
      clearTimeout(timeout);
      if (timedOut) {
        reject(new Error("mnote-cli host 执行超时"));
        return;
      }
      resolve({ code, stdout, stderr });
    });
  });
}

async function executeAiPageOptionsPatch(input: {
  request: Request;
  payload: MnoteCliAgentRunPayload;
  patch: Partial<PageOptionsState>;
}) {
  const documentId = String(input.payload.context?.documentId ?? "").trim();
  const workspaceId = String(input.payload.context?.workspaceId ?? "").trim() || null;


resolve({ code, stdout, stderr });
    });
  });
}

async function executeAiPageOptionsPatch(input: {
  request: Request;
  payload: MnoteCliAgentRunPayload;
  patch: Partial<PageOptionsState>;
}) {
  const documentId = String(input.payload.context?.documentId ?? "").trim();
  const workspaceId = String(input.payload.context?.workspaceId ?? "").trim() || null;
  const bridgeContext = await buildDocumentBridgeContext({
    request: input.request,
    workspaceId,
  });
  const envelope = buildDocumentCommandEnvelope({
    name: PAGE_COMMAND_NAMES.updateLayout,
    payload: {
      documentId,
      workspaceId,
      options: input.patch,
    },
    context: bridgeContext,
    target: {
      workspaceId,
      pageId: documentId,
    },
    reason: "ai-agent-run:mnote-cli-host:update-page-options",
    refs: ["mnote-cli-host", "page_options_patch"],
  });
  const result = await executePageWriteBridgeCommand({
    context: bridgeContext,
    envelope,
  });
  return {
    documentId,
    patch: input.patch,
    meta: {
      requestId: result.requestId,
      traceId: result.traceId,
      commandId: result.commandId,
      commandName: result.commandName,
    },
    assistantText: describePageOptionsPatch(input.patch),
  };
}

export async function startMnoteCliAgentHostRun(input: {
  request: Request;
  userId: string;
  userEmail?: string;
  userName?: string;
  payload: MnoteCliAgentRunPayload;
}): Promise<Response> {
  const pageOptionsPatch = extractPageOptionsPatchFromAiMessage({
    messages: input.payload.messages,
    currentPageOptions: input.payload.context?.pageOptions ?? null,
  });
  const shouldHandlePageOptionsPatch =


export async function startMnoteCliAgentHostRun(input: {
  request: Request;
  userId: string;
  userEmail?: string;
  userName?: string;
  payload: MnoteCliAgentRunPayload;
}): Promise<Response> {
  const pageOptionsPatch = extractPageOptionsPatchFromAiMessage({
    messages: input.payload.messages,
    currentPageOptions: input.payload.context?.pageOptions ?? null,
  });
  const shouldHandlePageOptionsPatch =
    pageOptionsPatch &&
    String(input.payload.context?.documentId ?? "").trim() &&
    String(input.payload.context?.workspaceId ?? "").trim();

  if (shouldHandlePageOptionsPatch) {
    const toolCallId = `${PAGE_OPTIONS_TOOL_NAME}_${Date.now()}`;
    const toolArgs = {
      documentId: String(input.payload.context?.documentId ?? "").trim(),
      pageOptionsPatch,
    };
    if (!input.payload.stream) {
      const result = await executeAiPageOptionsPatch({
        request: input.request,
        payload: input.payload,
        patch: pageOptionsPatch,
      });
      return NextResponse.json({
        ok: true,
        bridgeOwner: "mnote-cli",
        text: result.assistantText,
        toolResult: {
          action: "update_page_options",
          documentId: result.documentId,
          pageOptionsPatch: result.patch,
          meta: result.meta,
        },
      });
    }

    const body = new ReadableStream<Uint8Array>({
      async start(controller) {
        const encoder = new TextEncoder();
        controller.enqueue(encoder.encode(toSseFrame("ready", { ok: true, bridgeOwner: "mnote-cli" })));
        controller.enqueue(
          encoder.encode(
            toSseFrame("tool_call", {
              id: toolCallId,
              tool: PAGE_OPTIONS_TOOL_NAME,
              args: toolArgs,
            }),
          ),
        );
        const startedAt = Date.now();
        try {
          const result = await executeAiPageOptionsPatch({
            request: input.request,
            payload: input.payload,
            patch: pageOptionsPatch,


id: toolCallId,
              tool: PAGE_OPTIONS_TOOL_NAME,
              args: toolArgs,
            }),
          ),
        );
        const startedAt = Date.now();
        try {
          const result = await executeAiPageOptionsPatch({
            request: input.request,
            payload: input.payload,
            patch: pageOptionsPatch,
          });
          const toolResult = {
            action: "update_page_options",
            documentId: result.documentId,
            pageOptionsPatch: result.patch,
            meta: result.meta,
          };
          controller.enqueue(
            encoder.encode(
              toSseFrame("tool_result", {
                id: toolCallId,
                tool: PAGE_OPTIONS_TOOL_NAME,
                ok: true,
                ms: Math.max(0, Date.now() - startedAt),
                result: toolResult,
              }),
            ),
          );
          controller.enqueue(encoder.encode(toSseFrame("assistant_message", { text: result.assistantText })));
          controller.enqueue(
            encoder.encode(toSseFrame("completion", { ok: true, text: result.assistantText, steps: 1 })),
          );
        } catch (error) {
          const message = error instanceof Error ? error.message : String(error);
          controller.enqueue(
            encoder.encode(
              toSseFrame("tool_result", {
                id: toolCallId,
                tool: PAGE_OPTIONS_TOOL_NAME,
                ok: false,
                ms: Math.max(0, Date.now() - startedAt),
                result: { error: message },
              }),
            ),
          );
          controller.enqueue(encoder.encode(toSseFrame("error", { ok: false, message })));
        } finally {
          controller.close();
        }
      },
    });

    return new Response(body, {
      headers: {
        "Content-Type": "text/event-stream; charset=utf-8",
        "Cache-Control": "no-cache, no-transform",
        Connection: "keep-alive",
        "X-Accel-Buffering": "no",


} finally {
          controller.close();
        }
      },
    });

    return new Response(body, {
      headers: {
        "Content-Type": "text/event-stream; charset=utf-8",
        "Cache-Control": "no-cache, no-transform",
        Connection: "keep-alive",
        "X-Accel-Buffering": "no",
        "x-mnote-ai-execution-owner": "mnote-cli",
      },
    });
  }

  const run = runMnoteCli({
    userId: input.userId,
    userEmail: input.userEmail,
    userName: input.userName,
    payload: input.payload,
  });

  if (!input.payload.stream) {
    const result = await run;
    if (result.code !== 0) {
      return NextResponse.json({ error: result.stderr || "mnote-cli 执行失败" }, { status: 500 });
    }
    return NextResponse.json({
      ok: true,
      bridgeOwner: "mnote-cli",
      text: result.stdout.trim(),
    });
  }

  const body = new ReadableStream<Uint8Array>({
    async start(controller) {
      const encoder = new TextEncoder();
      controller.enqueue(encoder.encode(toSseFrame("ready", { ok: true, bridgeOwner: "mnote-cli" })));
      try {
        const result = await run;
        if (result.code !== 0) {
          controller.enqueue(
            encoder.encode(toSseFrame("error", { ok: false, message: result.stderr || "mnote-cli 执行失败" })),
          );
        } else {
          const text = result.stdout.trim() || "mnote-cli 无输出";
          controller.enqueue(encoder.encode(toSseFrame("assistant_message", { text })));
          controller.enqueue(encoder.encode(toSseFrame("completion", { ok: true, text, steps: 1 })));
        }
      } catch (error) {
        controller.enqueue(
          encoder.encode(toSseFrame("error", { ok: false, message: error instanceof Error ? error.message : String(error) })),
        );
      } finally {
        controller.close();
      }
    },
  });


controller.enqueue(encoder.encode(toSseFrame("assistant_message", { text })));
          controller.enqueue(encoder.encode(toSseFrame("completion", { ok: true, text, steps: 1 })));
        }
      } catch (error) {
        controller.enqueue(
          encoder.encode(toSseFrame("error", { ok: false, message: error instanceof Error ? error.message : String(error) })),
        );
      } finally {
        controller.close();
      }
    },
  });

  return new Response(body, {
    headers: {
      "Content-Type": "text/event-stream; charset=utf-8",
      "Cache-Control": "no-cache, no-transform",
      Connection: "keep-alive",
      "X-Accel-Buffering": "no",
      "x-mnote-ai-execution-owner": "mnote-cli",
    },
  });
}


import { randomUUID } from "crypto";
import type { ConvexHttpClient } from "convex/browser";
import type { FunctionReference } from "convex/server";
import type { SidebarInitialData } from "@/components/sidebar/types";
import type { DocumentRecord } from "@/lib/documents";
import { api } from "@/lib/convex/api";
import {
  mapSidebarDatasetListQueryResultToInitialData,
  type SidebarDatasetListQueryResult,
} from "@/lib/sidebar-data";
import type { WorkspaceSummary } from "@/lib/workspaces";
import {
  attachKernelFileTreeProjection,
  resolveKernelFileTreeProjection,
} from "@/lib/server/kernel-file-tree";
import type { AuthContext } from "@/lib/auth/types";

type LoadSidebarDataFromConvexInput = {
  client: ConvexHttpClient;
  auth: AuthContext;
  fallbackName: string;
  requestedWorkspaceId?: string | null;
};

type LoadSidebarDataFromConvexResult = {
  workspaces: WorkspaceSummary[];
  activeWorkspaceId: string;
  targetWorkspaceId: string | null;
  sidebarDataset: SidebarDatasetListQueryResult | null;
  sidebarInitialData: SidebarInitialData | null;
  documents: DocumentRecord[];
};

const sidebarDatasetListQuery = ((api as unknown as Record<string, unknown>).sidebar as
  | Record<string, unknown>
  | undefined)?.datasetList as FunctionReference<
  "query",
  "public",
  { workspaceId: string },
  SidebarDatasetListQueryResult
>;

export async function loadSidebarDataFromConvex(
  input: LoadSidebarDataFromConvexInput,
): Promise<LoadSidebarDataFromConvexResult> {
  const bootstrap = await input.client.mutation(api.workspaces.ensureDefaultWorkspace, {
    fallbackName: input.fallbackName,
    workspaceIdIfCreate: randomUUID(),
  });

  const workspaces = bootstrap.workspaces;
  const activeWorkspaceId = bootstrap.activeWorkspaceId;
  const targetWorkspaceId = input.requestedWorkspaceId?.trim() || activeWorkspaceId || null;

  if (!targetWorkspaceId) {
    return {
      workspaces,
      activeWorkspaceId,
      targetWorkspaceId: null,
      sidebarDataset: null,


});

  const workspaces = bootstrap.workspaces;
  const activeWorkspaceId = bootstrap.activeWorkspaceId;
  const targetWorkspaceId = input.requestedWorkspaceId?.trim() || activeWorkspaceId || null;

  if (!targetWorkspaceId) {
    return {
      workspaces,
      activeWorkspaceId,
      targetWorkspaceId: null,
      sidebarDataset: null,
      sidebarInitialData: null,
      documents: [],
    };
  }

  // 说明：SSR 首屏阶段必须优先保证“可进入页面”。
  // mnote-web compat sidebar 仍处于渐进接入期，若这里优先走远端/Rust 兼容接口，
  // 一旦 actor/header 权限链不一致，就会把整个 (app) layout 阻塞住，表现为“登录后进不去页面”。
  // 因此服务端首屏一律先走稳定的 Convex 直查；mnote-web 仅允许用于客户端树壳渐进增强。
  const sidebarDataset = (await input.client.query(sidebarDatasetListQuery, {
    workspaceId: targetWorkspaceId,
  })) as SidebarDatasetListQueryResult;
  const syntheticRequest = new Request(`http://127.0.0.1:3000/api/sidebar?workspaceId=${targetWorkspaceId}`);
  const sidebarDatasetWithFileTree = attachKernelFileTreeProjection({
    dataset: sidebarDataset,
    projection: await resolveKernelFileTreeProjection({
      client: input.client,
      request: syntheticRequest,
      workspaceId: targetWorkspaceId,
      actor: {
        actorType: "user",
        actorId: input.auth.userId,
        sessionId: null,
      },
      dataset: sidebarDataset,
    }),
  });
  const normalizedDocuments = (sidebarDatasetWithFileTree.documents ?? []) as DocumentRecord[];

  return {
    workspaces: sidebarDatasetWithFileTree.workspaces ?? workspaces,
    activeWorkspaceId,
    targetWorkspaceId,
    sidebarDataset: sidebarDatasetWithFileTree,
    sidebarInitialData: mapSidebarDatasetListQueryResultToInitialData(sidebarDatasetWithFileTree),
    documents: normalizedDocuments,
  };
}


export async function recordRecentPage(workspaceId: string | null, documentId: string) {
  if (!workspaceId) {
    return;
  }
  try {
    await fetch("/api/search/recent", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ workspaceId, documentId }),
    });
  } catch (error) {
    console.error("recordRecentPage failed", error);
  }
}


import { describe, expect, it } from "vitest";
import { SEARCH_REQUEST_TRANSPORT_CONTRACT, buildSearchRequest } from "./request";

describe("buildSearchRequest", () => {
  it("combines标题过滤与时间范围", () => {
    const payload = buildSearchRequest({
      workspaceId: "ws-1",
      activeDocumentId: "doc-1",
      query: "demo",
      filters: {
        titleOnly: true,
        exact: false,
        onlyCurrentPage: false,
        includeOcr: false,
        timeField: "updated",
        customRange: undefined,
      },
      timeRange: "7d",
    });

    expect(payload).not.toBeNull();
    expect(payload?.filters.titleOnly).toBe(true);
    expect(payload?.filters.timeRange).toBe("7d");
  });

  it("自动重置仅当前页面过滤条件", () => {
    const payload = buildSearchRequest({
      workspaceId: "ws-2",
      activeDocumentId: null,
      query: "",
      filters: {
        titleOnly: false,
        exact: false,
        onlyCurrentPage: true,
        includeOcr: false,
        timeField: "created",
        customRange: { from: "2025-02-01", to: "2025-02-07" },
      },
      timeRange: "any",
    });

    expect(payload?.filters.onlyCurrentPage).toBe(false);
    expect(payload?.documentId).toBeUndefined();
  });

  it("固定搜索请求将由 Rust Web search.documents transport 拥有", () => {
    const payload = buildSearchRequest({
      workspaceId: "ws-3",
      activeDocumentId: "doc-3",
      query: "Rust Web",
      filters: {
        titleOnly: false,
        exact: true,
        onlyCurrentPage: true,
        includeOcr: true,
        timeField: "updated",
        customRange: undefined,
      },
      timeRange: "any",
    });


activeDocumentId: "doc-3",
      query: "Rust Web",
      filters: {
        titleOnly: false,
        exact: true,
        onlyCurrentPage: true,
        includeOcr: true,
        timeField: "updated",
        customRange: undefined,
      },
      timeRange: "any",
    });

    expect(payload).toMatchObject({
      workspaceId: "ws-3",
      documentId: "doc-3",
      query: "Rust Web",
      filters: {
        exact: true,
        includeOcr: true,
        onlyCurrentPage: true,
      },
    });
  });
});

describe("SEARCH_REQUEST_TRANSPORT_CONTRACT", () => {
  it("搜索请求 contract 固定为 Rust Web transport", () => {
    expect(SEARCH_REQUEST_TRANSPORT_CONTRACT).toEqual({
      shellOwner: "mnote-web",
      queryName: "search.documents",
      endpoint: "/api/search/documents",
      runtimeRole: "rust_web_transport",
    });
  });
});


import type {
  DocumentSearchFilters,
  DocumentSearchRequest,
  DocumentSearchTimeRange,
} from "@/types/search";

export const SEARCH_REQUEST_TRANSPORT_CONTRACT = {
  shellOwner: "mnote-web",
  queryName: "search.documents",
  endpoint: "/api/search/documents",
  runtimeRole: "rust_web_transport",
} as const;

interface BuildSearchRequestArgs {
  workspaceId: string | null;
  activeDocumentId: string | null;
  query: string;
  filters: Omit<DocumentSearchFilters, "timeRange">;
  timeRange: DocumentSearchTimeRange;
}

export function buildSearchRequest({
  workspaceId,
  activeDocumentId,
  query,
  filters,
  timeRange,
}: BuildSearchRequestArgs): DocumentSearchRequest | null {
  if (!workspaceId) {
    return null;
  }

  const normalizedFilters: DocumentSearchFilters = {
    ...filters,
    timeRange,
    onlyCurrentPage: filters.onlyCurrentPage && Boolean(activeDocumentId),
  };

  return {
    workspaceId,
    documentId: normalizedFilters.onlyCurrentPage ? activeDocumentId ?? undefined : undefined,
    query,
    filters: normalizedFilters,
  };
}


import "server-only";

import { api } from "@/lib/convex/api";
import { getAuthedConvexClient } from "@/lib/convex/route";
import {
  buildDocumentBridgeContext,
  buildDocumentQueryEnvelope,
  DocumentBridgeError,
} from "@/lib/documents/bridge";
import { executeRustBridgeQuery } from "@/lib/documents/rust-runtime";
import type { DocumentSearchFilters, DocumentSearchRequest, DocumentSearchResponse, DocumentSearchResult } from "@/types/search";

const MAX_LIMIT = 50;

const DEFAULT_FILTERS: DocumentSearchFilters = {
  titleOnly: false,
  exact: false,
  onlyCurrentPage: false,
  includeOcr: false,
  timeRange: "any",
  timeField: "updated",
};

function parseDateToIso(value: string | undefined): string | null {
  const raw = String(value ?? "").trim();
  if (!raw) {
    return null;
  }
  const date = new Date(raw);
  if (Number.isNaN(date.getTime())) {
    return null;
  }
  return date.toISOString();
}

type SearchDocumentsRustResult = {
  results: Array<{
    id: string;
    title: string;
    snippet: string;
    updatedAt?: string | null;
    createdAt?: string | null;
    matchField: "title" | "content" | "recent";
    hasOcr: boolean;
    publicPath: string;
    score: number;
    nodeId?: string | null;
    subtreeRootId?: string | null;
    evidence?: Array<{
      kind: string;
      nodeId?: string | null;
      snippet: string;
    }>;
  }>;
  enqueueAssetIds?: string[];
};

type SearchRecentRustResult = {
  items: Array<{
    documentId: string;


evidence?: Array<{
      kind: string;
      nodeId?: string | null;
      snippet: string;
    }>;
  }>;
  enqueueAssetIds?: string[];
};

type SearchRecentRustResult = {
  items: Array<{
    documentId: string;
  }>;
};

type SearchDocumentRow = {
  id: string;
  workspace_id?: string | null;
  title?: string | null;
  raw_text?: string | null;
  created_at?: string | null;
  updated_at?: string | null;
};

type SearchMindmapRow = {
  document_id?: string | null;
  data?: unknown;
};

type SearchTableRow = {
  id: string;
  document_id?: string | null;
  title?: string | null;
};

type SearchTableContentRow = {
  table_id?: string | null;
  document_id?: string | null;
  row_hash?: string | null;
};

type SearchAssetRow = {
  id: string;
  document_id?: string | null;
  asset_type?: string | null;
  file_name?: string | null;
  mime_type?: string | null;
  ocr_text?: string | null;
  ocr_status?: string | null;
};

type RecentRow = {
  document_id: string;
  workspace_id: string;
  last_accessed_at: string;
};

function normalizeWorkspaceId(workspaceId: string | null | undefined) {
  const normalized = typeof workspaceId === "string" ? workspaceId.trim() : "";
  if (!normalized) {


ocr_status?: string | null;
};

type RecentRow = {
  document_id: string;
  workspace_id: string;
  last_accessed_at: string;
};

function normalizeWorkspaceId(workspaceId: string | null | undefined) {
  const normalized = typeof workspaceId === "string" ? workspaceId.trim() : "";
  if (!normalized) {
    throw new DocumentBridgeError("缺少 workspaceId", 400, "VALIDATION_ERROR");
  }
  return normalized;
}

function normalizeQuery(value: string | null | undefined) {
  return String(value ?? "").trim();
}

function normalizeLimit(value: number | null | undefined) {
  if (typeof value !== "number" || !Number.isFinite(value)) {
    return 30;
  }
  return Math.max(1, Math.min(Math.floor(value), MAX_LIMIT));
}

function normalizeDocumentId(value: string | null | undefined) {
  const normalized = typeof value === "string" ? value.trim() : "";
  return normalized || null;
}

function normalizeFilters(input: DocumentSearchRequest["filters"] | undefined): DocumentSearchFilters {
  return {
    ...DEFAULT_FILTERS,
    ...input,
  };
}

function mapRecentRowsToResults(input: {
  documentIds: string[];
  docs: SearchDocumentRow[];
}): DocumentSearchResult[] {
  const docMap = new Map(input.docs.map((item) => [item.id, item]));
  return input.documentIds
    .map((documentId) => docMap.get(documentId))
    .filter((item): item is NonNullable<typeof item> => Boolean(item))
    .map((item) => ({
      id: item.id,
      title: item.title ?? "无标题",
      snippet: "",
      updatedAt: item.updated_at ?? null,
      createdAt: item.created_at ?? null,
      matchField: "recent",
      hasOcr: false,
      publicPath: `/documents/${item.id}`,
      score: 0,
      nodeId: item.id,
      subtreeRootId: item.id,


.map((item) => ({
      id: item.id,
      title: item.title ?? "无标题",
      snippet: "",
      updatedAt: item.updated_at ?? null,
      createdAt: item.created_at ?? null,
      matchField: "recent",
      hasOcr: false,
      publicPath: `/documents/${item.id}`,
      score: 0,
      nodeId: item.id,
      subtreeRootId: item.id,
      evidence: [
        {
          kind: "recent",
          nodeId: item.id,
          snippet: item.title ?? "最近访问页面",
        },
      ],
    }));
}

function mapRustResultsToResponse(
  result: SearchDocumentsRustResult,
): DocumentSearchResult[] {
  return (result.results ?? []).map((item) => ({
    id: item.id,
    title: item.title ?? "无标题",
    snippet: item.snippet,
    updatedAt: item.updatedAt ?? null,
    createdAt: item.createdAt ?? null,
    matchField: item.matchField,
    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 || "命中文档",
            },
          ],
  }));
}

async function enqueueOcrJobs(assetIds: string[]) {
  if (assetIds.length === 0) {
    return;
  }
  const { auth, client } = await getAuthedConvexClient();
  await Promise.all(
    assetIds.slice(0, 3).map((assetId) =>
      client.mutation(api.mediaAssets.enqueueExtractText, {
        userId: auth.userId,
        id: assetId,


}

async function enqueueOcrJobs(assetIds: string[]) {
  if (assetIds.length === 0) {
    return;
  }
  const { auth, client } = await getAuthedConvexClient();
  await Promise.all(
    assetIds.slice(0, 3).map((assetId) =>
      client.mutation(api.mediaAssets.enqueueExtractText, {
        userId: auth.userId,
        id: assetId,
      }).catch(() => null),
    ),
  );
}

async function loadRecentResults(input: {
  request: Request;
  workspaceId: string;
  docs: SearchDocumentRow[];
}): Promise<DocumentSearchResult[]> {
  const { client } = await getAuthedConvexClient();
  const context = await buildDocumentBridgeContext({
    request: input.request,
    workspaceId: input.workspaceId,
  });
  const envelope = buildDocumentQueryEnvelope({
    name: "search.recent",
    payload: {
      workspaceId: input.workspaceId,
      limit: 10,
      cursor: null,
    },
  });
  const recentRows = (await client.query(api.recents.listByWorkspace, {
    userId: context.actor.actorId,
    workspaceId: input.workspaceId,
    limit: 10,
  })) as RecentRow[];
  const result = await executeRustBridgeQuery<SearchRecentRustResult>({
    context,
    envelope,
    data: {
      recents: recentRows.map((row) => ({
        documentId: row.document_id,
        workspaceId: row.workspace_id,
        lastAccessedAt: row.last_accessed_at,
      })),
    },
  });

  return mapRecentRowsToResults({
    documentIds: (result.items ?? []).map((item) => item.documentId),
    docs: input.docs,
  });
}

export async function executeDocumentSearchQuery(input: {
  request: Request;


})),
    },
  });

  return mapRecentRowsToResults({
    documentIds: (result.items ?? []).map((item) => item.documentId),
    docs: input.docs,
  });
}

export async function executeDocumentSearchQuery(input: {
  request: Request;
  payload: DocumentSearchRequest;
}): Promise<{
  response: DocumentSearchResponse;
  meta: {
    requestId: string;
    traceId: string;
    queryName: string;
  };
}> {
  const workspaceId = normalizeWorkspaceId(input.payload.workspaceId);
  const normalizedQuery = normalizeQuery(input.payload.query);
  const filters = normalizeFilters(input.payload.filters);
  const limit = normalizeLimit(input.payload.limit);
  const documentId = filters.onlyCurrentPage ? normalizeDocumentId(input.payload.documentId) : null;
  const { client } = await getAuthedConvexClient();
  const context = await buildDocumentBridgeContext({
    request: input.request,
    workspaceId,
  });
  const customRangeFrom = parseDateToIso(filters.customRange?.from);
  const customRangeTo = parseDateToIso(filters.customRange?.to);

  const docs = (await client.query(api.documents.listSearchDataByWorkspace, {
    workspaceId,
  })) as SearchDocumentRow[];
  const recent = await loadRecentResults({
    request: input.request,
    workspaceId,
    docs,
  });

  if (!normalizedQuery) {
    return {
      response: {
        results: [],
        recent,
      },
      meta: {
        requestId: context.requestId,
        traceId: context.traceId,
        queryName: "search.documents",
      },
    };
  }

  const [mindmaps, tables, tableRows, assets] = (await Promise.all([
    client.query(api.mindmaps.listByWorkspace, { workspaceId, includeDeleted: false }).catch(() => []),
    client.query(api.tables.listByWorkspaceForSearch, {


},
      meta: {
        requestId: context.requestId,
        traceId: context.traceId,
        queryName: "search.documents",
      },
    };
  }

  const [mindmaps, tables, tableRows, assets] = (await Promise.all([
    client.query(api.mindmaps.listByWorkspace, { workspaceId, includeDeleted: false }).catch(() => []),
    client.query(api.tables.listByWorkspaceForSearch, {
      userId: context.actor.actorId,
      workspaceId,
      includeArchived: false,
      limit: 3000,
    }).catch(() => []),
    client.query(api.tables.listRowsByWorkspaceForSearch, {
      userId: context.actor.actorId,
      workspaceId,
      limit: 8000,
    }).catch(() => []),
    client.query(api.mediaAssets.listSearchDataByWorkspace, {
      userId: context.actor.actorId,
      workspaceId,
      includeDeleted: false,
      limit: 5000,
    }).catch(() => []),
  ])) as [SearchMindmapRow[], SearchTableRow[], SearchTableContentRow[], SearchAssetRow[]];

  const envelope = buildDocumentQueryEnvelope({
    name: "search.documents",
    payload: {
      query: normalizedQuery,
      workspaceId,
      pageId: documentId,
      limit,
      cursor: null,
      titleOnly: filters.titleOnly,
      exact: filters.exact,
      includeOcr: filters.includeOcr,
      timeRange: filters.timeRange,
      timeField: filters.timeField,
      customRangeFrom,
      customRangeTo,
    },
  });

  const result = await executeRustBridgeQuery<SearchDocumentsRustResult>({
    context,
    envelope,
    data: {
      documents: docs.map((item) => ({
        id: item.id,
        workspaceId: item.workspace_id ?? workspaceId,
        title: item.title ?? null,
        rawText: item.raw_text ?? null,
        createdAt: item.created_at ?? null,
        updatedAt: item.updated_at ?? null,
      })),


const result = await executeRustBridgeQuery<SearchDocumentsRustResult>({
    context,
    envelope,
    data: {
      documents: docs.map((item) => ({
        id: item.id,
        workspaceId: item.workspace_id ?? workspaceId,
        title: item.title ?? null,
        rawText: item.raw_text ?? null,
        createdAt: item.created_at ?? null,
        updatedAt: item.updated_at ?? null,
      })),
      mindmaps: mindmaps.map((item) => ({
        documentId: item.document_id ?? "",
        data: item.data ?? null,
      })),
      tables: tables.map((item) => ({
        id: item.id,
        documentId: item.document_id ?? "",
        title: item.title ?? null,
      })),
      tableRows: tableRows.map((item) => ({
        tableId: item.table_id ?? "",
        documentId: item.document_id ?? "",
        rowHash: item.row_hash ?? null,
      })),
      assets: assets.map((item) => ({
        id: item.id,
        documentId: item.document_id ?? "",
        assetType: item.asset_type ?? null,
        fileName: item.file_name ?? null,
        mimeType: item.mime_type ?? null,
        ocrText: item.ocr_text ?? null,
        ocrStatus: item.ocr_status ?? null,
      })),
    },
  });

  await enqueueOcrJobs(result.enqueueAssetIds ?? []);

  return {
    response: {
      results: mapRustResultsToResponse(result),
      recent,
    },
    meta: {
      requestId: context.requestId,
      traceId: context.traceId,
      queryName: envelope.name,
    },
  };
}


import { describe, expect, it } from "vitest";
import { resolveSearchOpenMode } from "./shortcuts";

describe("resolveSearchOpenMode", () => {
  it("Ctrl/Cmd+Enter 打开新窗口", () => {
    expect(resolveSearchOpenMode({ ctrlKey: true })).toBe("new-window");
    expect(resolveSearchOpenMode({ metaKey: true, altKey: true })).toBe("new-window");
  });

  it("Alt+Enter 打开右侧预览", () => {
    expect(resolveSearchOpenMode({ altKey: true })).toBe("sidebar");
  });

  it("默认回车在当前窗口打开", () => {
    expect(resolveSearchOpenMode({})).toBe("main");
  });
});


export type SearchOpenMode = "main" | "new-window" | "sidebar";

interface ShortcutLikeEvent {
  metaKey?: boolean;
  ctrlKey?: boolean;
  altKey?: boolean;
}

export const resolveSearchOpenMode = (event: ShortcutLikeEvent): SearchOpenMode => {
  if (event.metaKey || event.ctrlKey) {
    return "new-window";
  }
  if (event.altKey) {
    return "sidebar";
  }
  return "main";
};


import { describe, expect, it } from "vitest";
import { buildSnippet } from "./snippet";

describe("buildSnippet", () => {
  it("命中关键字时高亮 OCR 片段", () => {
    const snippet = buildSnippet("图像 OCR 测试文本，用于高亮", "OCR");
    expect(snippet).toContain("<mark>OCR</mark>");
  });

  it("无关键字时返回截断文本", () => {
    const snippet = buildSnippet("一段很长的文字用于测试截断逻辑", null);
    expect(snippet.endsWith("…") || snippet.length <= 120).toBe(true);
  });
});


const escapeHtml = (value: string): string =>
  value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");

const escapeRegExp = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");

export const buildSnippet = (text: string | null, keyword: string | null): string => {
  const source = (text ?? "").trim();
  if (!source) {
    return "暂无正文内容";
  }
  const safeSource = escapeHtml(source);
  if (!keyword) {
    return `${safeSource.slice(0, 120)}${safeSource.length > 120 ? "…" : ""}`;
  }
  const pattern = new RegExp(escapeRegExp(keyword), "gi");
  const match = pattern.exec(safeSource);
  if (!match) {
    return `${safeSource.slice(0, 120)}${safeSource.length > 120 ? "…" : ""}`;
  }
  const start = Math.max(0, match.index - 20);
  const end = Math.min(safeSource.length, match.index + keyword.length + 80);
  const segment = safeSource.slice(start, end);
  return segment.replace(pattern, (found) => `<mark>${found}</mark>`);
};


import { describe, expect, it } from "vitest";
import {
  buildOnlyOfficeAssetOpenUrl,
  buildOnlyOfficeCallbackUrl,
  buildOnlyOfficeForcesaveUrl,
  buildOnlyOfficeOpenFileId,
  docTypeFromExt,
  inferOnlyOfficeFileType,
  resolveOnlyOfficeDocumentUrl,
} from "@/lib/onlyoffice/client-session";

describe("onlyoffice client session helpers", () => {
  it("docTypeFromExt maps office extensions", () => {
    expect(docTypeFromExt("docx")).toBe("word");
    expect(docTypeFromExt("xlsx")).toBe("cell");
    expect(docTypeFromExt("pptx")).toBe("slide");
    expect(docTypeFromExt("pdf")).toBe("word");
  });

  it("inferOnlyOfficeFileType detects only true Office assets from file name and MIME", () => {
    expect(inferOnlyOfficeFileType("demo.pptx", null)).toBe("pptx");
    expect(inferOnlyOfficeFileType("demo", "application/vnd.openxmlformats-officedocument.wordprocessingml.document")).toBe("docx");
    expect(inferOnlyOfficeFileType("demo", "application/pdf")).toBeNull();
    expect(inferOnlyOfficeFileType("demo.png", "image/png")).toBeNull();
    expect(inferOnlyOfficeFileType("demo.pdf", null)).toBeNull();
    expect(inferOnlyOfficeFileType("demo.toml", null)).toBeNull();
    expect(inferOnlyOfficeFileType("demo.json", null)).toBeNull();
    expect(inferOnlyOfficeFileType("demo.yaml", null)).toBeNull();
    expect(inferOnlyOfficeFileType("demo.yml", null)).toBeNull();
    expect(inferOnlyOfficeFileType("demo.md", null)).toBeNull();
    expect(inferOnlyOfficeFileType("demo.txt", null)).toBeNull();
    expect(inferOnlyOfficeFileType("demo.ts", null)).toBeNull();
    expect(inferOnlyOfficeFileType("demo.js", null)).toBeNull();
    expect(inferOnlyOfficeFileType("demo.py", null)).toBeNull();
    expect(inferOnlyOfficeFileType("demo.rs", null)).toBeNull();
    expect(inferOnlyOfficeFileType("demo.go", null)).toBeNull();
    expect(inferOnlyOfficeFileType("demo.html", null)).toBeNull();
    expect(inferOnlyOfficeFileType("demo.css", null)).toBeNull();
    expect(inferOnlyOfficeFileType("demo.vue", null)).toBeNull();
    expect(inferOnlyOfficeFileType("demo.svelte", null)).toBeNull();
    expect(inferOnlyOfficeFileType("demo.proto", null)).toBeNull();
    expect(inferOnlyOfficeFileType("demo.graphql", null)).toBeNull();
    expect(inferOnlyOfficeFileType("demo.gradle", null)).toBeNull();
    expect(inferOnlyOfficeFileType("demo.tf", null)).toBeNull();
    expect(inferOnlyOfficeFileType("demo.pdf", "application/vnd.openxmlformats-officedocument.wordprocessingml.document")).toBeNull();
    expect(inferOnlyOfficeFileType("Dockerfile", "application/vnd.openxmlformats-officedocument.wordprocessingml.document")).toBeNull();
    expect(inferOnlyOfficeFileType(".gitignore", "application/vnd.openxmlformats-officedocument.wordprocessingml.document")).toBeNull();
  });

  it("buildOnlyOfficeAssetOpenUrl keeps attachment identity for callback writeback", () => {
    const result = buildOnlyOfficeAssetOpenUrl({
      origin: "https://mnote.example.com",
      fileUrl: "https://storage.example.com/demo.pptx?token=abc",
      fileName: "2023自我介绍PPT_李爱波0831.pptx",
      fileType: "pptx",
      assetId: "asset_ppt_1",
      documentId: "doc_1",
      mode: "edit",
    });


it("buildOnlyOfficeAssetOpenUrl keeps attachment identity for callback writeback", () => {
    const result = buildOnlyOfficeAssetOpenUrl({
      origin: "https://mnote.example.com",
      fileUrl: "https://storage.example.com/demo.pptx?token=abc",
      fileName: "2023自我介绍PPT_李爱波0831.pptx",
      fileType: "pptx",
      assetId: "asset_ppt_1",
      documentId: "doc_1",
      mode: "edit",
    });

    expect(result).toBe(
      "https://mnote.example.com/onlyoffice?fileUrl=https%3A%2F%2Fstorage.example.com%2Fdemo.pptx%3Ftoken%3Dabc&fileName=2023%E8%87%AA%E6%88%91%E4%BB%8B%E7%BB%8DPPT_%E6%9D%8E%E7%88%B1%E6%B3%A20831.pptx&fileType=pptx&assetId=asset_ppt_1&documentId=doc_1&mode=edit",
    );
  });

  it("resolveOnlyOfficeDocumentUrl wraps signed url with proxy", () => {
    const result = resolveOnlyOfficeDocumentUrl({
      effectiveFileUrl: "https://public.example.com/storage/v1/object/sign/documents/a.docx?token=abc",
      proxyOrigin: "https://app.example.com",
      storageHostOverride: "",
      runtimeSupabaseUrl: "https://public.example.com",
      useConvex: false,
    });
    expect(result).toContain("https://app.example.com/api/onlyoffice/proxy?u=");
  });

  it("buildOnlyOfficeCallbackUrl keeps asset and user", () => {
    const result = buildOnlyOfficeCallbackUrl({
      callbackOrigin: "http://host.docker.internal:3000",
      proxyOrigin: "",
      windowOrigin: "https://app.example.com",
      assetId: "asset_1",
      userId: "user_1",
    });
    expect(result).toBe(
      "http://host.docker.internal:3000/api/onlyoffice/callback?assetId=asset_1&userId=user_1",
    );
  });

  it("buildOnlyOfficeForcesaveUrl keeps key", () => {
    const result = buildOnlyOfficeForcesaveUrl({
      windowOrigin: "https://app.example.com",
      assetId: "asset_1",
      key: "doc_key_1",
    });
    expect(result).toBe(
      "https://app.example.com/api/onlyoffice/forcesave?assetId=asset_1&key=doc_key_1",
    );
  });

  it("buildOnlyOfficeOpenFileId prefers asset id", () => {
    expect(
      buildOnlyOfficeOpenFileId({
        assetId: "asset_1",
        docKey: "doc_key_1",
        resolvedFileUrl: "https://app.example.com/file.docx",
        fileName: "file.docx",
      }),


"https://app.example.com/api/onlyoffice/forcesave?assetId=asset_1&key=doc_key_1",
    );
  });

  it("buildOnlyOfficeOpenFileId prefers asset id", () => {
    expect(
      buildOnlyOfficeOpenFileId({
        assetId: "asset_1",
        docKey: "doc_key_1",
        resolvedFileUrl: "https://app.example.com/file.docx",
        fileName: "file.docx",
      }),
    ).toBe("asset_1");
  });
});


import { rewriteToPublicOrigin } from "@/lib/url/rewritePublicOrigin";

export const hashOnlyOfficeKey = (input: string) => {
  let hash = 0;
  for (let i = 0; i < input.length; i += 1) {
    hash = (hash << 5) - hash + input.charCodeAt(i);
    hash |= 0;
  }
  return Math.abs(hash).toString();
};

export const base64UrlEncodeUtf8 = (input: string) => {
  const bytes = new TextEncoder().encode(input);
  let binary = "";
  for (let i = 0; i < bytes.length; i += 1) {
    binary += String.fromCharCode(bytes[i] as number);
  }
  return btoa(binary).replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_");
};

export const docTypeFromExt = (ext: string) => {
  const word = ["doc", "docx", "odt", "rtf"];
  const slide = ["ppt", "pptx", "odp"];
  const sheet = ["xls", "xlsx", "ods", "csv"];
  if (word.includes(ext)) return "word";
  if (slide.includes(ext)) return "slide";
  if (sheet.includes(ext)) return "cell";
  return "word";
};

export const inferOnlyOfficeFileType = (fileName: string | null | undefined, mimeType: string | null | undefined) => {
  const name = (fileName ?? "").trim().toLowerCase();
  const mt = (mimeType ?? "").trim().toLowerCase();
  const ext = name.includes(".") ? name.split(".").pop() ?? "" : "";
  const officeExts = ["doc", "docx", "odt", "rtf", "ppt", "pptx", "odp", "xls", "xlsx", "ods", "csv"];
  const nonOfficeFileNames = [
    ".dockerignore",
    ".editorconfig",
    ".env",
    ".eslintrc",
    ".gitattributes",
    ".gitignore",
    ".npmrc",
    ".prettierrc",
    "cmakelists.txt",
    "dockerfile",
    "gemfile",
    "makefile",
    "procfile",
    "rakefile",
  ];
  const nonOfficeExts = [
    "pdf",
    "toml",
    "json",
    "jsonc",
    "json5",
    "yaml",
    "yml",
    "md",


"procfile",
    "rakefile",
  ];
  const nonOfficeExts = [
    "pdf",
    "toml",
    "json",
    "jsonc",
    "json5",
    "yaml",
    "yml",
    "md",
    "markdown",
    "txt",
    "ini",
    "env",
    "xml",
    "ts",
    "tsx",
    "js",
    "jsx",
    "mjs",
    "cjs",
    "py",
    "rs",
    "go",
    "html",
    "htm",
    "css",
    "scss",
    "less",
    "vue",
    "svelte",
    "astro",
    "java",
    "c",
    "cpp",
    "h",
    "hpp",
    "cs",
    "php",
    "rb",
    "sh",
    "bash",
    "zsh",
    "sql",
    "lock",
    "log",
    "proto",
    "graphql",
    "gql",
    "prisma",
    "tf",
    "tfvars",
    "hcl",
    "nix",
    "gradle",
  ];
  if (officeExts.includes(ext)) return ext;
  if (nonOfficeFileNames.includes(name)) return null;


"proto",
    "graphql",
    "gql",
    "prisma",
    "tf",
    "tfvars",
    "hcl",
    "nix",
    "gradle",
  ];
  if (officeExts.includes(ext)) return ext;
  if (nonOfficeFileNames.includes(name)) return null;
  if (nonOfficeExts.includes(ext)) return null;
  if (mt.includes("wordprocessingml")) return "docx";
  if (mt.includes("presentationml")) return "pptx";
  if (mt.includes("spreadsheetml")) return "xlsx";
  return null;
};

export function buildOnlyOfficeAssetOpenUrl(input: {
  origin: string;
  fileUrl: string;
  fileName: string;
  fileType: string;
  assetId?: string | null;
  documentId?: string | null;
  mode?: "view" | "edit";
}): string {
  const target = new URL("/onlyoffice", input.origin);
  target.searchParams.set("fileUrl", input.fileUrl);
  target.searchParams.set("fileName", input.fileName);
  target.searchParams.set("fileType", input.fileType);
  if (input.assetId) target.searchParams.set("assetId", input.assetId);
  if (input.documentId) target.searchParams.set("documentId", input.documentId);
  target.searchParams.set("mode", input.mode ?? "edit");
  return target.toString();
}

export function resolveOnlyOfficeDocumentUrl(input: {
  effectiveFileUrl: string;
  proxyOrigin: string;
  storageHostOverride: string;
  runtimeSupabaseUrl: string;
  useConvex: boolean;
}): string {
  const { effectiveFileUrl, proxyOrigin, storageHostOverride, runtimeSupabaseUrl, useConvex } = input;
  if (!effectiveFileUrl) return "";

  const isConvexStorageUrl = (() => {
    try {
      const u = new URL(effectiveFileUrl);
      return u.pathname.startsWith("/api/storage/");
    } catch {
      return false;
    }
  })();

  let base =
    storageHostOverride || useConvex || isConvexStorageUrl
      ? effectiveFileUrl


const isConvexStorageUrl = (() => {
    try {
      const u = new URL(effectiveFileUrl);
      return u.pathname.startsWith("/api/storage/");
    } catch {
      return false;
    }
  })();

  let base =
    storageHostOverride || useConvex || isConvexStorageUrl
      ? effectiveFileUrl
      : rewriteToPublicOrigin(effectiveFileUrl, runtimeSupabaseUrl);
  try {
    const raw = new URL(base);
    let alreadyProxy = raw.pathname.includes("/api/onlyoffice/proxy");
    const isLocalHost =
      raw.hostname === "127.0.0.1" || raw.hostname === "localhost" || raw.hostname === "host.docker.internal";

    if (alreadyProxy && proxyOrigin) {
      const po = new URL(proxyOrigin);
      raw.protocol = po.protocol;
      raw.host = po.host;
      base = raw.toString();
    }

    if (!alreadyProxy && proxyOrigin && isLocalHost) {
      const proxy = new URL("/api/onlyoffice/proxy", proxyOrigin || window.location.origin);
      proxy.searchParams.set("u", base64UrlEncodeUtf8(base));
      base = proxy.toString();
      alreadyProxy = true;
    }
    if (!alreadyProxy && raw.searchParams.has("token")) {
      const proxy = new URL("/api/onlyoffice/proxy", proxyOrigin || window.location.origin);
      proxy.searchParams.set("u", base64UrlEncodeUtf8(base));
      base = proxy.toString();
      alreadyProxy = true;
    }

    const u = new URL(base);
    if (!storageHostOverride || alreadyProxy) return u.toString();

    if (/^https?:\/\//i.test(storageHostOverride)) {
      const ov = new URL(storageHostOverride);
      u.protocol = ov.protocol;
      u.host = ov.host;
      return u.toString();
    }

    u.hostname = storageHostOverride;
    return u.toString();
  } catch {
    return base;
  }
}

export function buildOnlyOfficeCallbackUrl(input: {
  callbackOrigin: string;
  proxyOrigin: string;
  windowOrigin: string;


u.hostname = storageHostOverride;
    return u.toString();
  } catch {
    return base;
  }
}

export function buildOnlyOfficeCallbackUrl(input: {
  callbackOrigin: string;
  proxyOrigin: string;
  windowOrigin: string;
  assetId: string;
  userId: string;
}): string {
  const base = input.callbackOrigin || input.proxyOrigin || input.windowOrigin;
  const callback = new URL("/api/onlyoffice/callback", base);
  if (input.assetId) callback.searchParams.set("assetId", input.assetId);
  if (input.userId) callback.searchParams.set("userId", input.userId);
  return callback.toString();
}

export function buildOnlyOfficeForcesaveUrl(input: {
  windowOrigin: string;
  assetId: string;
  key: string;
}): string {
  const url = new URL("/api/onlyoffice/forcesave", input.windowOrigin);
  url.searchParams.set("assetId", input.assetId);
  url.searchParams.set("key", input.key);
  return url.toString();
}

export function buildOnlyOfficeOpenFileId(input: {
  assetId: string;
  docKey: string;
  resolvedFileUrl: string;
  fileName: string;
}): string {
  return input.assetId || `onlyoffice_${input.docKey || hashOnlyOfficeKey(`${input.resolvedFileUrl}-${input.fileName}`)}`;
}


const DEFAULT_ONLYOFFICE_INTERNAL_URL = "http://127.0.0.1:8082";
const ONLYOFFICE_PROBE_PATH = "/web-apps/apps/api/documents/api.js";
const RESOLVE_CACHE_TTL_MS = 30_000;

const DEFAULT_ONLYOFFICE_INTERNAL_URL_CANDIDATES = [
  DEFAULT_ONLYOFFICE_INTERNAL_URL,
  "http://127.0.0.1:8081",
  "http://localhost:8082",
  "http://localhost:8081",
];

let cachedOnlyOfficeInternalUrl = "";
let cachedOnlyOfficeInternalUrlAt = 0;
let pendingOnlyOfficeInternalUrl: Promise<string> | null = null;

const normalizeOnlyOfficeInternalUrl = (raw?: string | null) => {
  const value = String(raw || "").trim().replace(/\/+$/, "");
  if (!value) return "";
  try {
    const url = new URL(value);
    if (url.protocol !== "http:" && url.protocol !== "https:") return "";
    return url.toString().replace(/\/+$/, "");
  } catch {
    return "";
  }
};

const getOnlyOfficeInternalUrlCandidates = () => {
  const candidates: string[] = [];
  const push = (value?: string | null) => {
    const normalized = normalizeOnlyOfficeInternalUrl(value);
    if (!normalized) return;
    if (!candidates.includes(normalized)) candidates.push(normalized);
  };

  push(process.env.ONLYOFFICE_INTERNAL_URL);

  for (const raw of String(process.env.ONLYOFFICE_INTERNAL_URL_CANDIDATES || "").split(",")) {
    push(raw);
  }

  for (const value of DEFAULT_ONLYOFFICE_INTERNAL_URL_CANDIDATES) {
    push(value);
  }

  return candidates.length > 0 ? candidates : [DEFAULT_ONLYOFFICE_INTERNAL_URL];
};

const probeOnlyOfficeInternalUrl = async (candidate: string) => {
  try {
    const probeUrl = new URL(ONLYOFFICE_PROBE_PATH, `${candidate}/`);
    const response = await fetch(probeUrl, {
      method: "HEAD",
      redirect: "follow",
      cache: "no-store",
      signal: AbortSignal.timeout(2_500),
    });
    return response.ok;
  } catch {
    return false;


const probeOnlyOfficeInternalUrl = async (candidate: string) => {
  try {
    const probeUrl = new URL(ONLYOFFICE_PROBE_PATH, `${candidate}/`);
    const response = await fetch(probeUrl, {
      method: "HEAD",
      redirect: "follow",
      cache: "no-store",
      signal: AbortSignal.timeout(2_500),
    });
    return response.ok;
  } catch {
    return false;
  }
};

export const resolveOnlyOfficeInternalUrl = async () => {
  const now = Date.now();
  if (cachedOnlyOfficeInternalUrl && now - cachedOnlyOfficeInternalUrlAt < RESOLVE_CACHE_TTL_MS) {
    return cachedOnlyOfficeInternalUrl;
  }

  if (pendingOnlyOfficeInternalUrl) {
    return pendingOnlyOfficeInternalUrl;
  }

  pendingOnlyOfficeInternalUrl = (async () => {
    const candidates = getOnlyOfficeInternalUrlCandidates();
    for (const candidate of candidates) {
      if (await probeOnlyOfficeInternalUrl(candidate)) {
        return candidate;
      }
    }
    return candidates[0] || DEFAULT_ONLYOFFICE_INTERNAL_URL;
  })();

  try {
    const resolved = await pendingOnlyOfficeInternalUrl;
    cachedOnlyOfficeInternalUrl = resolved;
    cachedOnlyOfficeInternalUrlAt = Date.now();
    return resolved;
  } finally {
    pendingOnlyOfficeInternalUrl = null;
  }
};


import type { MnoteRuntimeConfig } from "@/lib/runtime-config";
import {
  buildDocumentBridgeContextWithActor,
  type BridgeContext,
  type BridgeTarget,
} from "@/lib/documents/bridge";
import { executeRustBridgeTool } from "@/lib/documents/rust-runtime";

type OnlyOfficeHeader = {
  name: string;
  value: string;
};

type OnlyOfficeServiceActorInput = {
  actorId?: string;
  actorType?: string;
  sessionId?: string | null;
  workspaceId?: string | null;
  sourceChannel: string;
  sourceClient: string;
  idempotencyKey?: string | null;
};

export type OnlyOfficeSignResult = {
  token: string | null;
  documentToken: string | null;
  editorConfigToken: string | null;
};

export type OnlyOfficeProxyPreparationResult = {
  targetUrl: string;
  forwardHeaders: OnlyOfficeHeader[];
};

export type OnlyOfficeCallbackPreparationResult = {
  shouldWrite: boolean;
  downloadUrl: string | null;
  idempotencyKey: string | null;
  locator: {
    assetId: string;
    workspaceId: string | null;
    documentId: string | null;
  };
  session: {
    sessionId: string;
    assetId: string;
    workspaceId: string | null;
    documentId: string | null;
    userId: string | null;
  };
};

export type OnlyOfficeForcesavePreparationResult = {
  requests: Array<{
    via: string;
    url: string;
    method: string;
    headers: OnlyOfficeHeader[];
    bodyJson: string;
  }>;