Explorar el Código

refactor(cleanup): 收口测试专用旧模块和未使用导出

darknessomi hace 2 semanas
padre
commit
01aab5ff44
Se han modificado 100 ficheros con 116 adiciones y 3837 borrados
  1. 2 23
      src/commands/file-sync.ts
  2. 1 20
      src/commands/fs.ts
  3. 0 36
      src/components/chat/chat-input-resize.spec.ts
  4. 0 161
      src/components/chat/chat-input-resize.ts
  5. 0 377
      src/components/chat/chat-input.resize-interaction.spec.tsx
  6. 0 401
      src/components/chat/chat-input.tsx
  7. 1 1
      src/components/chat/chat-model-selector.tsx
  8. 0 6
      src/components/chat/chat-panel.tsx
  9. 1 1
      src/components/chat/chat-resume.ts
  10. 1 5
      src/components/chat/chat-shared.ts
  11. 1 1
      src/components/chat/context-usage-ring.tsx
  12. 1 1
      src/components/chat/stream-session.ts
  13. 1 1
      src/components/chat/tool-call-timeline.tsx
  14. 1 1
      src/components/common/ai-change-compare-panel.tsx
  15. 0 147
      src/components/common/ai-change-review.spec.tsx
  16. 0 200
      src/components/common/ai-change-review.tsx
  17. 2 2
      src/components/common/context-hub-details.tsx
  18. 2 2
      src/components/common/context-hub-stats-summary.tsx
  19. 1 1
      src/components/common/conversation-run-status-icon.tsx
  20. 1 8
      src/components/common/timeline-types.ts
  21. 1 1
      src/components/editor/textarea-find-bar.tsx
  22. 1 1
      src/components/editor/textarea-find-highlights.tsx
  23. 0 6
      src/components/layout/preview-panel.tsx
  24. 0 4
      src/components/novel/book-analysis-import-task-panel.tsx
  25. 1 1
      src/components/novel/book-analysis-module-view.tsx
  26. 1 1
      src/components/novel/character-selection-panel.tsx
  27. 1 1
      src/components/novel/de-ai-batch-review-dialog.tsx
  28. 1 1
      src/components/novel/hooks/use-character-extraction.ts
  29. 1 1
      src/components/novel/hooks/use-character-recognition.ts
  30. 1 1
      src/components/novel/hooks/use-library-operations.ts
  31. 1 1
      src/components/settings/context-size-selector.tsx
  32. 2 2
      src/components/settings/hooks/use-batch-model-test.ts
  33. 0 32
      src/components/settings/llm-presets.ts
  34. 1 1
      src/components/settings/model-select-input.tsx
  35. 1 1
      src/components/settings/output-tokens-selector.tsx
  36. 1 1
      src/components/settings/sections/custom-provider-cards.tsx
  37. 2 2
      src/components/skill-library/de-ai-skill-errors.ts
  38. 1 1
      src/components/skill-library/de-ai-skill-picker.tsx
  39. 0 132
      src/components/skill-library/unified-skill-model.spec.ts
  40. 0 169
      src/components/skill-library/unified-skill-model.ts
  41. 0 1
      src/components/sources/outline-chat-panel.spec.tsx
  42. 0 48
      src/components/sources/outline-chat-panel.tsx
  43. 0 434
      src/components/sources/outline-file-tree-panel.spec.tsx
  44. 0 395
      src/components/sources/outline-file-tree-panel.tsx
  45. 0 340
      src/components/sources/outline-file-tree.spec.tsx
  46. 0 618
      src/components/sources/outline-file-tree.tsx
  47. 1 1
      src/components/ui/button.tsx
  48. 0 12
      src/components/ui/dialog.tsx
  49. 1 1
      src/components/ui/scroll-area.tsx
  50. 6 6
      src/lib/agent/activity-trace.ts
  51. 2 2
      src/lib/agent/ai-chat-session.ts
  52. 1 1
      src/lib/agent/capabilities/registry.ts
  53. 1 1
      src/lib/agent/capabilities/selector.ts
  54. 2 2
      src/lib/agent/config.ts
  55. 1 21
      src/lib/agent/context-trace.ts
  56. 2 2
      src/lib/agent/novel-pre-plugin-chain.ts
  57. 1 1
      src/lib/agent/pipeline.ts
  58. 1 1
      src/lib/agent/plugins/build-system-prompt-plugin.ts
  59. 2 2
      src/lib/agent/plugins/confidence-gate-plugin.ts
  60. 1 1
      src/lib/agent/plugins/post-write-check-ai.ts
  61. 1 1
      src/lib/agent/plugins/post-write-check-plugin.ts
  62. 1 1
      src/lib/agent/plugins/resolve-chapter-plugin.ts
  63. 1 1
      src/lib/agent/plugins/route-task-plugin.ts
  64. 1 1
      src/lib/agent/plugins/soul-dialog-plugin.ts
  65. 1 1
      src/lib/agent/plugins/trim-context-plugin.ts
  66. 1 1
      src/lib/agent/required-tool-fallback.ts
  67. 2 2
      src/lib/agent/required-tools-gate.ts
  68. 1 1
      src/lib/agent/skills/draft-review-skill.ts
  69. 2 2
      src/lib/agent/tool-events.ts
  70. 1 1
      src/lib/agent/tool-executor.ts
  71. 1 1
      src/lib/agent/tools/list-chapters.ts
  72. 1 1
      src/lib/agent/tools/list-outlines.ts
  73. 0 53
      src/lib/agent/tools/mcp-tool.spec.ts
  74. 0 1
      src/lib/agent/tools/mcp-tool.ts
  75. 0 9
      src/lib/agent/tools/outline-list-helpers.ts
  76. 2 2
      src/lib/agent/tools/read-markdown-resource.ts
  77. 1 1
      src/lib/agent/tools/read-web-page.ts
  78. 1 1
      src/lib/agent/tools/run-chapter-workflow.ts
  79. 1 1
      src/lib/agent/tools/summarize-search-results.ts
  80. 1 1
      src/lib/agent/tools/web-search.ts
  81. 1 1
      src/lib/agent/tools/write-chapter.ts
  82. 4 4
      src/lib/agent/workflow-trace.ts
  83. 1 1
      src/lib/app-title.ts
  84. 1 1
      src/lib/app-update-support.ts
  85. 1 1
      src/lib/app-updater.ts
  86. 1 1
      src/lib/async-pool.ts
  87. 2 2
      src/lib/azure-openai.ts
  88. 0 9
      src/lib/backup/types.ts
  89. 2 2
      src/lib/changelog.ts
  90. 0 4
      src/lib/chapter-display.ts
  91. 1 2
      src/lib/chapter-external-update-coordinator.ts
  92. 3 3
      src/lib/chat-copy-content.ts
  93. 2 2
      src/lib/claude-cli-transport.ts
  94. 3 3
      src/lib/codex-app-server-client.ts
  95. 1 1
      src/lib/codex-cli-model.ts
  96. 1 1
      src/lib/codex-cli-timeout.ts
  97. 2 56
      src/lib/connection-tests.ts
  98. 10 10
      src/lib/context-budget.ts
  99. 3 3
      src/lib/context-hub/chapter-body-injection.ts
  100. 2 2
      src/lib/context-hub/composer.ts

+ 2 - 23
src/commands/file-sync.ts

@@ -22,33 +22,16 @@ export interface FileChangeTask {
   needsRerun: boolean
 }
 
-export interface FileChangeQueue {
+interface FileChangeQueue {
   version: number
   tasks: FileChangeTask[]
 }
 
-export interface FileChangeRescanResult {
+interface FileChangeRescanResult {
   queue: FileChangeQueue
   changedTasks: FileChangeTask[]
 }
 
-export interface FileSyncPayload {
-  projectId: string
-  tasks: FileChangeTask[]
-}
-
-export function startProjectFileWatcher(
-  projectId: string,
-  projectPath: string,
-  sourceWatchConfig?: SourceWatchConfig,
-): Promise<FileChangeQueue> {
-  return invoke<FileChangeQueue>("start_project_file_watcher", {
-    projectId,
-    projectPath,
-    sourceWatchConfig: normalizeSourceWatchConfig(sourceWatchConfig),
-  })
-}
-
 export function stopProjectFileWatcher(): Promise<void> {
   return invoke<void>("stop_project_file_watcher")
 }
@@ -65,10 +48,6 @@ export function rescanProjectFiles(
   })
 }
 
-export function getFileChangeQueue(projectPath: string): Promise<FileChangeQueue> {
-  return invoke<FileChangeQueue>("get_file_change_queue", { projectPath })
-}
-
 export function retryFileChangeTask(
   projectId: string,
   projectPath: string,

+ 1 - 20
src/commands/fs.ts

@@ -122,22 +122,11 @@ export async function copyDirectory(
   return invoke<string[]>("copy_directory", { source, destination })
 }
 
-export async function preprocessFile(path: string): Promise<string> {
-  return invoke<string>("preprocess_file", { path })
-}
-
 export async function deleteFile(path: string): Promise<void> {
   await invoke("delete_file", { path })
   notifyProjectFileMutation({ type: "delete", path })
 }
 
-export async function findRelatedWikiPages(
-  projectPath: string,
-  sourceName: string
-): Promise<string[]> {
-  return invoke<string[]>("find_related_wiki_pages", { projectPath, sourceName })
-}
-
 export async function createDirectory(path: string): Promise<void> {
   return invoke<void>("create_directory", { path })
 }
@@ -146,10 +135,6 @@ export async function fileExists(path: string): Promise<boolean> {
   return invoke<boolean>("file_exists", { path })
 }
 
-export async function getFileModifiedTime(path: string): Promise<number> {
-  return invoke<number>("get_file_modified_time", { path })
-}
-
 export async function getFileSize(path: string): Promise<number> {
   return invoke<number>("get_file_size", { path })
 }
@@ -158,7 +143,7 @@ export async function getFileMd5(path: string): Promise<string> {
   return invoke<string>("get_file_md5", { path })
 }
 
-export interface FileBase64 {
+interface FileBase64 {
   base64: string
   mimeType: string
 }
@@ -184,10 +169,6 @@ export async function openProject(path: string): Promise<WikiProject> {
   return { id, name: raw.name, path: raw.path }
 }
 
-export async function openProjectFolder(path: string): Promise<void> {
-  return invoke<void>("open_project_folder", { path })
-}
-
 export async function openFileLocation(path: string): Promise<void> {
   return invoke<void>("open_file_location", { path })
 }

+ 0 - 36
src/components/chat/chat-input-resize.spec.ts

@@ -1,36 +0,0 @@
-import { describe, expect, it } from "vitest"
-import { clampResizableInputHeight, getResizeBoundsForElement, resolveMaxHeightFromContext, DEFAULT_RESIZABLE_INPUT_HEIGHT } from "./chat-input-resize"
-
-describe("chat input resize bounds", () => {
-  it("keeps the input height between the default height and max height", () => {
-    expect(clampResizableInputHeight(20, { minHeight: 44, maxHeight: 300 })).toBe(44)
-    expect(clampResizableInputHeight(180, { minHeight: 44, maxHeight: 300 })).toBe(180)
-    expect(clampResizableInputHeight(500, { minHeight: 44, maxHeight: 300 })).toBe(300)
-  })
-
-  it("clamps invalid finite values to min height", () => {
-    expect(clampResizableInputHeight(Number.NaN, { minHeight: 44, maxHeight: 300 })).toBe(44)
-    expect(clampResizableInputHeight(Number.POSITIVE_INFINITY, { minHeight: 44, maxHeight: 300 })).toBe(300)
-  })
-
-  it("returns safe defaults when element is null", () => {
-    const bounds = getResizeBoundsForElement(null)
-    expect(bounds.minHeight).toBe(DEFAULT_RESIZABLE_INPUT_HEIGHT)
-    expect(bounds.maxHeight).toBeGreaterThanOrEqual(DEFAULT_RESIZABLE_INPUT_HEIGHT)
-  })
-
-  it("predicts max height from context allowing upward expansion", () => {
-    const ctx = {
-      startRootTop: 680,
-      startRootHeight: 120,
-      startInputHeight: 44,
-      viewportHeight: 800,
-      containerHeight: 700,
-      fixedOverhead: 76,
-    }
-    const initialMax = resolveMaxHeightFromContext(ctx, 44)
-    expect(initialMax).toBe(44)
-    const expandedMax = resolveMaxHeightFromContext(ctx, 200)
-    expect(expandedMax).toBeGreaterThan(initialMax)
-  })
-})

+ 0 - 161
src/components/chat/chat-input-resize.ts

@@ -1,161 +0,0 @@
-export const DEFAULT_RESIZABLE_INPUT_HEIGHT = 44
-const INPUT_BOTTOM_RESERVED = 80
-const MIN_MESSAGE_AREA_MIN = 80
-const MIN_MESSAGE_AREA_RATIO = 0.15
-const MIN_USABLE_CONTAINER_HEIGHT = DEFAULT_RESIZABLE_INPUT_HEIGHT + MIN_MESSAGE_AREA_MIN + 80
-
-export interface ResizableInputBounds {
-  minHeight: number
-  maxHeight: number
-}
-
-export interface ResizeContext {
-  startRootTop: number
-  startRootHeight: number
-  startInputHeight: number
-  viewportHeight: number
-  containerHeight: number
-  fixedOverhead: number
-}
-
-function computeMinMessageAreaHeight(containerHeight: number): number {
-  return Math.max(MIN_MESSAGE_AREA_MIN, Math.floor(containerHeight * MIN_MESSAGE_AREA_RATIO))
-}
-
-function getEffectiveRootRect(rootEl: HTMLDivElement): DOMRect {
-  const rootRect = rootEl.getBoundingClientRect()
-  if (rootRect.height > 0) return rootRect
-  const parentRect = rootEl.parentElement?.getBoundingClientRect()
-  return parentRect && parentRect.height > 0 && parentRect.height < MIN_USABLE_CONTAINER_HEIGHT
-    ? parentRect
-    : rootRect
-}
-
-export function createResizeContext(rootEl: HTMLDivElement | null, currentInputHeight: number): ResizeContext | null {
-  if (!rootEl) return null
-  const rootRect = getEffectiveRootRect(rootEl)
-  const containerEl = findChatContainer(rootEl)
-  const containerRect = containerEl?.getBoundingClientRect()
-  const containerHeight = containerRect?.height ?? window.innerHeight
-  const textareaEl = rootEl.querySelector("textarea")
-  const textareaRect = textareaEl?.getBoundingClientRect()
-  const actualTextareaHeight = textareaRect && textareaRect.height > 0 ? textareaRect.height : currentInputHeight
-  const chatInputOverhead = Math.max(0, rootRect.height - actualTextareaHeight)
-  const siblingOverhead = computeSiblingOverhead(rootEl, containerEl)
-  return {
-    startRootTop: rootRect.top,
-    startRootHeight: rootRect.height,
-    startInputHeight: currentInputHeight,
-    viewportHeight: window.innerHeight,
-    containerHeight,
-    fixedOverhead: chatInputOverhead + siblingOverhead,
-  }
-}
-
-export function findChatContainer(root: HTMLDivElement): HTMLElement | null {
-  let current: HTMLElement | null = root.parentElement
-  let fallback: HTMLElement | null = null
-  let sizeFallback: HTMLElement | null = null
-  for (let i = 0; i < 8 && current; i++) {
-    const tag = current.tagName
-    if (tag === "BODY" || tag === "HTML") break
-    const rect = current.getBoundingClientRect()
-    const hasUsableHeight = rect.height >= MIN_USABLE_CONTAINER_HEIGHT
-    if (!sizeFallback && hasUsableHeight) {
-      sizeFallback = current
-    }
-    const style = window.getComputedStyle(current)
-    const isFlexColumn = style.display === "flex" && style.flexDirection === "column"
-    if (isFlexColumn) {
-      const hasConstrainedHeight = style.height !== "auto" && style.height !== ""
-      const hasOverflowClip = style.overflowY === "hidden" || style.overflowY === "auto" || style.overflowY === "scroll"
-      if ((hasConstrainedHeight || hasOverflowClip) && hasUsableHeight) {
-        return current
-      }
-      if (!fallback && hasUsableHeight) {
-        fallback = current
-      }
-    }
-    current = current.parentElement
-  }
-  return fallback ?? sizeFallback ?? root.parentElement
-}
-
-/**
- * 计算 ChatInput 根 div 之前的所有非 flex-grow 兄弟元素总高度。
- * 用于在 maxHeight 计算中扣除 Header、Section 按钮区等外层固定开销,
- * 防止 textarea 被允许拉高到超出容器实际可用空间。
- */
-function computeSiblingOverhead(rootEl: HTMLDivElement, container: HTMLElement | null): number {
-  if (!container) return 0
-  let overhead = 0
-  let found = false
-  for (const child of Array.from(container.children)) {
-    if (child === rootEl || (child as HTMLElement).contains(rootEl)) {
-      found = true
-      break
-    }
-    const style = window.getComputedStyle(child as HTMLElement)
-    const flexGrow = parseFloat(style.flexGrow || "0")
-    // 跳过 flex-grow > 0 的元素(如 messages 区域 flex-1,会自适应)
-    if (flexGrow > 0) continue
-    const rect = (child as HTMLElement).getBoundingClientRect()
-    overhead += rect.height
-  }
-  return found ? overhead : 0
-}
-
-export function resolveMaxHeightFromContext(ctx: ResizeContext, nextHeight: number): number {
-  const predictedRootTop = ctx.startRootTop - (nextHeight - ctx.startInputHeight)
-  const viewportAvailable = ctx.viewportHeight - predictedRootTop - INPUT_BOTTOM_RESERVED
-  const minMessageArea = computeMinMessageAreaHeight(ctx.containerHeight)
-  const containerAvailable = ctx.containerHeight - minMessageArea - ctx.fixedOverhead
-  const effectiveMax = Math.min(
-    Math.max(DEFAULT_RESIZABLE_INPUT_HEIGHT, Math.floor(viewportAvailable)),
-    Math.max(DEFAULT_RESIZABLE_INPUT_HEIGHT, Math.floor(containerAvailable)),
-  )
-  return effectiveMax
-}
-
-export function getResizeBoundsForElement(rootEl: HTMLDivElement | null): ResizableInputBounds {
-  if (!rootEl) {
-    return { minHeight: DEFAULT_RESIZABLE_INPUT_HEIGHT, maxHeight: 200 }
-  }
-  const containerEl = findChatContainer(rootEl)
-  const containerRect = containerEl?.getBoundingClientRect()
-  const containerHeight = containerRect?.height ?? window.innerHeight
-  const rootRect = getEffectiveRootRect(rootEl)
-  const textareaEl = rootEl.querySelector("textarea")
-  const measuredTextareaHeight = textareaEl?.getBoundingClientRect().height ?? 0
-  const textareaHeight = measuredTextareaHeight > 0 ? measuredTextareaHeight : DEFAULT_RESIZABLE_INPUT_HEIGHT
-  const chatInputOverhead = Math.max(0, rootRect.height - textareaHeight)
-  const siblingOverhead = computeSiblingOverhead(rootEl, containerEl)
-  const minMessageArea = computeMinMessageAreaHeight(containerHeight)
-  const containerMax = Math.max(DEFAULT_RESIZABLE_INPUT_HEIGHT, Math.floor(containerHeight - minMessageArea - chatInputOverhead - siblingOverhead))
-  const viewportMax = Math.max(DEFAULT_RESIZABLE_INPUT_HEIGHT, Math.floor(window.innerHeight - rootRect.top - INPUT_BOTTOM_RESERVED))
-  return {
-    minHeight: DEFAULT_RESIZABLE_INPUT_HEIGHT,
-    maxHeight: Math.min(containerMax, viewportMax),
-  }
-}
-
-export function clampResizableInputHeight(
-  nextHeight: number,
-  bounds: ResizableInputBounds,
-): number {
-  const minHeight = Math.max(1, Math.floor(bounds.minHeight))
-  const maxHeight = Math.max(minHeight, Math.floor(bounds.maxHeight))
-  if (Number.isNaN(nextHeight)) return minHeight
-  if (nextHeight === Number.POSITIVE_INFINITY) return maxHeight
-  if (nextHeight === Number.NEGATIVE_INFINITY) return minHeight
-  return Math.min(maxHeight, Math.max(minHeight, Math.round(nextHeight)))
-}
-
-export function isHeightAtMax(currentHeight: number, bounds: ResizableInputBounds): boolean {
-  const maxHeight = Math.max(bounds.minHeight, Math.floor(bounds.maxHeight))
-  return currentHeight >= maxHeight - 1
-}
-
-export function isHeightAtMin(currentHeight: number, bounds: ResizableInputBounds): boolean {
-  return currentHeight <= bounds.minHeight + 1
-}

+ 0 - 377
src/components/chat/chat-input.resize-interaction.spec.tsx

@@ -1,377 +0,0 @@
-// @vitest-environment jsdom
-
-import { act } from "react"
-import { createRoot, type Root } from "react-dom/client"
-import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
-import { ChatInput } from "./chat-input"
-
-let host: HTMLDivElement
-let root: Root
-
-function createPointerEvent(
-  type: string,
-  init: {
-    button?: number
-    buttons?: number
-    clientY?: number
-    pointerId?: number
-  } = {},
-) {
-  const event = new MouseEvent(type, {
-    bubbles: true,
-    button: init.button ?? 0,
-    buttons: init.buttons ?? 0,
-    cancelable: true,
-    clientY: init.clientY ?? 0,
-  })
-
-  Object.defineProperty(event, "pointerId", { value: init.pointerId ?? 1 })
-  return event
-}
-
-async function flush() {
-  await act(async () => {
-    await Promise.resolve()
-  })
-}
-
-describe("ChatInput resize interaction", () => {
-  beforeEach(() => {
-    host = document.createElement("div")
-    document.body.appendChild(host)
-    root = createRoot(host)
-    window.innerHeight = 800
-    window.localStorage.removeItem("lk-chat-input-height")
-  })
-
-  afterEach(() => {
-    act(() => {
-      root.unmount()
-    })
-    host.remove()
-    vi.restoreAllMocks()
-  })
-
-  it("places left toolbar controls above the textarea", async () => {
-    await act(async () => {
-      root.render(
-        <ChatInput
-          onSend={() => {}}
-          onStop={() => {}}
-          isStreaming={false}
-          leftControls={<span data-testid="left-toolbar">left tools</span>}
-          rightControls={<span data-testid="right-toolbar">right tools</span>}
-        />,
-      )
-    })
-
-    const leftToolbar = host.querySelector<HTMLElement>('[data-testid="left-toolbar"]')
-    const textarea = host.querySelector<HTMLTextAreaElement>("textarea")
-    const rightToolbar = host.querySelector<HTMLElement>('[data-testid="right-toolbar"]')
-    if (!leftToolbar || !textarea || !rightToolbar) throw new Error("chat input toolbar elements not found")
-
-    expect(Boolean(leftToolbar.compareDocumentPosition(textarea) & Node.DOCUMENT_POSITION_FOLLOWING)).toBe(true)
-    expect(Boolean(textarea.compareDocumentPosition(rightToolbar) & Node.DOCUMENT_POSITION_FOLLOWING)).toBe(true)
-  })
-
-  it("places bottom left controls in the footer before right controls", async () => {
-    await act(async () => {
-      root.render(
-        <ChatInput
-          onSend={() => {}}
-          onStop={() => {}}
-          isStreaming={false}
-          bottomLeftControls={<span data-testid="bottom-left-toolbar">bottom left</span>}
-          rightControls={<span data-testid="right-toolbar">right tools</span>}
-        />,
-      )
-    })
-
-    const bottomLeftToolbar = host.querySelector<HTMLElement>('[data-testid="bottom-left-toolbar"]')
-    const textarea = host.querySelector<HTMLTextAreaElement>("textarea")
-    const rightToolbar = host.querySelector<HTMLElement>('[data-testid="right-toolbar"]')
-    if (!bottomLeftToolbar || !textarea || !rightToolbar) throw new Error("chat input footer elements not found")
-
-    expect(Boolean(textarea.compareDocumentPosition(bottomLeftToolbar) & Node.DOCUMENT_POSITION_FOLLOWING)).toBe(true)
-    expect(Boolean(bottomLeftToolbar.compareDocumentPosition(rightToolbar) & Node.DOCUMENT_POSITION_FOLLOWING)).toBe(true)
-  })
-
-  it("captures the pointer and resizes while dragging the height handle", async () => {
-    await act(async () => {
-      root.render(
-        <div
-          ref={(node) => {
-            if (!node) return
-            node.getBoundingClientRect = () => ({
-              x: 0,
-              y: 0,
-              top: 0,
-              right: 360,
-              bottom: 480,
-              left: 0,
-              width: 360,
-              height: 480,
-              toJSON: () => ({}),
-            })
-          }}
-        >
-          <ChatInput onSend={() => {}} onStop={() => {}} isStreaming={false} />
-        </div>,
-      )
-    })
-
-    const resizeHandle = host.querySelector<HTMLElement>('[role="separator"]')
-    const textarea = host.querySelector<HTMLTextAreaElement>("textarea")
-    if (!resizeHandle || !textarea) throw new Error("chat input resize elements not found")
-
-    const setPointerCapture = vi.fn()
-    const releasePointerCapture = vi.fn()
-    resizeHandle.setPointerCapture = setPointerCapture
-    resizeHandle.releasePointerCapture = releasePointerCapture
-
-    await act(async () => {
-      resizeHandle.dispatchEvent(createPointerEvent("pointerdown", {
-        button: 0,
-        buttons: 1,
-        clientY: 220,
-        pointerId: 17,
-      }))
-    })
-
-    expect(setPointerCapture).toHaveBeenCalledWith(17)
-
-    await act(async () => {
-      window.dispatchEvent(createPointerEvent("pointermove", {
-        buttons: 1,
-        clientY: 120,
-        pointerId: 17,
-      }))
-    })
-    await flush()
-
-    expect(textarea.style.height).toBe("144px")
-
-    await act(async () => {
-      window.dispatchEvent(createPointerEvent("pointerup", { pointerId: 17 }))
-    })
-
-    expect(releasePointerCapture).toHaveBeenCalledWith(17)
-  })
-
-  it("keeps the manually resized height after sending a message", async () => {
-    const onSend = vi.fn()
-
-    await act(async () => {
-      root.render(
-        <div
-          ref={(node) => {
-            if (!node) return
-            node.getBoundingClientRect = () => ({
-              x: 0,
-              y: 0,
-              top: 0,
-              right: 360,
-              bottom: 480,
-              left: 0,
-              width: 360,
-              height: 480,
-              toJSON: () => ({}),
-            })
-          }}
-        >
-          <ChatInput onSend={onSend} onStop={() => {}} isStreaming={false} />
-        </div>,
-      )
-    })
-
-    const resizeHandle = host.querySelector<HTMLElement>('[role="separator"]')
-    const textarea = host.querySelector<HTMLTextAreaElement>("textarea")
-    const sendButton = host.querySelector<HTMLButtonElement>("button")
-    if (!resizeHandle || !textarea || !sendButton) throw new Error("chat input elements not found")
-
-    resizeHandle.setPointerCapture = vi.fn()
-    resizeHandle.releasePointerCapture = vi.fn()
-
-    await act(async () => {
-      resizeHandle.dispatchEvent(createPointerEvent("pointerdown", {
-        button: 0,
-        buttons: 1,
-        clientY: 220,
-        pointerId: 20,
-      }))
-    })
-    await act(async () => {
-      window.dispatchEvent(createPointerEvent("pointermove", {
-        buttons: 1,
-        clientY: 120,
-        pointerId: 20,
-      }))
-    })
-    await act(async () => {
-      window.dispatchEvent(createPointerEvent("pointerup", { pointerId: 20 }))
-    })
-    await flush()
-
-    expect(textarea.style.height).toBe("144px")
-
-    await act(async () => {
-      const valueSetter = Object.getOwnPropertyDescriptor(
-        HTMLTextAreaElement.prototype,
-        "value",
-      )?.set
-      valueSetter?.call(textarea, "测试")
-      textarea.dispatchEvent(new InputEvent("input", {
-        bubbles: true,
-        data: "测试",
-        inputType: "insertText",
-      }))
-    })
-    await flush()
-
-    await act(async () => {
-      sendButton.click()
-    })
-    await flush()
-
-    expect(onSend).toHaveBeenCalledWith("测试")
-    expect(textarea.style.height).toBe("144px")
-  })
-
-  it("uses the nearest usable panel height when the chat input is wrapped in a short footer", async () => {
-    await act(async () => {
-      root.render(
-        <div
-          ref={(node) => {
-            if (!node) return
-            node.getBoundingClientRect = () => ({
-              x: 0,
-              y: 0,
-              top: 0,
-              right: 360,
-              bottom: 480,
-              left: 0,
-              width: 360,
-              height: 480,
-              toJSON: () => ({}),
-            })
-          }}
-        >
-          <div
-            ref={(node) => {
-              if (!node) return
-              node.getBoundingClientRect = () => ({
-                x: 0,
-                y: 420,
-                top: 420,
-                right: 360,
-                bottom: 480,
-                left: 0,
-                width: 360,
-                height: 60,
-                toJSON: () => ({}),
-              })
-            }}
-          >
-            <ChatInput onSend={() => {}} onStop={() => {}} isStreaming={false} />
-          </div>
-        </div>,
-      )
-    })
-
-    const resizeHandle = host.querySelector<HTMLElement>('[role="separator"]')
-    const textarea = host.querySelector<HTMLTextAreaElement>("textarea")
-    if (!resizeHandle || !textarea) throw new Error("chat input resize elements not found")
-
-    resizeHandle.setPointerCapture = vi.fn()
-    resizeHandle.releasePointerCapture = vi.fn()
-
-    await act(async () => {
-      resizeHandle.dispatchEvent(createPointerEvent("pointerdown", {
-        button: 0,
-        buttons: 1,
-        clientY: 220,
-        pointerId: 18,
-      }))
-    })
-
-    await act(async () => {
-      window.dispatchEvent(createPointerEvent("pointermove", {
-        buttons: 1,
-        clientY: 120,
-        pointerId: 18,
-      }))
-    })
-    await flush()
-
-    expect(textarea.style.height).toBe("144px")
-  })
-
-  it("does not let a grown chat footer become its own resize limit", async () => {
-    await act(async () => {
-      root.render(
-        <div
-          ref={(node) => {
-            if (!node) return
-            node.getBoundingClientRect = () => ({
-              x: 0,
-              y: 0,
-              top: 0,
-              right: 360,
-              bottom: 480,
-              left: 0,
-              width: 360,
-              height: 480,
-              toJSON: () => ({}),
-            })
-          }}
-        >
-          <div
-            ref={(node) => {
-              if (!node) return
-              node.getBoundingClientRect = () => ({
-                x: 0,
-                y: 360,
-                top: 360,
-                right: 360,
-                bottom: 480,
-                left: 0,
-                width: 360,
-                height: 120,
-                toJSON: () => ({}),
-              })
-            }}
-          >
-            <ChatInput onSend={() => {}} onStop={() => {}} isStreaming={false} />
-          </div>
-        </div>,
-      )
-    })
-
-    const resizeHandle = host.querySelector<HTMLElement>('[role="separator"]')
-    const textarea = host.querySelector<HTMLTextAreaElement>("textarea")
-    if (!resizeHandle || !textarea) throw new Error("chat input resize elements not found")
-
-    resizeHandle.setPointerCapture = vi.fn()
-    resizeHandle.releasePointerCapture = vi.fn()
-
-    await act(async () => {
-      resizeHandle.dispatchEvent(createPointerEvent("pointerdown", {
-        button: 0,
-        buttons: 1,
-        clientY: 320,
-        pointerId: 19,
-      }))
-    })
-
-    await act(async () => {
-      window.dispatchEvent(createPointerEvent("pointermove", {
-        buttons: 1,
-        clientY: 20,
-        pointerId: 19,
-      }))
-    })
-    await flush()
-
-    expect(textarea.style.height).toBe("324px")
-  })
-})

+ 0 - 401
src/components/chat/chat-input.tsx

@@ -1,401 +0,0 @@
-import { useRef, useState, useCallback, useEffect, type ReactNode } from "react"
-import { ArrowUp, Square } from "lucide-react"
-import { Button } from "@/components/ui/button"
-import { isImeComposing } from "@/lib/keyboard-utils"
-import { useChatStore } from "@/stores/chat-store"
-import {
-  clampResizableInputHeight,
-  DEFAULT_RESIZABLE_INPUT_HEIGHT,
-  getResizeBoundsForElement,
-  createResizeContext,
-  resolveMaxHeightFromContext,
-  isHeightAtMax,
-  isHeightAtMin,
-  findChatContainer,
-  type ResizeContext,
-  type ResizableInputBounds,
-} from "./chat-input-resize"
-
-const CHAT_INPUT_HEIGHT_KEY = "lk-chat-input-height"
-
-function loadSavedInputHeight(): number | null {
-  if (typeof localStorage === "undefined") return null
-  const raw = localStorage.getItem(CHAT_INPUT_HEIGHT_KEY)
-  if (!raw) return null
-  const v = Number(raw)
-  return Number.isFinite(v) && v >= DEFAULT_RESIZABLE_INPUT_HEIGHT ? Math.round(v) : null
-}
-
-function saveInputHeight(h: number) {
-  if (typeof localStorage === "undefined") return
-  localStorage.setItem(CHAT_INPUT_HEIGHT_KEY, String(Math.round(h)))
-}
-
-interface ChatInputProps {
-  onSend: (text: string) => void
-  onStop: () => void
-  isStreaming: boolean
-  placeholder?: string
-  leftControls?: ReactNode
-  bottomLeftControls?: ReactNode
-  rightControls?: ReactNode
-  value?: string
-  onChange?: (value: string) => void
-}
-
-export function ChatInput({ onSend, onStop, isStreaming, placeholder, leftControls, bottomLeftControls, rightControls, value: controlledValue, onChange }: ChatInputProps) {
-  const leftToolbarContent = leftControls
-  const bottomLeftToolbarContent = bottomLeftControls
-  const rightToolbarContent = rightControls
-  const activeConversationId = useChatStore((state) => state.activeConversationId)
-  const setConversationInputDraft = useChatStore((state) => state.setConversationInputDraft)
-  const conversation = useChatStore((state) =>
-    activeConversationId
-      ? state.conversations.find((c) => c.id === activeConversationId)
-      : undefined
-  )
-  const isControlled = controlledValue !== undefined
-  const [fallbackDraft, setFallbackDraft] = useState("")
-  const storeValue = conversation?.inputDraft ?? ""
-  const value = isControlled ? controlledValue : activeConversationId ? storeValue : fallbackDraft
-
-  const savedHeight = useRef<number | null>(loadSavedInputHeight())
-  const [inputHeight, setInputHeight] = useState<number>(() => savedHeight.current ?? DEFAULT_RESIZABLE_INPUT_HEIGHT)
-  const [userSetMinHeight, setUserSetMinHeight] = useState<number | null>(savedHeight.current)
-  const userSetMinHeightRef = useRef<number | null>(savedHeight.current)
-  const [isDragging, setIsDragging] = useState(false)
-  const [isAtLimitTop, setIsAtLimitTop] = useState(false)
-  const [isAtLimitBottom, setIsAtLimitBottom] = useState(true)
-  const rootRef = useRef<HTMLDivElement>(null)
-  const textareaRef = useRef<HTMLTextAreaElement>(null)
-  const resizeContextRef = useRef<ResizeContext | null>(null)
-  const dragHeightRef = useRef<number>(inputHeight)
-  const inputHeightRef = useRef<number>(inputHeight)
-
-  useEffect(() => {
-    inputHeightRef.current = inputHeight
-  }, [inputHeight])
-
-  const updateLimitState = useCallback((height: number) => {
-    const bounds = getResizeBoundsForElement(rootRef.current)
-    setIsAtLimitTop(isHeightAtMax(height, bounds))
-    setIsAtLimitBottom(isHeightAtMin(height, bounds))
-  }, [])
-
-  useEffect(() => {
-    updateLimitState(inputHeight)
-  }, [inputHeight, updateLimitState])
-
-  useEffect(() => {
-    userSetMinHeightRef.current = userSetMinHeight
-  }, [userSetMinHeight])
-
-  const handleContainerResize = useCallback(() => {
-    const userMin = userSetMinHeightRef.current
-    const ta = textareaRef.current
-
-    if (userMin != null) {
-      // 用户已手动设置高度:容器变化时只做上下边界检查,保持高度尽量不变
-      const bounds = getResizeBoundsForElement(rootRef.current)
-      const current = inputHeightRef.current
-      const next = clampResizableInputHeight(current, bounds)
-      if (next !== current) {
-        if (ta) {
-          ta.style.height = `${next}px`
-        }
-        setInputHeight(next)
-        setUserSetMinHeight(next)
-        // 同步到 localStorage,防止下次启动仍用超出边界的高度
-        saveInputHeight(next)
-      }
-      setIsAtLimitTop(isHeightAtMax(next, bounds))
-      setIsAtLimitBottom(isHeightAtMin(next, bounds))
-      return
-    }
-
-    if (ta) {
-      const bounds = getResizeBoundsForElement(rootRef.current)
-      ta.style.height = "auto"
-      const contentHeight = ta.scrollHeight
-      const current = inputHeightRef.current
-      const next = clampResizableInputHeight(Math.max(DEFAULT_RESIZABLE_INPUT_HEIGHT, contentHeight, current), bounds)
-      ta.style.height = `${next}px`
-      setInputHeight(next)
-    } else {
-      const bounds = getResizeBoundsForElement(rootRef.current)
-      setInputHeight((prev) => clampResizableInputHeight(prev, bounds))
-    }
-  }, [])
-
-  // 监听 window resize
-  useEffect(() => {
-    window.addEventListener("resize", handleContainerResize)
-    return () => window.removeEventListener("resize", handleContainerResize)
-  }, [handleContainerResize])
-
-  // 使用 ResizeObserver 监听容器大小变化(停靠模式切换、容器拖拽等)
-  useEffect(() => {
-    const rootEl = rootRef.current
-    if (!rootEl) return
-    const containerEl = findChatContainer(rootEl)
-    if (!containerEl || typeof ResizeObserver === "undefined") return
-
-    const observer = new ResizeObserver(() => {
-      handleContainerResize()
-    })
-    observer.observe(containerEl)
-    return () => observer.disconnect()
-  }, [handleContainerResize])
-
-  // 挂载时校验 localStorage 中保存的高度是否超出当前容器 maxHeight
-  // 防止在不同停靠模式(右侧大容器 vs 底部小容器)间切换时 textarea 高度溢出
-  useEffect(() => {
-    const saved = savedHeight.current
-    if (saved == null) return
-    const bounds = getResizeBoundsForElement(rootRef.current)
-    const clamped = clampResizableInputHeight(saved, bounds)
-    if (clamped !== saved) {
-      const ta = textareaRef.current
-      if (ta) {
-        ta.style.height = `${clamped}px`
-      }
-      setInputHeight(clamped)
-      setUserSetMinHeight(clamped)
-      saveInputHeight(clamped)
-    }
-  }, [])
-
-  const autoFitTextarea = useCallback(() => {
-    const ta = textareaRef.current
-    if (!ta) return
-    const userMin = userSetMinHeightRef.current
-
-    // 用户已手动设置高度:保持固定高度,不随内容自适应
-    // 内容超出时 textarea 内部自动滚动,高度始终不变
-    if (userMin != null) {
-      return
-    }
-
-    const bounds = getResizeBoundsForElement(rootRef.current)
-    ta.style.height = "auto"
-    const contentHeight = ta.scrollHeight
-    const next = clampResizableInputHeight(Math.max(DEFAULT_RESIZABLE_INPUT_HEIGHT, contentHeight), bounds)
-    ta.style.height = `${next}px`
-    setInputHeight(next)
-    setIsAtLimitTop(isHeightAtMax(next, bounds))
-    setIsAtLimitBottom(isHeightAtMin(next, bounds))
-  }, [])
-
-  useEffect(() => {
-    autoFitTextarea()
-  }, [value, autoFitTextarea])
-
-  const setValue = useCallback(
-    (draft: string) => {
-      if (isControlled) {
-        onChange?.(draft)
-      } else if (activeConversationId) {
-        setConversationInputDraft(activeConversationId, draft)
-      } else {
-        setFallbackDraft(draft)
-      }
-    },
-    [isControlled, onChange, activeConversationId, setConversationInputDraft]
-  )
-
-  const getStaticBounds = useCallback(() => {
-    return getResizeBoundsForElement(rootRef.current)
-  }, [])
-
-  const handleInput = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
-    setValue(e.target.value)
-    requestAnimationFrame(() => {
-      autoFitTextarea()
-    })
-  }, [setValue, autoFitTextarea])
-
-  const resetHeight = useCallback(() => {
-    setUserSetMinHeight(null)
-    const ta = textareaRef.current
-    const bounds = getResizeBoundsForElement(rootRef.current)
-    if (ta) {
-      ta.style.height = "auto"
-      const next = clampResizableInputHeight(Math.max(DEFAULT_RESIZABLE_INPUT_HEIGHT, ta.scrollHeight), bounds)
-      ta.style.height = `${next}px`
-      setInputHeight(next)
-    } else {
-      setInputHeight(DEFAULT_RESIZABLE_INPUT_HEIGHT)
-    }
-  }, [])
-
-  const handleResizePointerDown = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
-    if (event.button !== 0) return
-    event.preventDefault()
-
-    const resizeHandle = event.currentTarget
-    const pointerId = event.pointerId
-    const startY = event.clientY
-    const startHeight = inputHeight
-    const previousCursor = document.body.style.cursor
-    document.body.style.cursor = "ns-resize"
-    setIsDragging(true)
-
-    const ctx = createResizeContext(rootRef.current, startHeight)
-    resizeContextRef.current = ctx
-    dragHeightRef.current = startHeight
-
-    try {
-      resizeHandle.setPointerCapture(pointerId)
-    } catch {
-      // Older WebViews can miss pointer capture support; window listeners still provide a fallback.
-    }
-
-    const handlePointerMove = (pointerEvent: PointerEvent) => {
-      const nextHeight = startHeight + (startY - pointerEvent.clientY)
-      let maxHeight: number
-      let bounds: ResizableInputBounds
-      if (resizeContextRef.current) {
-        maxHeight = resolveMaxHeightFromContext(resizeContextRef.current, nextHeight)
-        bounds = { minHeight: DEFAULT_RESIZABLE_INPUT_HEIGHT, maxHeight }
-      } else {
-        bounds = getStaticBounds()
-      }
-      const clamped = clampResizableInputHeight(nextHeight, bounds)
-      dragHeightRef.current = clamped
-      const ta = textareaRef.current
-      if (ta) {
-        ta.style.height = `${clamped}px`
-      }
-      setInputHeight(clamped)
-      setIsAtLimitTop(isHeightAtMax(clamped, bounds))
-      setIsAtLimitBottom(isHeightAtMin(clamped, bounds))
-    }
-    const handlePointerUp = () => {
-      const finalHeight = dragHeightRef.current
-      setUserSetMinHeight(finalHeight)
-      saveInputHeight(finalHeight)
-      resizeContextRef.current = null
-      setIsDragging(false)
-      try {
-        resizeHandle.releasePointerCapture(pointerId)
-      } catch {
-        // Ignore release errors when the pointer was already cancelled by the WebView.
-      }
-      document.body.style.cursor = previousCursor
-      window.removeEventListener("pointermove", handlePointerMove)
-      window.removeEventListener("pointerup", handlePointerUp)
-      window.removeEventListener("pointercancel", handlePointerUp)
-    }
-
-    window.addEventListener("pointermove", handlePointerMove)
-    window.addEventListener("pointerup", handlePointerUp)
-    window.addEventListener("pointercancel", handlePointerUp)
-  }, [inputHeight, getStaticBounds])
-
-  const handleDoubleClick = useCallback(() => {
-    resetHeight()
-  }, [resetHeight])
-
-  const handleSend = useCallback(() => {
-    const trimmed = value.trim()
-    if (!trimmed || isStreaming) return
-    onSend(trimmed)
-    setValue("")
-  }, [value, isStreaming, onSend, setValue])
-
-  const handleKeyDown = useCallback(
-    (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
-      if (isImeComposing(e)) return
-      if (e.key === "Enter" && !e.shiftKey) {
-        e.preventDefault()
-        handleSend()
-      }
-    },
-    [handleSend],
-  )
-
-  const handleBarColor = isDragging
-    ? isAtLimitTop
-      ? "bg-destructive/60"
-      : "bg-primary/60"
-    : isAtLimitTop
-      ? "bg-destructive/30"
-      : isAtLimitBottom
-        ? "bg-border"
-        : "bg-primary/30"
-
-  const resizeTitle = isAtLimitBottom
-    ? "向上拖动拉高输入框,双击重置高度"
-    : isAtLimitTop
-      ? "已达最大高度,向下拖动缩小,双击重置"
-      : "拖动调整输入框高度,双击重置"
-
-  return (
-    <div ref={rootRef} className="border-t px-3 pt-1.5 pb-0">
-      <div className="rounded-2xl border bg-background shadow-sm overflow-hidden flex flex-col">
-        <div
-          role="separator"
-          aria-label="拖动调整输入框高度"
-          title={resizeTitle}
-          className="flex h-2 cursor-ns-resize items-center justify-center transition-colors"
-          onPointerDown={handleResizePointerDown}
-          onDoubleClick={handleDoubleClick}
-        >
-          <span className={`h-0.5 w-10 rounded-full transition-colors ${handleBarColor}`} />
-        </div>
-        {leftToolbarContent ? (
-          <div className="flex items-center border-b bg-muted/10 px-3 py-2">
-            <div className="flex min-w-0 items-center gap-2 overflow-x-auto">
-              {leftToolbarContent}
-            </div>
-          </div>
-        ) : null}
-        <div className="px-3 pt-1.5">
-          <textarea
-            ref={textareaRef}
-            value={value}
-            dir="auto"
-            onChange={handleInput}
-            onKeyDown={handleKeyDown}
-            placeholder={placeholder ?? "输入消息,Enter 发送,Shift+Enter 换行"}
-            disabled={isStreaming}
-            rows={1}
-            className="w-full resize-none bg-transparent px-0 py-1.5 text-sm placeholder:text-muted-foreground focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
-            style={{ height: inputHeight, maxHeight: inputHeight, overflowY: "auto" }}
-          />
-        </div>
-        <div className="flex items-center justify-between border-t px-3 py-2 gap-2">
-          {bottomLeftToolbarContent ? (
-            <div className="flex min-w-0 items-center gap-2 shrink-0">
-              {bottomLeftToolbarContent}
-            </div>
-          ) : null}
-          <div className="ml-auto flex items-center gap-2 shrink-0">
-            {rightToolbarContent}
-            {isStreaming ? (
-              <Button
-                variant="destructive"
-                size="icon"
-                onClick={onStop}
-                title="停止生成"
-                aria-label="停止生成"
-              >
-                <Square className="h-4 w-4" />
-              </Button>
-            ) : (
-              <Button
-                size="icon"
-                onClick={handleSend}
-                disabled={!value.trim()}
-                title="发送消息"
-                aria-label="发送消息"
-              >
-                <ArrowUp className="h-4 w-4" />
-              </Button>
-            )}
-          </div>
-        </div>
-      </div>
-    </div>
-  )
-}

+ 1 - 1
src/components/chat/chat-model-selector.tsx

@@ -24,7 +24,7 @@ const DROPDOWN_GAP = 4
 /** Prefer opening downward when at least this much space exists below the trigger. */
 const DROPDOWN_PREFER_BELOW_PX = 200
 
-export type ChatModelDropdownStyle = {
+type ChatModelDropdownStyle = {
   right: number
   width: number
   maxHeight: number

+ 0 - 6
src/components/chat/chat-panel.tsx

@@ -212,12 +212,6 @@ function formatDate(timestamp: number): string {
   return d.toLocaleDateString([], { month: "short", day: "numeric" })
 }
 
-export function getDeepChapterToggleButtonClass(enabled: boolean): string {
-  return enabled
-    ? "border-primary bg-primary text-primary-foreground shadow-sm hover:bg-primary/90 hover:text-primary-foreground"
-    : "text-muted-foreground hover:text-foreground"
-}
-
 export function getWorkflowModeButtonClass(active: boolean): string {
   return active
     ? "bg-primary text-primary-foreground hover:bg-primary/90 hover:text-primary-foreground"

+ 1 - 1
src/components/chat/chat-resume.ts

@@ -5,7 +5,7 @@ const THINK_BLOCK_RE = /<think(?:ing)?>[\s\S]*?(?:<\/think(?:ing)?>|$)/i
 const MAX_RESUME_CONTEXT_CHARS = 60_000
 const RESUME_CONTEXT_COMMENT_RE = /<!--\s*qmai-continue-unfinished-context:([\s\S]*?)\s*-->/g
 
-export interface ContinueUnfinishedDeepChapterContext {
+interface ContinueUnfinishedDeepChapterContext {
   originalRequest?: string
   resumeContext: string
   rootResumeContext?: string

+ 1 - 5
src/components/chat/chat-shared.ts

@@ -4,7 +4,7 @@ import { normalizePath } from "@/lib/path-utils"
 import { useWikiStore } from "@/stores/wiki-store"
 import type { FileNode } from "@/types/wiki"
 
-export interface QueryPageReference {
+interface QueryPageReference {
   title: string
   path: string
 }
@@ -34,10 +34,6 @@ export function getLastQueryPages(): QueryPageReference[] {
   return lastQueryPages
 }
 
-export function setLastQueryPages(pages: QueryPageReference[]) {
-  lastQueryPages = pages
-}
-
 function flattenNames(nodes: FileNode[]): string[] {
   const names: string[] = []
   for (const node of nodes) {

+ 1 - 1
src/components/chat/context-usage-ring.tsx

@@ -28,7 +28,7 @@ const SEGMENT_COLORS: Record<ContextUsageKey, string> = {
   currentInput: "#fb923c",
 }
 
-export interface ContextUsageRingProps {
+interface ContextUsageRingProps {
   usage?: ContextUsageSnapshot | null
   onCreateConversation?: () => void
   className?: string

+ 1 - 1
src/components/chat/stream-session.ts

@@ -1,4 +1,4 @@
-export interface StreamSessionGuard {
+interface StreamSessionGuard {
   /** 为指定会话创建一个新的流式会话,返回 sessionId */
   start: (conversationId: string) => number
   /** 检查指定会话的 sessionId 是否仍然活跃 */

+ 1 - 1
src/components/chat/tool-call-timeline.tsx

@@ -4,7 +4,7 @@ import type { AgentRunRecord } from "@/lib/agent/types"
 import { getWorkflowToolResultDisplay } from "@/lib/agent/workflow-trace"
 import { cn } from "@/lib/utils"
 
-export type ToolCallRecord = AgentRunRecord["toolCalls"][number]
+type ToolCallRecord = AgentRunRecord["toolCalls"][number]
 
 interface ToolCallTimelineProps {
   toolCalls: ToolCallRecord[] | undefined

+ 1 - 1
src/components/common/ai-change-compare-panel.tsx

@@ -11,7 +11,7 @@ function normalizeDiffText(value: string): string {
   return `${normalized}\n`
 }
 
-export interface AiChangeComparePanelProps {
+interface AiChangeComparePanelProps {
   originalContent: string
   modifiedContent: string
   onModifiedContentChange: (content: string) => void

+ 0 - 147
src/components/common/ai-change-review.spec.tsx

@@ -1,147 +0,0 @@
-// @vitest-environment jsdom
-import React from "react"
-import { act } from "react"
-import { createRoot, type Root } from "react-dom/client"
-import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
-
-vi.mock("./source-diff-editor", () => ({
-  SourceDiffEditor: ({ originalValue, modifiedValue, onChange }: {
-    originalValue: string
-    modifiedValue: string
-    onChange: (value: string) => void
-  }) => {
-    const [value, setValue] = React.useState(modifiedValue)
-    return (
-      <div>
-        <pre aria-label="原始源码">{originalValue}</pre>
-        <textarea
-          aria-label="最新源码"
-          value={value}
-          onChange={(event) => {
-            const next = event.target.value
-            setValue(next)
-            onChange(next)
-          }}
-        />
-      </div>
-    )
-  },
-}))
-
-import { AiChangeReview, type AiChangeReviewItem } from "./ai-change-review"
-
-function findButton(container: HTMLElement, label: string): HTMLButtonElement {
-  const button = Array.from(container.querySelectorAll("button"))
-    .find((item) => item.textContent?.includes(label))
-  if (!button) throw new Error(`未找到按钮:${label}`)
-  return button
-}
-
-describe("AiChangeReview", () => {
-  let container: HTMLDivElement
-  let root: Root
-
-  beforeEach(() => {
-    container = document.createElement("div")
-    document.body.appendChild(container)
-    root = createRoot(container)
-  })
-
-  afterEach(async () => {
-    await act(async () => root.unmount())
-    container.remove()
-  })
-
-  it("按文件勾选并提交选中项;取消勾选的不提交", async () => {
-    const onConfirm = vi.fn()
-    const items: AiChangeReviewItem[] = [{
-      id: "outline",
-      fileName: "总纲.md",
-      originalContent: "# 旧总纲",
-      modifiedContent: "# 新总纲",
-      selected: true,
-    }, {
-      id: "character",
-      fileName: "林默.md",
-      originalContent: "",
-      modifiedContent: "# 林默",
-      selected: true,
-    }]
-
-    await act(async () => {
-      root.render(
-        <AiChangeReview
-          open
-          title="确认 AI 修改"
-          items={items}
-          onClose={() => {}}
-          onConfirm={onConfirm}
-        />,
-      )
-    })
-
-    const characterCheckbox = document.body.querySelector('input[aria-label="保存 林默.md"]') as HTMLInputElement
-    await act(async () => characterCheckbox.click())
-    await act(async () => findButton(document.body, "确认保存").click())
-
-    expect(onConfirm).toHaveBeenCalledOnce()
-    const submitted = onConfirm.mock.calls[0][0] as AiChangeReviewItem[]
-    expect(submitted).toHaveLength(1)
-    expect(submitted[0].id).toBe("outline")
-    expect(submitted[0].modifiedContent).toBe("# 新总纲")
-    expect(submitted.some((i: AiChangeReviewItem) => i.id === "character")).toBe(false)
-  })
-
-  it("未选择任何文件时禁用确认保存", async () => {
-    const onConfirm = vi.fn()
-    const items: AiChangeReviewItem[] = [{
-      id: "outline",
-      fileName: "总纲.md",
-      originalContent: "# 旧总纲",
-      modifiedContent: "# 新总纲",
-      selected: false,
-    }]
-
-    await act(async () => {
-      root.render(
-        <AiChangeReview
-          open
-          title="确认 AI 修改"
-          items={items}
-          onClose={() => {}}
-          onConfirm={onConfirm}
-        />,
-      )
-    })
-
-    const btn = findButton(document.body, "确认保存")
-    expect(btn.disabled).toBe(true)
-  })
-
-  it("可在源码对比和不带符号的渲染预览之间切换", async () => {
-    await act(async () => {
-      root.render(
-        <AiChangeReview
-          open
-          title="确认 AI 修改"
-          items={[{
-            id: "outline",
-            fileName: "总纲.md",
-            originalContent: "# 旧总纲",
-            modifiedContent: "# 新总纲",
-            selected: true,
-          }]}
-          onClose={() => {}}
-          onConfirm={() => {}}
-        />,
-      )
-    })
-
-    expect(document.body.querySelector('textarea[aria-label="最新源码"]')).not.toBeNull()
-    await act(async () => findButton(document.body, "渲染预览").click())
-    expect(document.body.textContent).toContain("原始内容预览")
-    expect(document.body.textContent).toContain("最新内容预览")
-    await act(async () => findButton(document.body, "源码对比").click())
-    expect(document.body.querySelector('textarea[aria-label="最新源码"]')).not.toBeNull()
-  })
-})

+ 0 - 200
src/components/common/ai-change-review.tsx

@@ -1,200 +0,0 @@
-import { useState, useMemo, useCallback, useEffect } from "react"
-import { X, Check, FileText } from "lucide-react"
-import { AiChangeComparePanel } from "./ai-change-compare-panel"
-
-export interface AiChangeReviewItem {
-  id: string
-  fileName: string
-  originalContent: string
-  modifiedContent: string
-  selected: boolean
-  error?: string
-  targetFolder?: string
-  writeMode?: string
-}
-
-interface AiChangeReviewProps {
-  open: boolean
-  title: string
-  items: AiChangeReviewItem[]
-  onClose: () => void
-  onConfirm: (items: AiChangeReviewItem[]) => void
-  description?: string
-}
-
-export function AiChangeReview({
-  open,
-  title,
-  items: initialItems,
-  onClose,
-  onConfirm,
-  description,
-}: AiChangeReviewProps) {
-  const [items, setItems] = useState<AiChangeReviewItem[]>(initialItems)
-  const [activeId, setActiveId] = useState<string | null>(null)
-  const [saving, setSaving] = useState(false)
-
-  useEffect(() => {
-    if (open) {
-      setItems(initialItems)
-      setActiveId(initialItems[0]?.id ?? null)
-      setSaving(false)
-    }
-  }, [open, initialItems])
-
-  const activeItem = useMemo(
-    () => items.find((item) => item.id === activeId) ?? null,
-    [items, activeId],
-  )
-
-  const selectedCount = useMemo(
-    () => items.filter((item) => item.selected).length,
-    [items],
-  )
-
-  const handleModifiedChange = useCallback(
-    (value: string) => {
-      setItems((prev) =>
-        prev.map((item) =>
-          item.id === activeId ? { ...item, modifiedContent: value } : item,
-        ),
-      )
-    },
-    [activeId],
-  )
-
-  const handleToggle = useCallback((id: string) => {
-    setItems((prev) =>
-      prev.map((item) =>
-        item.id === id ? { ...item, selected: !item.selected } : item,
-      ),
-    )
-  }, [])
-
-  const handleConfirm = useCallback(() => {
-    if (saving) return
-    setSaving(true)
-    onConfirm(items.filter((item) => item.selected))
-  }, [items, onConfirm, saving])
-
-  if (!open) return null
-
-  const isNew = !activeItem?.originalContent.trim()
-
-  return (
-    <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
-      <div className="flex h-[85vh] w-full max-w-7xl flex-col rounded-lg border bg-background shadow-2xl">
-        {/* 标题栏 */}
-        <div className="flex items-center justify-between border-b px-4 py-3">
-          <div className="flex items-center gap-2">
-            <FileText className="h-5 w-5 text-blue-500" />
-            <div>
-              <h3 className="font-semibold">{title}</h3>
-              {description && (
-                <p className="text-xs text-muted-foreground">{description}</p>
-              )}
-            </div>
-          </div>
-          <div className="flex items-center gap-3">
-            <button
-              onClick={onClose}
-              className="rounded-md p-1 hover:bg-accent"
-              aria-label="关闭"
-            >
-              <X className="h-4 w-4" />
-            </button>
-          </div>
-        </div>
-
-        {/* 主体:文件栏 + 工作区 */}
-        <div className="flex min-h-0 flex-1">
-          {/* 左侧文件栏 */}
-          {items.length > 1 && (
-            <div className="w-40 shrink-0 border-r bg-muted/20 px-2 py-3">
-              <div className="mb-2 text-[10px] font-medium text-muted-foreground px-1">
-                待写入文件
-              </div>
-              {items.map((item) => (
-                <label
-                  key={item.id}
-                  className={`flex cursor-pointer items-start gap-2 rounded-md px-2 py-1.5 text-xs hover:bg-accent ${
-                    activeId === item.id ? "bg-accent/60" : ""
-                  }`}
-                >
-                  <input
-                    aria-label={`保存 ${item.fileName}`}
-                    type="checkbox"
-                    checked={item.selected}
-                    onChange={() => handleToggle(item.id)}
-                    onClick={(e) => e.stopPropagation()}
-                    className="mt-0.5 h-3.5 w-3.5 shrink-0 accent-primary"
-                  />
-                  <span
-                    className="min-w-0 flex-1 truncate"
-                    onClick={() => setActiveId(item.id)}
-                  >
-                    {item.fileName}
-                    {!item.originalContent.trim() && (
-                      <span className="ml-1 text-[10px] text-blue-500">新建</span>
-                    )}
-                    {item.error && (
-                      <span className="block text-[10px] text-red-500">{item.error}</span>
-                    )}
-                  </span>
-                </label>
-              ))}
-            </div>
-          )}
-
-          {/* 中央工作区 */}
-          <div className="flex min-w-0 flex-1 flex-col">
-            {activeItem ? (
-              <>
-                <div className="flex items-center justify-between border-b px-3 py-1.5 text-xs text-muted-foreground">
-                  <span className="font-medium">{activeItem.fileName}</span>
-                  {isNew && (
-                    <span className="text-blue-600 dark:text-blue-400">新建文件</span>
-                  )}
-                </div>
-
-                <AiChangeComparePanel
-                  originalContent={activeItem.originalContent}
-                  modifiedContent={activeItem.modifiedContent}
-                  onModifiedContentChange={handleModifiedChange}
-                  resetKey={activeItem.id}
-                />
-              </>
-            ) : (
-              <div className="flex flex-1 items-center justify-center text-sm text-muted-foreground">
-                请从左侧选择文件
-              </div>
-            )}
-          </div>
-        </div>
-
-        {/* 底部栏 */}
-        <div className="flex items-center justify-between border-t px-4 py-3">
-          <span className="text-xs text-muted-foreground">
-            将保存 {selectedCount} 个文件
-          </span>
-          <div className="flex items-center gap-2">
-            <button
-              onClick={onClose}
-              className="rounded-md border px-4 py-1.5 text-sm hover:bg-accent"
-            >
-              取消
-            </button>
-            <button
-              onClick={handleConfirm}
-              disabled={selectedCount === 0 || saving}
-              className="flex items-center gap-1.5 rounded-md bg-primary px-4 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed"
-            >
-              <Check className="h-4 w-4" />
-              确认保存
-            </button>
-          </div>
-        </div>
-      </div>
-    </div>
-  )
-}

+ 2 - 2
src/components/common/context-hub-details.tsx

@@ -10,9 +10,9 @@ import {
   type ContextHubSnapshotRef,
   type ContextHubStats,
 } from "@/lib/context-hub/types"
-import { ContextHubStatsSummary, ProviderCacheUsage } from "./context-hub-stats-summary"
+import { ContextHubStatsSummary } from "./context-hub-stats-summary"
+
 
-export { ProviderCacheUsage }
 
 type ContextSection = "stableCore" | "sessionSummary" | "dynamicContext"
 

+ 2 - 2
src/components/common/context-hub-stats-summary.tsx

@@ -10,7 +10,7 @@ const STABLE_PREFIX_LABELS: Record<StablePrefixStatus, string> = {
   persist_failed: "持久化失败",
 }
 
-export interface ContextHubStatsSummaryProps {
+interface ContextHubStatsSummaryProps {
   stats: ContextHubStats
   warnings?: string[]
   /** Show composed/budget row (default true). */
@@ -53,7 +53,7 @@ const REQUEST_STATUS_LABELS: Record<LlmRequestCacheTrace["status"], string> = {
   network_error: "网络错误",
 }
 
-export function ProviderCacheUsage({ stats }: { stats: ContextHubStats }) {
+function ProviderCacheUsage({ stats }: { stats: ContextHubStats }) {
   const cachedTokens = stats.providerCachedTokens
   const inputTokens = stats.providerInputTokens
   const hitPercent = cachedTokens !== undefined && inputTokens !== undefined && inputTokens > 0

+ 1 - 1
src/components/common/conversation-run-status-icon.tsx

@@ -2,7 +2,7 @@ import { CircleAlert, CircleCheck, LoaderCircle, PauseCircle } from "lucide-reac
 import type { ConversationRunState } from "@/lib/conversation-run-state"
 import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
 
-export function getConversationRunStatusLabel(state: ConversationRunState): string {
+function getConversationRunStatusLabel(state: ConversationRunState): string {
   if (state.status === "running") return "正在生成"
   if (state.status === "completed_unread") return "已完成,点击查看"
   if (state.status === "failed") return `生成失败:${state.error ?? "未知错误"}`

+ 1 - 8
src/components/common/timeline-types.ts

@@ -83,13 +83,6 @@ export type TimelineEvent =
   | { kind: "tool_call"; data: ToolCallEventItem }
   | { kind: "tool_group"; data: ToolCallGroupItem }
 
-export interface EventStreamProps {
-  events: TimelineEvent[]
-  isStreaming: boolean
-  totalDurationMs?: number
-  totalTokens?: number
-}
-
 export function interleaveThinkingWithToolCalls(
   thinkingText: string,
   thinkingStreaming: boolean,
@@ -117,7 +110,7 @@ export function interleaveThinkingWithToolCalls(
   return events
 }
 
-export interface StreamingEventBuilder {
+interface StreamingEventBuilder {
   update: (
     thinkingText: string,
     toolCalls: ToolCallEventItem[],

+ 1 - 1
src/components/editor/textarea-find-bar.tsx

@@ -2,7 +2,7 @@ import { useEffect, useRef } from "react"
 import { ChevronDown, ChevronUp, X } from "lucide-react"
 import { isImeComposing } from "@/lib/keyboard-utils"
 
-export interface TextareaFindBarProps {
+interface TextareaFindBarProps {
   open: boolean
   query: string
   activeMatchIndex: number

+ 1 - 1
src/components/editor/textarea-find-highlights.tsx

@@ -4,7 +4,7 @@ import {
   type FindHighlightPart,
 } from "@/lib/textarea-find"
 
-export interface TextareaFindHighlightsProps {
+interface TextareaFindHighlightsProps {
   text: string
   query: string
   matches: number[]

+ 0 - 6
src/components/layout/preview-panel.tsx

@@ -52,7 +52,6 @@ import {
   type ChapterSelectionAction,
 } from "@/lib/chapter-selection"
 import { shouldApplyDiskToEditor } from "@/lib/editor-disk-sync"
-import { registerEditorDiskSyncHandler } from "@/lib/editor-disk-sync-session"
 import { registerEditorExternalUpdateHandler } from "@/lib/editor-external-update-session"
 import { createChapterExternalUpdateCoordinator } from "@/lib/chapter-external-update-coordinator"
 import { applyOpenChapterBodyUpdate, createDeAiBatchChapterApplier } from "@/lib/novel/de-ai-batch/chapter-apply"
@@ -354,11 +353,6 @@ export function PreviewPanel() {
 
   useEffect(() => registerEditorExternalUpdateHandler(applyExternalChapterBody), [applyExternalChapterBody])
 
-  useEffect(() => {
-    registerEditorDiskSyncHandler(applyDiskSyncIfSafe)
-    return () => registerEditorDiskSyncHandler(null)
-  }, [applyDiskSyncIfSafe])
-
   useEffect(() => {
     setChapterDeAiSkillId(undefined)
   }, [project?.path])

+ 0 - 4
src/components/novel/book-analysis-import-task-panel.tsx

@@ -21,7 +21,6 @@ interface BookAnalysisImportTaskPanelProps {
   onRenameCompleted: (taskId: string, title: string) => void
   onOpenBook: (bookId: string) => void
 }
-
 interface TaskGroup {
   batchId?: string
   tasks: BatchImportTask[]
@@ -189,7 +188,6 @@ function BatchSummary({ tasks, missingCount }: { tasks: BatchImportTask[]; missi
     </p>
   )
 }
-
 function buildTaskGroups(batches: BatchImportBatch[], tasks: BatchImportTask[]) {
   const taskMap = new Map(tasks.map((task) => [task.id, task]))
   const batchMap = new Map(batches.map((batch) => [batch.id, batch]))
@@ -364,5 +362,3 @@ export function BookAnalysisImportTaskPanel({
     </section>
   )
 }
-
-export type { BookAnalysisImportTaskPanelProps }

+ 1 - 1
src/components/novel/book-analysis-module-view.tsx

@@ -12,7 +12,7 @@ import {
 import { BookAnalysisCharacterPanel } from "./book-analysis-character-panel"
 import { BookAnalysisStyleCard } from "./book-analysis-style-card"
 
-export type BookAnalysisModuleTab = "characters" | "story" | "style" | "evidence"
+type BookAnalysisModuleTab = "characters" | "story" | "style" | "evidence"
 
 interface BookAnalysisModuleViewProps {
   book: BookAnalysisLibraryBook

+ 1 - 1
src/components/novel/character-selection-panel.tsx

@@ -13,7 +13,7 @@ import {
 import type { RecognizedCharacter } from "@/lib/novel/book-analysis/types"
 import { Search } from "lucide-react"
 
-export interface CharacterSelectionPanelProps {
+interface CharacterSelectionPanelProps {
   characters: RecognizedCharacter[]
   selectedIds: string[]
   onToggle: (id: string) => void

+ 1 - 1
src/components/novel/de-ai-batch-review-dialog.tsx

@@ -26,7 +26,7 @@ const CHAPTER_STATUS: Record<DeAiBatchChapter["status"], string> = {
   cancelled: "已取消",
 }
 
-export interface DeAiBatchReviewDialogProps {
+interface DeAiBatchReviewDialogProps {
   open: boolean
   record: DeAiBatchTaskRecord | null
   currentChapterId: string | null

+ 1 - 1
src/components/novel/hooks/use-character-extraction.ts

@@ -30,7 +30,7 @@ export interface ChapterSelectionData {
   extractionPhase?: "deep" | "simple" | null
 }
 
-export interface UseCharacterExtractionParams {
+interface UseCharacterExtractionParams {
   chapterSelectionData: ChapterSelectionData | null
   setChapterSelectionData: React.Dispatch<React.SetStateAction<ChapterSelectionData | null>>
   recognizedCharacters: RecognizedCharacter[]

+ 1 - 1
src/components/novel/hooks/use-character-recognition.ts

@@ -9,7 +9,7 @@ import { saveRecognizedCharacters } from "@/lib/novel/book-analysis/recognized-c
 import type { RecognizedCharacter } from "@/lib/novel/book-analysis/types"
 import type { ChapterSelectionData } from "./use-character-extraction"
 
-export interface UseCharacterRecognitionParams {
+interface UseCharacterRecognitionParams {
   chapterSelectionData: ChapterSelectionData | null
   setChapterSelectionData: React.Dispatch<React.SetStateAction<ChapterSelectionData | null>>
   recognizedCharacters: RecognizedCharacter[]

+ 1 - 1
src/components/novel/hooks/use-library-operations.ts

@@ -20,7 +20,7 @@ import { hasUsableLlm } from "@/lib/has-usable-llm"
 import type { AnalysisDepth } from "@/lib/novel/book-analysis/types"
 import type { ChapterSelectionData } from "./use-character-extraction"
 
-export interface UseLibraryOperationsParams {
+interface UseLibraryOperationsParams {
   currentProjectPath: string | null
   selectedLibraryBook: BookAnalysisLibraryBook | null
   libraryState: BookAnalysisLibraryState

+ 1 - 1
src/components/settings/context-size-selector.tsx

@@ -1,7 +1,7 @@
 import { useTranslation } from "react-i18next"
 import { normalizeUserLlmContextSize } from "@/lib/llm-context-size"
 
-export const CONTEXT_PRESETS = [
+const CONTEXT_PRESETS = [
   { value: 204800, label: "200K" },
   { value: 262144, label: "256K" },
   { value: 307200, label: "300K" },

+ 2 - 2
src/components/settings/hooks/use-batch-model-test.ts

@@ -2,14 +2,14 @@ import { useState, useCallback } from "react"
 import { testSettingsLlmModel } from "@/lib/settings-model-test"
 import type { LlmConfig } from "@/stores/wiki-store"
 
-export interface BatchModelTestState {
+interface BatchModelTestState {
   loading: boolean
   success: boolean
   message: string
   failedModels?: string[]
 }
 
-export interface UseBatchModelTestResult {
+interface UseBatchModelTestResult {
   modelTestState: BatchModelTestState
   runBatchTest: (modelsToTest: string[], buildConfig: (modelId: string) => LlmConfig) => Promise<void>
   retryFailed: (buildConfig: (modelId: string) => LlmConfig) => Promise<void>

+ 0 - 32
src/components/settings/llm-presets.ts

@@ -575,35 +575,3 @@ export const LLM_PRESETS: LlmPreset[] = ALL_LLM_PRESETS
 export function findLlmPresetById(id: string): LlmPreset | undefined {
   return ALL_LLM_PRESETS.find((preset) => preset.id === id)
 }
-
-/**
- * Best-effort reverse lookup: given the current LlmConfig fields, which
- * preset does it most likely correspond to? Used so the dropdown can
- * show the user what they're effectively on.
- */
-export function matchPreset(params: {
-  provider: Provider
-  customEndpoint: string
-  ollamaUrl: string
-  apiMode?: CustomApiMode
-}): LlmPreset | null {
-  const norm = (u: string) => u.replace(/\/+$/, "").toLowerCase()
-  const { provider, customEndpoint, ollamaUrl, apiMode } = params
-
-  for (const preset of LLM_PRESETS) {
-    if (preset.provider !== provider) continue
-    if (provider === "custom") {
-      if (!preset.baseUrl) continue // skip the generic Custom catch-alls
-      if (norm(preset.baseUrl) !== norm(customEndpoint)) continue
-      if ((preset.apiMode ?? "chat_completions") !== (apiMode ?? "chat_completions"))
-        continue
-      return preset
-    }
-    if (provider === "ollama") {
-      if (preset.baseUrl && norm(preset.baseUrl) !== norm(ollamaUrl)) continue
-      return preset
-    }
-    return preset
-  }
-  return null
-}

+ 1 - 1
src/components/settings/model-select-input.tsx

@@ -14,7 +14,7 @@ interface ModelSelectOption {
   label: string
 }
 
-export function buildModelSelectOptions(value: string, options: string[]): ModelSelectOption[] {
+function buildModelSelectOptions(value: string, options: string[]): ModelSelectOption[] {
   const current = value.trim()
   const fetched = Array.from(new Set(options.map((item) => item.trim()).filter(Boolean)))
   const hasFetchedModels = fetched.length > 0

+ 1 - 1
src/components/settings/output-tokens-selector.tsx

@@ -1,7 +1,7 @@
 import { useTranslation } from "react-i18next"
 import { normalizeUserLlmMaxOutputTokens } from "@/lib/llm-context-size"
 
-export const OUTPUT_TOKEN_PRESETS = [
+const OUTPUT_TOKEN_PRESETS = [
   { value: 65536, label: "64K" },
   { value: 131072, label: "128K" },
   { value: 262144, label: "256K" },

+ 1 - 1
src/components/settings/sections/custom-provider-cards.tsx

@@ -21,7 +21,7 @@ import {
   normalizeUserLlmMaxOutputTokens,
 } from "@/lib/llm-context-size"
 
-export interface CustomProviderCard {
+interface CustomProviderCard {
   id: string
   label: string
   apiMode: "chat_completions" | "responses" | "anthropic_messages"

+ 2 - 2
src/components/skill-library/de-ai-skill-errors.ts

@@ -1,7 +1,7 @@
 import { isDeAiSkillConfigCorruptError } from "@/lib/novel/de-ai-skill-library"
 
-export const DE_AI_SKILL_CONFIG_CORRUPT_MESSAGE = "技能库配置文件损坏,请到技能库恢复配置"
-export const DE_AI_SKILL_LOAD_FAILED_MESSAGE = "读取去AI味技能失败"
+const DE_AI_SKILL_CONFIG_CORRUPT_MESSAGE = "技能库配置文件损坏,请到技能库恢复配置"
+const DE_AI_SKILL_LOAD_FAILED_MESSAGE = "读取去AI味技能失败"
 
 export function getDeAiSkillLoadErrorMessage(error: unknown): string {
   return isDeAiSkillConfigCorruptError(error)

+ 1 - 1
src/components/skill-library/de-ai-skill-picker.tsx

@@ -13,7 +13,7 @@ interface DeAiSkillPickerProps {
   showLibraryShortcut?: boolean
 }
 
-export { getDeAiSkillLoadErrorMessage } from "./de-ai-skill-errors"
+
 
 interface DeAiSkillOptionsPanelProps {
   loading: boolean

+ 0 - 132
src/components/skill-library/unified-skill-model.spec.ts

@@ -1,132 +0,0 @@
-import { describe, expect, it } from "vitest"
-import type { DeAiSkillConfig } from "@/lib/novel/de-ai-skill-library"
-import type { UserSkillConfig } from "@/lib/novel/user-skill-store"
-import {
-  buildUnifiedSkillEntries,
-  filterUnifiedSkillEntries,
-  getUnifiedSkillCategory,
-  getUnifiedSkillStatus,
-} from "./unified-skill-model"
-
-const deAiConfig: DeAiSkillConfig = {
-  version: 1,
-  defaultSkillId: "built-in:comprehensive",
-  disabledSkillIds: ["project:quiet"],
-  lastChapterDeAiSkillId: null,
-  projectSkills: [
-    {
-      id: "project:quiet",
-      name: "沉浸式去AI味",
-      description: "减少解释腔和总结腔",
-      templateId: "custom",
-      content: "删除协作口吻,保留角色语气。",
-      source: "project",
-      createdAt: 100,
-      updatedAt: 200,
-    },
-  ],
-  builtInSkillOverrides: [
-    {
-      id: "built-in:comprehensive",
-      name: "综合去AI味-项目版",
-      description: "项目覆盖版本",
-      templateId: "comprehensive",
-      content: "去掉模板句和机械总结。",
-      source: "built-in",
-      updatedAt: 300,
-    },
-  ],
-}
-
-const writingConfig: UserSkillConfig = {
-  version: 1,
-  selectedSkillId: "skill:rhythm",
-  disabledSkillIds: [],
-  categories: [],
-  skills: [
-    {
-      id: "skill:rhythm",
-      name: "节奏压迫",
-      description: "用于严格模式下审稿和改写章节节奏",
-      kind: ["review", "structure"],
-      stages: ["review", "rewrite"],
-      modes: ["strict"],
-      content: "检查中段塌陷、重复场景和结尾钩子。",
-      source: "uploaded",
-      priority: 0,
-      tags: [],
-      categoryId: "",
-      createdAt: 100,
-      updatedAt: 200,
-    },
-  ],
-}
-
-describe("unified skill model", () => {
-  it("builds unified entries from writing and de-AI skill configs", () => {
-    const entries = buildUnifiedSkillEntries(deAiConfig, writingConfig)
-
-    expect(entries.map((entry) => entry.id)).toContain("de-ai:project:quiet")
-    expect(entries.map((entry) => entry.id)).toContain("writing:skill:rhythm")
-
-    const deAi = entries.find((entry) => entry.id === "de-ai:project:quiet")
-    expect(deAi).toMatchObject({
-      library: "de-ai",
-      source: "project",
-      enabled: false,
-      defaultSkill: false,
-      category: "去AI味",
-      kind: ["style", "rewrite"],
-      stages: ["rewrite", "output"],
-      modes: ["fast", "standard", "strict"],
-    })
-
-    const writing = entries.find((entry) => entry.id === "writing:skill:rhythm")
-    expect(writing).toMatchObject({
-      library: "writing",
-      source: "uploaded",
-      enabled: true,
-      defaultSkill: true,
-      category: "审稿",
-      kind: ["review", "structure"],
-      stages: ["review", "rewrite"],
-      modes: ["strict"],
-    })
-  })
-
-  it("marks overridden built-in de-AI skill as modified", () => {
-    const entries = buildUnifiedSkillEntries(deAiConfig, writingConfig)
-    const overridden = entries.find((entry) => entry.id === "de-ai:built-in:comprehensive")
-
-    expect(overridden).toMatchObject({
-      name: "综合去AI味-项目版",
-      modified: true,
-      defaultSkill: true,
-      status: "启用",
-    })
-  })
-
-  it("filters entries by query, category, status, mode, stage and kind", () => {
-    const entries = buildUnifiedSkillEntries(deAiConfig, writingConfig)
-
-    expect(filterUnifiedSkillEntries(entries, { query: "压迫" }).map((entry) => entry.id))
-      .toEqual(["writing:skill:rhythm"])
-    expect(filterUnifiedSkillEntries(entries, { category: "去AI味" }).every((entry) => entry.library === "de-ai"))
-      .toBe(true)
-    expect(filterUnifiedSkillEntries(entries, { status: "disabled" }).map((entry) => entry.id))
-      .toEqual(["de-ai:project:quiet"])
-    expect(filterUnifiedSkillEntries(entries, { mode: "strict", stage: "review", kind: "review" }).map((entry) => entry.id))
-      .toEqual(["writing:skill:rhythm"])
-  })
-
-  it("returns user-facing category and status labels", () => {
-    const entries = buildUnifiedSkillEntries(deAiConfig, writingConfig)
-    const deAi = entries.find((entry) => entry.id === "de-ai:project:quiet")
-    const writing = entries.find((entry) => entry.id === "writing:skill:rhythm")
-
-    expect(deAi && getUnifiedSkillCategory(deAi)).toBe("去AI味")
-    expect(writing && getUnifiedSkillCategory(writing)).toBe("审稿")
-    expect(deAi && getUnifiedSkillStatus(deAi)).toBe("停用")
-    expect(writing && getUnifiedSkillStatus(writing)).toBe("启用")
-  })
-})

+ 0 - 169
src/components/skill-library/unified-skill-model.ts

@@ -1,169 +0,0 @@
-import {
-  getAllDeAiSkills,
-  isDeAiSkillModified,
-  type DeAiSkillConfig,
-  type DeAiSkillSource,
-} from "@/lib/novel/de-ai-skill-library"
-import {
-  SKILL_KIND_LABELS,
-  SKILL_STAGE_LABELS,
-  type SkillKind,
-  type SkillMode,
-  type SkillStage,
-} from "@/lib/novel/skill-library"
-import type { UserSkillConfig } from "@/lib/novel/user-skill-store"
-
-export type UnifiedSkillLibrary = "writing" | "de-ai"
-export type UnifiedSkillSource = DeAiSkillSource | "built-in" | "project" | "uploaded" | "linked"
-export type UnifiedSkillStatusFilter = "enabled" | "disabled"
-
-export interface UnifiedSkillEntry {
-  id: string
-  skillId: string
-  library: UnifiedSkillLibrary
-  name: string
-  description: string
-  content: string
-  kind: SkillKind[]
-  stages: SkillStage[]
-  modes: SkillMode[]
-  enabled: boolean
-  source: UnifiedSkillSource
-  modified: boolean
-  defaultSkill: boolean
-  category: string
-  status: "启用" | "停用"
-  searchText: string
-}
-
-export interface UnifiedSkillFilter {
-  query?: string
-  library?: UnifiedSkillLibrary
-  category?: string
-  status?: UnifiedSkillStatusFilter
-  mode?: SkillMode
-  stage?: SkillStage
-  kind?: SkillKind
-}
-
-const DE_AI_KIND: SkillKind[] = ["style", "rewrite"]
-const DE_AI_STAGES: SkillStage[] = ["rewrite", "output"]
-const DE_AI_MODES: SkillMode[] = ["fast", "standard", "strict"]
-
-function createSearchText(values: Array<string | string[] | undefined>): string {
-  return values
-    .flatMap((value) => Array.isArray(value) ? value : [value])
-    .filter((value): value is string => Boolean(value))
-    .join("\n")
-    .toLocaleLowerCase()
-}
-
-function getWritingSkillCategory(kind: SkillKind[], stages: SkillStage[]): string {
-  const preferredKind = kind.find((item) => item === "review" || item === "output" || item === "knowledge")
-    ?? kind[0]
-  if (preferredKind) return SKILL_KIND_LABELS[preferredKind]
-  const preferredStage = stages[0]
-  return preferredStage ? SKILL_STAGE_LABELS[preferredStage] : "写作"
-}
-
-export function buildUnifiedSkillEntries(
-  deAiConfig: DeAiSkillConfig,
-  writingConfig: UserSkillConfig,
-): UnifiedSkillEntry[] {
-  const disabledDeAiIds = new Set(deAiConfig.disabledSkillIds)
-  const disabledWritingIds = new Set(writingConfig.disabledSkillIds)
-
-  const deAiEntries = getAllDeAiSkills(deAiConfig).map((skill): UnifiedSkillEntry => {
-    const enabled = !disabledDeAiIds.has(skill.id)
-    const category = "去AI味"
-    const status = enabled ? "启用" : "停用"
-    return {
-      id: `de-ai:${skill.id}`,
-      skillId: skill.id,
-      library: "de-ai",
-      name: skill.name,
-      description: skill.description,
-      content: skill.content,
-      kind: DE_AI_KIND,
-      stages: DE_AI_STAGES,
-      modes: DE_AI_MODES,
-      enabled,
-      source: skill.source,
-      modified: isDeAiSkillModified(deAiConfig, skill.id),
-      defaultSkill: deAiConfig.defaultSkillId === skill.id,
-      category,
-      status,
-      searchText: createSearchText([
-        skill.name,
-        skill.description,
-        skill.content,
-        category,
-        status,
-        DE_AI_KIND,
-        DE_AI_STAGES,
-        DE_AI_MODES,
-      ]),
-    }
-  })
-
-  const writingEntries = writingConfig.skills.map((skill): UnifiedSkillEntry => {
-    const enabled = !disabledWritingIds.has(skill.id)
-    const category = getWritingSkillCategory(skill.kind, skill.stages)
-    const status = enabled ? "启用" : "停用"
-    return {
-      id: `writing:${skill.id}`,
-      skillId: skill.id,
-      library: "writing",
-      name: skill.name,
-      description: skill.description,
-      content: skill.content,
-      kind: skill.kind,
-      stages: skill.stages,
-      modes: skill.modes,
-      enabled,
-      source: skill.source,
-      modified: skill.source !== "built-in" && typeof skill.updatedAt === "number" && skill.updatedAt > (skill.createdAt ?? 0),
-      defaultSkill: writingConfig.selectedSkillId === skill.id,
-      category,
-      status,
-      searchText: createSearchText([
-        skill.name,
-        skill.description,
-        skill.content,
-        category,
-        status,
-        skill.kind,
-        skill.stages,
-        skill.modes,
-      ]),
-    }
-  })
-
-  return [...writingEntries, ...deAiEntries]
-}
-
-export function filterUnifiedSkillEntries(
-  entries: UnifiedSkillEntry[],
-  filter: UnifiedSkillFilter,
-): UnifiedSkillEntry[] {
-  const query = filter.query?.trim().toLocaleLowerCase()
-  return entries.filter((entry) => {
-    if (query && !entry.searchText.includes(query)) return false
-    if (filter.library && entry.library !== filter.library) return false
-    if (filter.category && entry.category !== filter.category) return false
-    if (filter.status === "enabled" && !entry.enabled) return false
-    if (filter.status === "disabled" && entry.enabled) return false
-    if (filter.mode && !entry.modes.includes(filter.mode)) return false
-    if (filter.stage && !entry.stages.includes(filter.stage)) return false
-    if (filter.kind && !entry.kind.includes(filter.kind)) return false
-    return true
-  })
-}
-
-export function getUnifiedSkillCategory(entry: UnifiedSkillEntry): string {
-  return entry.category
-}
-
-export function getUnifiedSkillStatus(entry: UnifiedSkillEntry): "启用" | "停用" {
-  return entry.enabled ? "启用" : "停用"
-}

+ 0 - 1
src/components/sources/outline-chat-panel.spec.tsx

@@ -812,7 +812,6 @@ describe("OutlineChatPanel controls", () => {
   })
 
   it("routes every outline generation menu item through the PRD 3.1 content workflow", () => {
-    expect(source).toContain("buildOutlineSectionGenerationPrompt")
     expect(source).toContain("## AI大纲生成工作流")
     expect(source).toContain("提取请求关键词")
     expect(source).toContain("识别用户意图")

+ 0 - 48
src/components/sources/outline-chat-panel.tsx

@@ -551,35 +551,6 @@ function getOutlineSectionOutputRules(title: string): string {
   return "按可保存的大纲正文输出:标题清楚、条目完整、能直接指导后续小说写作。";
 }
 
-export function buildOutlineSectionGenerationPrompt(
-  title: string,
-  requestHint: string,
-): string {
-  return [
-    `请按「AI大纲生成工作流」生成「${title}」。`,
-    "",
-    "## 本次意图",
-    "generate_outline",
-    "",
-    "## PRD 3.1 主流程要求",
-    "1. 提取请求关键词:确认用户要生成的大纲分项、范围和已有约束。",
-    "2. 识别用户意图:本次是生成/完善大纲正文,不是审稿报告、工具报告或分析说明。",
-    "3. 读取资料:优先读取用户 @ 引用、已有大纲、章节、记忆、推演和历史会话。",
-    "4. 提取对小说创作有用的关键内容:章节目标、冲突、伏笔、人物动机、设定限制、时间线承接和结尾钩子。",
-    "5. 结合用户要用的 skill + soul.md 约束,生成可直接保存的大纲正文。",
-    "6. 结果强约束收敛:最终回复只输出大纲标题和大纲正文。",
-    "",
-    "## 禁止输出",
-    "禁止输出工具调用报告、分析过程、完成报告、下一步行动、等待工具结果、已读取资料清单、泛泛建议。",
-    "",
-    "## 本分项内容要求",
-    requestHint,
-    getOutlineSectionOutputRules(title),
-    "",
-    "如果资料不足以完整生成,请只问一个最关键的澄清问题;如果资料足够,直接输出完整正文。",
-  ].join("\n");
-}
-
 function buildGenerationPrompt(
   title: string,
   requestHint: string,
@@ -802,25 +773,6 @@ function formatOutlineConversationDate(timestamp: number): string {
   return date.toLocaleDateString([], { month: "short", day: "numeric" });
 }
 
-export async function getUniqueOutlinePath(
-  outlinesDir: string,
-  fileName: string,
-): Promise<string> {
-  const normalizedFileName = fileName.toLowerCase().endsWith(".md")
-    ? fileName
-    : `${fileName}.md`;
-  const firstPath = `${outlinesDir}/${normalizedFileName}`;
-  if (!(await fileExists(firstPath))) return firstPath;
-  const extensionIndex = normalizedFileName.lastIndexOf(".");
-  const stem = extensionIndex > 0 ? normalizedFileName.slice(0, extensionIndex) : normalizedFileName;
-  const extension = extensionIndex > 0 ? normalizedFileName.slice(extensionIndex) : "";
-  for (let i = 2; i <= 99; i++) {
-    const candidate = `${outlinesDir}/${stem}-${i}${extension}`;
-    if (!(await fileExists(candidate))) return candidate;
-  }
-  return `${outlinesDir}/${stem}-${Date.now()}${extension}`;
-}
-
 function buildFallbackCharacterDraftsFromRequests(
   requests: OutlineSaveRequest[],
 ): CharacterSaveDraft[] {

+ 0 - 434
src/components/sources/outline-file-tree-panel.spec.tsx

@@ -1,434 +0,0 @@
-// @vitest-environment jsdom
-
-import { act } from "react"
-import { createRoot, type Root } from "react-dom/client"
-import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
-import type { FileNode } from "@/types/wiki"
-import { useWikiStore } from "@/stores/wiki-store"
-import { useOutlineChatStore } from "@/stores/outline-chat-store"
-import { OutlineFileTreePanel } from "./outline-file-tree-panel"
-
-;(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT =
-  true
-
-const { copyFileMock, createDirectoryMock, deleteFileMock, fileExistsMock, listDirectoryMock, writeFileMock } = vi.hoisted(() => ({
-  copyFileMock: vi.fn(async () => undefined),
-  createDirectoryMock: vi.fn(async () => undefined),
-  deleteFileMock: vi.fn(async () => undefined),
-  fileExistsMock: vi.fn(async () => false),
-  listDirectoryMock: vi.fn(),
-  writeFileMock: vi.fn(async () => undefined),
-}))
-
-vi.mock("@/commands/fs", () => ({
-  createDirectory: createDirectoryMock,
-  listDirectory: listDirectoryMock,
-  copyFile: copyFileMock,
-  deleteFile: deleteFileMock,
-  fileExists: fileExistsMock,
-  writeFile: writeFileMock,
-}))
-
-const outlineNodes: FileNode[] = [
-  {
-    name: "章纲",
-    path: "C:/Book/wiki/outlines/章纲",
-    is_dir: true,
-    children: [
-      {
-        name: "1.md",
-        path: "C:/Book/wiki/outlines/章纲/1.md",
-        is_dir: false,
-      },
-    ],
-  },
-  {
-    name: "大纲",
-    path: "C:/Book/wiki/outlines/大纲",
-    is_dir: true,
-    children: [],
-  },
-]
-
-describe("OutlineFileTreePanel", () => {
-  let host: HTMLDivElement
-  let root: Root
-
-  beforeEach(() => {
-    copyFileMock.mockClear()
-    createDirectoryMock.mockClear()
-    deleteFileMock.mockClear()
-    fileExistsMock.mockClear()
-    listDirectoryMock.mockClear()
-    writeFileMock.mockClear()
-    fileExistsMock.mockResolvedValue(false)
-    listDirectoryMock.mockResolvedValue(outlineNodes)
-    useWikiStore.setState({
-      project: { id: "p1", name: "测试书", path: "C:/Book" },
-      fileTree: [],
-      selectedFile: null,
-      fileContent: "",
-      dataVersion: 0,
-    })
-    useOutlineChatStore.setState({
-      conversations: [],
-      activeConversationId: null,
-      streamingContent: "",
-      isStreaming: false,
-      loaded: false,
-      pendingReferenceTokens: [],
-    })
-    host = document.createElement("div")
-    document.body.appendChild(host)
-    root = createRoot(host)
-  })
-
-  afterEach(() => {
-    act(() => root.unmount())
-    host.remove()
-  })
-
-  it("项目数据版本变化后刷新左侧大纲文件树", async () => {
-    await act(async () => {
-      root.render(<OutlineFileTreePanel showHeader={false} />)
-    })
-    await act(async () => {
-      await Promise.resolve()
-    })
-
-    listDirectoryMock.mockClear()
-
-    await act(async () => {
-      useWikiStore.getState().bumpDataVersion()
-      await Promise.resolve()
-    })
-
-    expect(listDirectoryMock).toHaveBeenCalledWith("C:/Book/wiki/outlines")
-  })
-
-  it("重命名大纲文件后刷新左侧树并更新选中文件", async () => {
-    useWikiStore.setState({
-      selectedFile: "C:/Book/wiki/outlines/章纲/1.md",
-    })
-
-    await act(async () => {
-      root.render(<OutlineFileTreePanel showHeader={false} />)
-    })
-    await act(async () => {
-      await Promise.resolve()
-    })
-
-    const fileButton = Array.from(host.querySelectorAll("button")).find((button) =>
-      button.textContent?.includes("1.md"),
-    ) as HTMLButtonElement
-
-    await act(async () => {
-      fileButton.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true }))
-    })
-
-    const renameButton = Array.from(document.body.querySelectorAll("button")).find((button) =>
-      button.textContent?.includes("重命名"),
-    ) as HTMLButtonElement
-
-    await act(async () => {
-      renameButton.click()
-    })
-
-    const input = host.querySelector("[data-testid='outline-rename-input']") as HTMLInputElement
-    await act(async () => {
-      input.value = "新文件.md"
-      input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }))
-      await Promise.resolve()
-    })
-
-    expect(copyFileMock).toHaveBeenCalledWith(
-      "C:/Book/wiki/outlines/章纲/1.md",
-      "C:/Book/wiki/outlines/章纲/新文件.md",
-    )
-    expect(deleteFileMock).toHaveBeenCalledWith("C:/Book/wiki/outlines/章纲/1.md")
-    expect(useWikiStore.getState().selectedFile).toBe("C:/Book/wiki/outlines/章纲/新文件.md")
-  })
-
-  it("发送到 AI 大纲会话时只加入大纲文件引用", async () => {
-    await act(async () => {
-      root.render(<OutlineFileTreePanel showHeader={false} />)
-    })
-    await act(async () => {
-      await Promise.resolve()
-    })
-
-    const fileButton = Array.from(host.querySelectorAll("button")).find((button) =>
-      button.textContent?.includes("1.md"),
-    ) as HTMLButtonElement
-
-    await act(async () => {
-      fileButton.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true }))
-    })
-
-    const sendButton = Array.from(document.body.querySelectorAll("button")).find((button) =>
-      button.textContent?.includes("发送到AI大纲会话"),
-    ) as HTMLButtonElement
-
-    await act(async () => {
-      sendButton.click()
-    })
-
-    expect(useOutlineChatStore.getState().pendingReferenceTokens).toEqual([
-      {
-        id: "outline:C:/Book/wiki/outlines/章纲/1.md",
-        category: "outline",
-        title: "1.md",
-        path: "C:/Book/wiki/outlines/章纲/1.md",
-        displayTitle: "1.md",
-      },
-    ])
-  })
-
-  it("加载时迁移旧的带文件夹后缀目录到新目录", async () => {
-    const migratedNodes: FileNode[] = [
-      {
-        name: "人物小传文件夹",
-        path: "C:/Book/wiki/outlines/人物小传文件夹",
-        is_dir: true,
-        children: [{
-          name: "角色-男主-林辰.md",
-          path: "C:/Book/wiki/outlines/人物小传文件夹/角色-男主-林辰.md",
-          is_dir: false,
-        }],
-      },
-      {
-        name: "人物小传",
-        path: "C:/Book/wiki/outlines/人物小传",
-        is_dir: true,
-        children: [],
-      },
-    ]
-    listDirectoryMock.mockResolvedValue(migratedNodes)
-    fileExistsMock.mockResolvedValue(false)
-
-    await act(async () => {
-      root.render(<OutlineFileTreePanel showHeader={false} />)
-    })
-    await act(async () => {
-      await Promise.resolve()
-    })
-
-    expect(copyFileMock).toHaveBeenCalledWith(
-      "C:/Book/wiki/outlines/人物小传文件夹/角色-男主-林辰.md",
-      "C:/Book/wiki/outlines/人物小传/角色-男主-林辰.md",
-    )
-    expect(deleteFileMock).toHaveBeenCalledWith("C:/Book/wiki/outlines/人物小传文件夹/角色-男主-林辰.md")
-    expect(deleteFileMock).toHaveBeenCalledWith("C:/Book/wiki/outlines/人物小传文件夹")
-  })
-
-  it("迁移旧设定目录时合并同名默认子目录,不生成带序号的重复文件夹", async () => {
-    const migratedNodes: FileNode[] = [
-      {
-        name: "设定文件夹",
-        path: "C:/Book/wiki/outlines/设定文件夹",
-        is_dir: true,
-        children: [
-          {
-            name: "世界观",
-            path: "C:/Book/wiki/outlines/设定文件夹/世界观",
-            is_dir: true,
-            children: [
-              {
-                name: "规则.md",
-                path: "C:/Book/wiki/outlines/设定文件夹/世界观/规则.md",
-                is_dir: false,
-              },
-            ],
-          },
-        ],
-      },
-      {
-        name: "设定",
-        path: "C:/Book/wiki/outlines/设定",
-        is_dir: true,
-        children: [
-          {
-            name: "世界观",
-            path: "C:/Book/wiki/outlines/设定/世界观",
-            is_dir: true,
-            children: [],
-          },
-        ],
-      },
-    ]
-    listDirectoryMock.mockResolvedValue(migratedNodes)
-    fileExistsMock.mockImplementation(async (path?: unknown) => path === "C:/Book/wiki/outlines/设定/世界观")
-
-    await act(async () => {
-      root.render(<OutlineFileTreePanel showHeader={false} />)
-    })
-    await act(async () => {
-      await Promise.resolve()
-    })
-
-    expect(copyFileMock).toHaveBeenCalledWith(
-      "C:/Book/wiki/outlines/设定文件夹/世界观/规则.md",
-      "C:/Book/wiki/outlines/设定/世界观/规则.md",
-    )
-    expect(copyFileMock).not.toHaveBeenCalledWith(
-      "C:/Book/wiki/outlines/设定文件夹/世界观/规则.md",
-      "C:/Book/wiki/outlines/设定/世界观-2/规则.md",
-    )
-    expect(deleteFileMock).toHaveBeenCalledWith("C:/Book/wiki/outlines/设定文件夹/世界观")
-    expect(deleteFileMock).toHaveBeenCalledWith("C:/Book/wiki/outlines/设定文件夹")
-  })
-
-  it("加载时清理设定下带序号的重复默认子目录", async () => {
-    const duplicatedNodes: FileNode[] = [
-      {
-        name: "设定",
-        path: "C:/Book/wiki/outlines/设定",
-        is_dir: true,
-        children: [
-          {
-            name: "世界观",
-            path: "C:/Book/wiki/outlines/设定/世界观",
-            is_dir: true,
-            children: [],
-          },
-          {
-            name: "世界观-2",
-            path: "C:/Book/wiki/outlines/设定/世界观-2",
-            is_dir: true,
-            children: [
-              {
-                name: "规则.md",
-                path: "C:/Book/wiki/outlines/设定/世界观-2/规则.md",
-                is_dir: false,
-              },
-            ],
-          },
-        ],
-      },
-    ]
-    listDirectoryMock.mockResolvedValue(duplicatedNodes)
-    fileExistsMock.mockResolvedValue(false)
-
-    await act(async () => {
-      root.render(<OutlineFileTreePanel showHeader={false} />)
-    })
-    await act(async () => {
-      await Promise.resolve()
-    })
-
-    expect(copyFileMock).toHaveBeenCalledWith(
-      "C:/Book/wiki/outlines/设定/世界观-2/规则.md",
-      "C:/Book/wiki/outlines/设定/世界观/规则.md",
-    )
-    expect(deleteFileMock).toHaveBeenCalledWith("C:/Book/wiki/outlines/设定/世界观-2/规则.md")
-    expect(deleteFileMock).toHaveBeenCalledWith("C:/Book/wiki/outlines/设定/世界观-2")
-  })
-
-  it("加载时将设定下的伏笔目录合并到顶层伏笔并删除嵌套目录", async () => {
-    const duplicatedForeshadowingNodes: FileNode[] = [
-      {
-        name: "伏笔",
-        path: "C:/Book/wiki/outlines/伏笔",
-        is_dir: true,
-        children: [],
-      },
-      {
-        name: "设定",
-        path: "C:/Book/wiki/outlines/设定",
-        is_dir: true,
-        children: [
-          {
-            name: "伏笔",
-            path: "C:/Book/wiki/outlines/设定/伏笔",
-            is_dir: true,
-            children: [
-              {
-                name: "主线伏笔.md",
-                path: "C:/Book/wiki/outlines/设定/伏笔/主线伏笔.md",
-                is_dir: false,
-              },
-            ],
-          },
-        ],
-      },
-    ]
-    listDirectoryMock.mockResolvedValue(duplicatedForeshadowingNodes)
-    fileExistsMock.mockResolvedValue(false)
-
-    await act(async () => {
-      root.render(<OutlineFileTreePanel showHeader={false} />)
-    })
-    await act(async () => {
-      await Promise.resolve()
-    })
-
-    expect(createDirectoryMock).not.toHaveBeenCalledWith("C:/Book/wiki/outlines/设定/伏笔")
-    expect(copyFileMock).toHaveBeenCalledWith(
-      "C:/Book/wiki/outlines/设定/伏笔/主线伏笔.md",
-      "C:/Book/wiki/outlines/伏笔/主线伏笔.md",
-    )
-    expect(deleteFileMock).toHaveBeenCalledWith("C:/Book/wiki/outlines/设定/伏笔/主线伏笔.md")
-    expect(deleteFileMock).toHaveBeenCalledWith("C:/Book/wiki/outlines/设定/伏笔")
-  })
-
-  it("右键文件夹后可新建文档、新建文件夹和删除文件夹", async () => {
-    const promptSpy = vi.spyOn(window, "prompt")
-    const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true)
-
-    await act(async () => {
-      root.render(<OutlineFileTreePanel showHeader={false} />)
-    })
-    await act(async () => {
-      await Promise.resolve()
-    })
-
-    const folderButton = Array.from(host.querySelectorAll("button")).find((button) =>
-      button.textContent?.includes("大纲"),
-    ) as HTMLButtonElement
-
-    promptSpy.mockReturnValueOnce("新文档")
-    await act(async () => {
-      folderButton.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true }))
-    })
-    let createFileButton = Array.from(document.body.querySelectorAll("button")).find((button) =>
-      button.textContent?.includes("新建文档"),
-    ) as HTMLButtonElement
-    await act(async () => {
-      createFileButton.click()
-      await Promise.resolve()
-    })
-    expect(writeFileMock).toHaveBeenCalledWith(
-      "C:/Book/wiki/outlines/大纲/新文档.md",
-      "# 新文档\n\n",
-    )
-
-    promptSpy.mockReturnValueOnce("子目录")
-    await act(async () => {
-      folderButton.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true }))
-    })
-    const createFolderButton = Array.from(document.body.querySelectorAll("button")).find((button) =>
-      button.textContent?.includes("新建文件夹"),
-    ) as HTMLButtonElement
-    await act(async () => {
-      createFolderButton.click()
-      await Promise.resolve()
-    })
-    expect(createDirectoryMock).toHaveBeenCalledWith("C:/Book/wiki/outlines/大纲/子目录")
-
-    await act(async () => {
-      folderButton.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true }))
-    })
-    const deleteButton = Array.from(document.body.querySelectorAll("button")).find((button) =>
-      button.textContent?.includes("删除"),
-    ) as HTMLButtonElement
-    await act(async () => {
-      deleteButton.click()
-      await Promise.resolve()
-    })
-    expect(confirmSpy).toHaveBeenCalled()
-    expect(deleteFileMock).toHaveBeenCalledWith("C:/Book/wiki/outlines/大纲")
-
-    promptSpy.mockRestore()
-    confirmSpy.mockRestore()
-  })
-})

+ 0 - 395
src/components/sources/outline-file-tree-panel.tsx

@@ -1,395 +0,0 @@
-import { useCallback, useEffect, useState } from "react"
-import type { FileNode } from "@/types/wiki"
-import { copyDirectory, copyFile, createDirectory, deleteFile, fileExists, listDirectory, writeFile } from "@/commands/fs"
-import { useWikiStore } from "@/stores/wiki-store"
-import { useOutlineChatStore } from "@/stores/outline-chat-store"
-import { OutlineFileTree } from "@/components/sources/outline-file-tree"
-import {
-  DEFAULT_OUTLINE_FOLDER_PATHS,
-  DEFAULT_SETTING_FOLDER_NAMES,
-  LEGACY_OUTLINE_FOLDER_MIGRATIONS,
-  getDefaultOutlineFolderPath,
-  getOutlineRoot,
-  planOutlineFileMove,
-} from "@/lib/novel/outline-workbench"
-import { normalizePath } from "@/lib/path-utils"
-
-interface OutlineFileTreePanelProps {
-  showHeader?: boolean
-}
-
-function getDirName(path: string): string {
-  const normalized = normalizePath(path)
-  const index = normalized.lastIndexOf("/")
-  return index >= 0 ? normalized.slice(0, index) : ""
-}
-
-function ensureMarkdownFileName(name: string): string {
-  const trimmed = name.trim()
-  return trimmed.toLowerCase().endsWith(".md") ? trimmed : `${trimmed}.md`
-}
-
-function getBaseName(path: string): string {
-  return normalizePath(path).split("/").pop() ?? ""
-}
-
-function assertSafeName(name: string, label: string) {
-  if (!name || name.includes("/") || name.includes("\\")) {
-    throw new Error(`${label}不能包含路径分隔符。`)
-  }
-}
-
-async function resolveUniquePath(path: string): Promise<string> {
-  if (!(await fileExists(path))) return path
-  const normalized = normalizePath(path)
-  const slashIndex = normalized.lastIndexOf("/")
-  const dir = slashIndex >= 0 ? normalized.slice(0, slashIndex) : ""
-  const name = slashIndex >= 0 ? normalized.slice(slashIndex + 1) : normalized
-  const dotIndex = name.lastIndexOf(".")
-  const stem = dotIndex > 0 ? name.slice(0, dotIndex) : name
-  const ext = dotIndex > 0 ? name.slice(dotIndex) : ""
-  for (let index = 2; index <= 99; index++) {
-    const candidate = `${dir}/${stem}-${index}${ext}`
-    if (!(await fileExists(candidate))) return candidate
-  }
-  return `${dir}/${stem}-${Date.now()}${ext}`
-}
-
-async function migrateNodeChildren(sourceNode: FileNode, targetDir: string) {
-  await createDirectory(targetDir)
-  for (const child of sourceNode.children ?? []) {
-    if (child.is_dir) {
-      const targetPath = `${targetDir}/${child.name}`
-      await migrateNodeChildren(child, targetPath)
-      await deleteFile(child.path)
-    } else {
-      const targetPath = await resolveUniquePath(`${targetDir}/${child.name}`)
-      await copyFile(child.path, targetPath)
-      await deleteFile(child.path)
-    }
-  }
-}
-
-async function migrateLegacyOutlineFolders(projectPath: string, nodes: FileNode[]): Promise<boolean> {
-  const outlineRoot = getOutlineRoot(projectPath)
-  let migrated = false
-  for (const migration of LEGACY_OUTLINE_FOLDER_MIGRATIONS) {
-    const source = nodes.find((node) => node.is_dir && node.name === migration.from)
-    if (!source) continue
-    const targetDir = `${outlineRoot}/${migration.to}`
-    await migrateNodeChildren(source, targetDir)
-    await deleteFile(source.path)
-    migrated = true
-  }
-  return migrated
-}
-
-function getDuplicatedDefaultSettingName(name: string): string | null {
-  const match = name.match(/^(.*)-\d+$/)
-  if (!match) return null
-  const baseName = match[1]?.trim()
-  return DEFAULT_SETTING_FOLDER_NAMES.some((item) => item === baseName) ? baseName : null
-}
-
-async function cleanupDuplicatedSettingFolders(nodes: FileNode[]): Promise<boolean> {
-  const settingNode = nodes.find((node) => node.is_dir && node.name === "设定")
-  if (!settingNode?.children?.length) return false
-
-  let migrated = false
-  for (const child of settingNode.children) {
-    if (!child.is_dir) continue
-    const baseName = getDuplicatedDefaultSettingName(child.name)
-    if (!baseName) continue
-    const targetDir = `${settingNode.path}/${baseName}`
-    await migrateNodeChildren(child, targetDir)
-    await deleteFile(child.path)
-    migrated = true
-  }
-
-  return migrated
-}
-
-function isNestedForeshadowingFolder(name: string): boolean {
-  return name === "伏笔" || /^伏笔-\d+$/.test(name)
-}
-
-async function cleanupNestedForeshadowingFolders(nodes: FileNode[]): Promise<boolean> {
-  const settingNode = nodes.find((node) => node.is_dir && node.name === "设定")
-  if (!settingNode?.children?.length) return false
-
-  const outlineRoot = getDirName(settingNode.path)
-  const targetDir = `${outlineRoot}/伏笔`
-  let migrated = false
-  for (const child of settingNode.children) {
-    if (!child.is_dir || !isNestedForeshadowingFolder(child.name)) continue
-    await migrateNodeChildren(child, targetDir)
-    await deleteFile(child.path)
-    migrated = true
-  }
-
-  return migrated
-}
-
-export function OutlineFileTreePanel({ showHeader = true }: OutlineFileTreePanelProps) {
-  const project = useWikiStore((s) => s.project)
-  const selectedFile = useWikiStore((s) => s.selectedFile)
-  const setSelectedFile = useWikiStore((s) => s.setSelectedFile)
-  const setFileTree = useWikiStore((s) => s.setFileTree)
-  const bumpDataVersion = useWikiStore((s) => s.bumpDataVersion)
-  const dataVersion = useWikiStore((s) => s.dataVersion)
-  const enqueueOutlineReferenceTokens = useOutlineChatStore((s) => s.enqueueReferenceTokens)
-  const [outlineNodes, setOutlineNodes] = useState<FileNode[]>([])
-  const [loading, setLoading] = useState(false)
-  const [error, setError] = useState<string | null>(null)
-
-  const refreshTrees = useCallback(async () => {
-    if (!project) return
-    const projectPath = normalizePath(project.path)
-    const outlineRoot = getOutlineRoot(projectPath)
-    const [nextOutlineNodes, nextProjectTree] = await Promise.all([
-      listDirectory(outlineRoot),
-      listDirectory(projectPath),
-    ])
-    setOutlineNodes(nextOutlineNodes)
-    setFileTree(nextProjectTree)
-    bumpDataVersion()
-  }, [bumpDataVersion, project, setFileTree])
-
-  useEffect(() => {
-    if (!project) {
-      setOutlineNodes([])
-      return
-    }
-
-    let cancelled = false
-    async function load() {
-      if (!project) return
-      setLoading(true)
-      setError(null)
-      try {
-        const projectPath = normalizePath(project.path)
-        await Promise.all(
-          DEFAULT_OUTLINE_FOLDER_PATHS.map((folderPath) =>
-            createDirectory(getDefaultOutlineFolderPath(projectPath, folderPath)),
-          ),
-        )
-        const outlineRoot = getOutlineRoot(projectPath)
-        const initialNodes = await listDirectory(outlineRoot)
-        const migrated = await migrateLegacyOutlineFolders(projectPath, initialNodes)
-        const afterMigrationNodes = migrated ? await listDirectory(outlineRoot) : initialNodes
-        const cleaned = await cleanupDuplicatedSettingFolders(afterMigrationNodes)
-        const afterSettingCleanupNodes = cleaned ? await listDirectory(outlineRoot) : afterMigrationNodes
-        const cleanedForeshadowing = await cleanupNestedForeshadowingFolders(afterSettingCleanupNodes)
-        const nodes = cleanedForeshadowing ? await listDirectory(outlineRoot) : afterSettingCleanupNodes
-        if (cancelled) return
-        setOutlineNodes(nodes)
-      } catch (err) {
-        if (cancelled) return
-        setError(err instanceof Error ? err.message : String(err))
-      } finally {
-        if (!cancelled) setLoading(false)
-      }
-    }
-
-    void load()
-    return () => {
-      cancelled = true
-    }
-  }, [dataVersion, project])
-
-  const handleMoveFile = useCallback(
-    async (sourcePath: string, targetFolderPath: string) => {
-      if (!project) return
-      const outlineRoot = getOutlineRoot(project.path)
-      const initialPlan = planOutlineFileMove({
-        outlineRoot,
-        sourcePath,
-        targetFolderPath,
-        targetExists: false,
-      })
-      if (!initialPlan.ok) throw new Error(initialPlan.error)
-
-      const exists = await fileExists(initialPlan.targetPath)
-      const plan = planOutlineFileMove({
-        outlineRoot,
-        sourcePath,
-        targetFolderPath,
-        targetExists: exists,
-      })
-      if (!plan.ok) throw new Error(plan.error)
-
-      await copyFile(sourcePath, plan.targetPath)
-      await deleteFile(sourcePath)
-      if (selectedFile === sourcePath) {
-        setSelectedFile(plan.targetPath)
-      }
-      await refreshTrees()
-    },
-    [project, refreshTrees, selectedFile, setSelectedFile],
-  )
-
-  const handleRenameFile = useCallback(
-    async (sourcePath: string, nextName: string) => {
-      if (!project) return
-      const fileName = ensureMarkdownFileName(nextName)
-      if (!fileName || fileName.includes("/") || fileName.includes("\\")) {
-        throw new Error("文件名不能包含路径分隔符。")
-      }
-      const source = normalizePath(sourcePath)
-      const targetPath = `${getDirName(source)}/${fileName}`
-      if (targetPath === source) return
-
-      const exists = await fileExists(targetPath)
-      if (exists) throw new Error("目标文件已存在,请更换文件名。")
-
-      await copyFile(source, targetPath)
-      await deleteFile(source)
-      if (selectedFile === sourcePath || normalizePath(selectedFile ?? "") === source) {
-        setSelectedFile(targetPath)
-      }
-      await refreshTrees()
-    },
-    [project, refreshTrees, selectedFile, setSelectedFile],
-  )
-
-  const handleRenameFolder = useCallback(
-    async (sourcePath: string, nextName: string) => {
-      if (!project) return
-      const folderName = nextName.trim()
-      assertSafeName(folderName, "文件夹名称")
-      const source = normalizePath(sourcePath)
-      const targetPath = `${getDirName(source)}/${folderName}`
-      if (targetPath === source) return
-
-      const exists = await fileExists(targetPath)
-      if (exists) throw new Error("目标文件夹已存在,请更换名称。")
-
-      await copyDirectory(source, targetPath)
-      await deleteFile(source)
-      const normalizedSelected = normalizePath(selectedFile ?? "")
-      if (normalizedSelected === source || normalizedSelected.startsWith(`${source}/`)) {
-        setSelectedFile(normalizedSelected ? normalizedSelected.replace(source, targetPath) : null)
-      }
-      await refreshTrees()
-    },
-    [project, refreshTrees, selectedFile, setSelectedFile],
-  )
-
-  const handleCreateFile = useCallback(
-    async (folderPath: string) => {
-      const rawName = window.prompt("请输入新文档名称")
-      if (rawName === null) return
-      const fileName = ensureMarkdownFileName(rawName)
-      assertSafeName(fileName, "文件名")
-      const baseTitle = fileName.replace(/\.md$/i, "")
-      const targetPath = `${normalizePath(folderPath)}/${fileName}`
-      const exists = await fileExists(targetPath)
-      if (exists) throw new Error("目标文件已存在,请更换文件名。")
-      await writeFile(targetPath, `# ${baseTitle}\n\n`)
-      setSelectedFile(targetPath)
-      await refreshTrees()
-    },
-    [refreshTrees, setSelectedFile],
-  )
-
-  const handleCreateFolder = useCallback(
-    async (folderPath: string) => {
-      const rawName = window.prompt("请输入新文件夹名称")
-      if (rawName === null) return
-      const folderName = rawName.trim()
-      assertSafeName(folderName, "文件夹名称")
-      const targetPath = `${normalizePath(folderPath)}/${folderName}`
-      const exists = await fileExists(targetPath)
-      if (exists) throw new Error("目标文件夹已存在,请更换名称。")
-      await createDirectory(targetPath)
-      await refreshTrees()
-    },
-    [refreshTrees],
-  )
-
-  const handleDeleteFile = useCallback(
-    async (sourcePath: string) => {
-      const sourceName = getBaseName(sourcePath)
-      if (!window.confirm(`确认删除大纲文件“${sourceName}”吗?此操作不可撤销。`)) return
-      const source = normalizePath(sourcePath)
-      await deleteFile(source)
-      if (normalizePath(selectedFile ?? "") === source) {
-        setSelectedFile(null)
-      }
-      await refreshTrees()
-    },
-    [refreshTrees, selectedFile, setSelectedFile],
-  )
-
-  const handleDeleteFolder = useCallback(
-    async (sourcePath: string) => {
-      const sourceName = getBaseName(sourcePath)
-      if (!window.confirm(`确认删除文件夹“${sourceName}”及其全部内容吗?此操作不可撤销。`)) return
-      const source = normalizePath(sourcePath)
-      await deleteFile(source)
-      const normalizedSelected = normalizePath(selectedFile ?? "")
-      if (normalizedSelected === source || normalizedSelected.startsWith(`${source}/`)) {
-        setSelectedFile(null)
-      }
-      await refreshTrees()
-    },
-    [refreshTrees, selectedFile, setSelectedFile],
-  )
-
-  const handleSendToOutlineChat = useCallback(
-    (sourcePath: string, sourceName: string) => {
-      const normalizedPath = normalizePath(sourcePath)
-      enqueueOutlineReferenceTokens([
-        {
-          id: `outline:${normalizedPath}`,
-          category: "outline",
-          title: sourceName,
-          path: normalizedPath,
-          displayTitle: sourceName,
-        },
-      ])
-    },
-    [enqueueOutlineReferenceTokens],
-  )
-
-  if (!project) {
-    return (
-      <div className="px-3 py-4 text-xs text-muted-foreground">
-        请先打开项目
-      </div>
-    )
-  }
-
-  if (error) {
-    return (
-      <div className="m-2 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
-        大纲目录加载失败:{error}
-      </div>
-    )
-  }
-
-  if (loading && outlineNodes.length === 0) {
-    return (
-      <div className="px-3 py-4 text-xs text-muted-foreground">
-        正在加载大纲目录...
-      </div>
-    )
-  }
-
-  return (
-    <OutlineFileTree
-      nodes={outlineNodes}
-      selectedPath={selectedFile}
-      onSelectFile={setSelectedFile}
-      onMoveFile={handleMoveFile}
-      onRenameFile={handleRenameFile}
-      onRenameFolder={handleRenameFolder}
-      onDeleteFile={handleDeleteFile}
-      onDeleteFolder={handleDeleteFolder}
-      onCreateFile={handleCreateFile}
-      onCreateFolder={handleCreateFolder}
-      onSendToOutlineChat={handleSendToOutlineChat}
-      showHeader={showHeader}
-    />
-  )
-}

+ 0 - 340
src/components/sources/outline-file-tree.spec.tsx

@@ -1,340 +0,0 @@
-// @vitest-environment jsdom
-
-import { act } from "react"
-import { createRoot, type Root } from "react-dom/client"
-import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
-import type { FileNode } from "@/types/wiki"
-import { OutlineFileTree } from "./outline-file-tree"
-
-;(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT =
-  true
-
-const tree: FileNode[] = [
-  {
-    name: "章纲",
-    path: "C:/Book/wiki/outlines/章纲",
-    is_dir: true,
-    children: [
-      {
-        name: "章纲-第001章.md",
-        path: "C:/Book/wiki/outlines/章纲/章纲-第001章.md",
-        is_dir: false,
-      },
-    ],
-  },
-  {
-    name: "大纲",
-    path: "C:/Book/wiki/outlines/大纲",
-    is_dir: true,
-    children: [
-      {
-        name: "总纲.md",
-        path: "C:/Book/wiki/outlines/大纲/总纲.md",
-        is_dir: false,
-      },
-    ],
-  },
-]
-
-describe("OutlineFileTree", () => {
-  let host: HTMLDivElement
-  let root: Root
-
-  beforeEach(() => {
-    host = document.createElement("div")
-    document.body.appendChild(host)
-    root = createRoot(host)
-  })
-
-  afterEach(() => {
-    act(() => root.unmount())
-    host.remove()
-  })
-
-  it("渲染大纲目录并在点击 Markdown 文件时选中文件", async () => {
-    const onSelectFile = vi.fn()
-    const onMoveFile = vi.fn(async () => {})
-
-    await act(async () => {
-      root.render(
-        <OutlineFileTree
-          nodes={tree}
-          selectedPath={null}
-          onSelectFile={onSelectFile}
-          onMoveFile={onMoveFile}
-        />,
-      )
-    })
-
-    expect(host.textContent).toContain("章纲")
-    expect(host.textContent).toContain("大纲")
-
-    const fileButton = Array.from(host.querySelectorAll("button")).find((button) =>
-      button.textContent?.includes("章纲-第001章.md"),
-    ) as HTMLButtonElement
-
-    await act(async () => {
-      fileButton.click()
-    })
-
-    expect(onSelectFile).toHaveBeenCalledWith("C:/Book/wiki/outlines/章纲/章纲-第001章.md")
-    expect(host.querySelector("[data-testid='outline-file-context-menu']")).toBeNull()
-    expect(host.querySelector("[data-testid='outline-move-submenu']")).toBeNull()
-    expect(onMoveFile).not.toHaveBeenCalled()
-  })
-
-  it("右键 Markdown 文件只显示一级操作菜单,点击移动后才显示目标文件夹", async () => {
-    const onMoveFile = vi.fn(async () => {})
-
-    await act(async () => {
-      root.render(
-        <OutlineFileTree
-          nodes={tree}
-          selectedPath={null}
-          onSelectFile={() => {}}
-          onMoveFile={onMoveFile}
-        />,
-      )
-    })
-
-    const fileButton = Array.from(host.querySelectorAll("button")).find((button) =>
-      button.textContent?.includes("章纲-第001章.md"),
-    ) as HTMLButtonElement
-
-    await act(async () => {
-      fileButton.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true }))
-    })
-
-    const menu = document.body.querySelector("[data-testid='outline-file-context-menu']")
-    expect(menu).not.toBeNull()
-    expect(menu?.textContent ?? "").toContain("重命名")
-    expect(menu?.textContent ?? "").toContain("移动")
-    expect(menu?.textContent ?? "").toContain("发送到AI大纲会话")
-    expect(document.body.querySelector("[data-testid='outline-move-submenu']")).toBeNull()
-
-    const moveButton = Array.from(menu?.querySelectorAll("button") ?? []).find((button) =>
-      button.textContent?.includes("移动"),
-    ) as HTMLButtonElement
-
-    await act(async () => {
-      moveButton.click()
-    })
-
-    const moveMenu = document.body.querySelector("[data-testid='outline-move-submenu']")
-    expect(moveMenu).not.toBeNull()
-    expect(moveMenu?.textContent ?? "").toContain("大纲")
-
-    const targetButton = Array.from(moveMenu?.querySelectorAll("button") ?? []).find((button) =>
-      button.textContent?.includes("大纲"),
-    ) as HTMLButtonElement
-
-    await act(async () => {
-      targetButton.click()
-    })
-
-    expect(onMoveFile).toHaveBeenCalledWith(
-      "C:/Book/wiki/outlines/章纲/章纲-第001章.md",
-      "C:/Book/wiki/outlines/大纲",
-    )
-  })
-
-  it("右键菜单打开后点击其他区域会自动关闭", async () => {
-    await act(async () => {
-      root.render(
-        <OutlineFileTree
-          nodes={tree}
-          selectedPath={null}
-          onSelectFile={() => {}}
-          onMoveFile={async () => {}}
-        />,
-      )
-    })
-
-    const fileButton = Array.from(host.querySelectorAll("button")).find((button) =>
-      button.textContent?.includes("章纲-第001章.md"),
-    ) as HTMLButtonElement
-
-    await act(async () => {
-      fileButton.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true }))
-    })
-
-    expect(document.body.querySelector("[data-testid='outline-file-context-menu']")).not.toBeNull()
-
-    await act(async () => {
-      document.body.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }))
-    })
-
-    expect(document.body.querySelector("[data-testid='outline-file-context-menu']")).toBeNull()
-  })
-
-  it("右键重命名后在文件行内编辑文件名并用回车提交", async () => {
-    const onRenameFile = vi.fn(async () => {})
-
-    await act(async () => {
-      root.render(
-        <OutlineFileTree
-          nodes={tree}
-          selectedPath={null}
-          onSelectFile={() => {}}
-          onMoveFile={async () => {}}
-          onRenameFile={onRenameFile}
-        />,
-      )
-    })
-
-    const fileButton = Array.from(host.querySelectorAll("button")).find((button) =>
-      button.textContent?.includes("章纲-第001章.md"),
-    ) as HTMLButtonElement
-
-    await act(async () => {
-      fileButton.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true }))
-    })
-
-    const menu = document.body.querySelector("[data-testid='outline-file-context-menu']")
-    const renameButton = Array.from(menu?.querySelectorAll("button") ?? []).find((button) =>
-      button.textContent?.includes("重命名"),
-    ) as HTMLButtonElement
-
-    await act(async () => {
-      renameButton.click()
-    })
-
-    const input = host.querySelector("[data-testid='outline-rename-input']") as HTMLInputElement
-    expect(input).not.toBeNull()
-
-    await act(async () => {
-      input.value = "新章纲.md"
-      input.dispatchEvent(new Event("input", { bubbles: true }))
-      input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }))
-    })
-
-    expect(onRenameFile).toHaveBeenCalledWith(
-      "C:/Book/wiki/outlines/章纲/章纲-第001章.md",
-      "新章纲.md",
-    )
-  })
-
-  it("右键发送到 AI 大纲会话时只传递文件引用", async () => {
-    const onSendToOutlineChat = vi.fn()
-
-    await act(async () => {
-      root.render(
-        <OutlineFileTree
-          nodes={tree}
-          selectedPath={null}
-          onSelectFile={() => {}}
-          onMoveFile={async () => {}}
-          onSendToOutlineChat={onSendToOutlineChat}
-        />,
-      )
-    })
-
-    const fileButton = Array.from(host.querySelectorAll("button")).find((button) =>
-      button.textContent?.includes("章纲-第001章.md"),
-    ) as HTMLButtonElement
-
-    await act(async () => {
-      fileButton.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true }))
-    })
-
-    const menu = document.body.querySelector("[data-testid='outline-file-context-menu']")
-    const sendButton = Array.from(menu?.querySelectorAll("button") ?? []).find((button) =>
-      button.textContent?.includes("发送到AI大纲会话"),
-    ) as HTMLButtonElement
-
-    await act(async () => {
-      sendButton.click()
-    })
-
-    expect(onSendToOutlineChat).toHaveBeenCalledWith(
-      "C:/Book/wiki/outlines/章纲/章纲-第001章.md",
-      "章纲-第001章.md",
-    )
-  })
-
-  it("拖拽 Markdown 文件到任意文件夹时移动到该文件夹", async () => {
-    const onMoveFile = vi.fn(async () => {})
-
-    await act(async () => {
-      root.render(
-        <OutlineFileTree
-          nodes={tree}
-          selectedPath={null}
-          onSelectFile={() => {}}
-          onMoveFile={onMoveFile}
-        />,
-      )
-    })
-
-    const fileButton = Array.from(host.querySelectorAll("button")).find((button) =>
-      button.textContent?.includes("章纲-第001章.md"),
-    ) as HTMLButtonElement
-    const folderButton = Array.from(host.querySelectorAll("button")).find((button) =>
-      button.textContent?.includes("大纲"),
-    ) as HTMLButtonElement
-
-    await act(async () => {
-      fileButton.dispatchEvent(new Event("dragstart", { bubbles: true }))
-    })
-
-    await act(async () => {
-      folderButton.dispatchEvent(new Event("dragover", { bubbles: true }))
-      folderButton.dispatchEvent(new Event("drop", { bubbles: true }))
-    })
-
-    expect(onMoveFile).toHaveBeenCalledWith(
-      "C:/Book/wiki/outlines/章纲/章纲-第001章.md",
-      "C:/Book/wiki/outlines/大纲",
-    )
-  })
-
-  it("右键文件夹显示新建、重命名和删除操作", async () => {
-    const onCreateFile = vi.fn()
-    const onCreateFolder = vi.fn()
-    const onRenameFolder = vi.fn()
-    const onDeleteFolder = vi.fn()
-
-    await act(async () => {
-      root.render(
-        <OutlineFileTree
-          nodes={tree}
-          selectedPath={null}
-          onSelectFile={() => {}}
-          onMoveFile={async () => {}}
-          onCreateFile={onCreateFile}
-          onCreateFolder={onCreateFolder}
-          onRenameFolder={onRenameFolder}
-          onDeleteFolder={onDeleteFolder}
-        />,
-      )
-    })
-
-    const folderButton = Array.from(host.querySelectorAll("button")).find((button) =>
-      button.textContent?.includes("章纲"),
-    ) as HTMLButtonElement
-
-    await act(async () => {
-      folderButton.dispatchEvent(new MouseEvent("contextmenu", {
-        bubbles: true,
-        clientX: 24,
-        clientY: 36,
-      }))
-    })
-
-    const menu = document.body.querySelector("[data-testid='outline-file-context-menu']")
-    expect(menu).not.toBeNull()
-    expect(menu?.textContent ?? "").toContain("新建文档")
-    expect(menu?.textContent ?? "").toContain("新建文件夹")
-    expect(menu?.textContent ?? "").toContain("重命名")
-    expect(menu?.textContent ?? "").toContain("删除")
-    expect(menu?.textContent ?? "").not.toContain("发送到AI大纲会话")
-
-    const createFileButton = Array.from(menu?.querySelectorAll("button") ?? []).find((button) =>
-      button.textContent?.includes("新建文档"),
-    ) as HTMLButtonElement
-    await act(async () => {
-      createFileButton.click()
-    })
-    expect(onCreateFile).toHaveBeenCalledWith("C:/Book/wiki/outlines/章纲")
-  })
-})

+ 0 - 618
src/components/sources/outline-file-tree.tsx

@@ -1,618 +0,0 @@
-import { useEffect, useMemo, useState } from "react"
-import { createPortal } from "react-dom"
-import { ChevronDown, ChevronRight, FileText, FilePlus, Folder, FolderInput, FolderPlus, MessageCircle, Pencil, Trash2 } from "lucide-react"
-import type { FileNode } from "@/types/wiki"
-
-interface OutlineFileTreeProps {
-  nodes: FileNode[]
-  selectedPath: string | null
-  onSelectFile: (path: string) => void
-  onMoveFile: (sourcePath: string, targetFolderPath: string) => Promise<void> | void
-  onRenameFile?: (sourcePath: string, nextName: string) => Promise<void> | void
-  onRenameFolder?: (sourcePath: string, nextName: string) => Promise<void> | void
-  onDeleteFile?: (sourcePath: string) => Promise<void> | void
-  onDeleteFolder?: (sourcePath: string) => Promise<void> | void
-  onCreateFile?: (folderPath: string) => Promise<void> | void
-  onCreateFolder?: (folderPath: string) => Promise<void> | void
-  onSendToOutlineChat?: (sourcePath: string, sourceName: string) => void
-  showHeader?: boolean
-}
-
-interface ContextMenuState {
-  sourcePath: string
-  sourceName: string
-  isDir: boolean
-  x: number
-  y: number
-}
-
-interface RenameState {
-  sourcePath: string
-  isDir: boolean
-  value: string
-}
-
-function collectFolders(nodes: FileNode[]): FileNode[] {
-  const folders: FileNode[] = []
-  for (const node of nodes) {
-    if (!node.is_dir) continue
-    folders.push(node)
-    if (node.children?.length) {
-      folders.push(...collectFolders(node.children))
-    }
-  }
-  return folders
-}
-
-function collectFolderPaths(nodes: FileNode[], result = new Set<string>()): Set<string> {
-  for (const node of nodes) {
-    if (!node.is_dir) continue
-    result.add(node.path)
-    if (node.children?.length) collectFolderPaths(node.children, result)
-  }
-  return result
-}
-
-function OutlineContextMenu({
-  menu,
-  folders,
-  moveMenuOpen,
-  onOpenMoveSubmenu,
-  onMoveToFolder,
-  onCreateFile,
-  onCreateFolder,
-  onStartRename,
-  onDeleteNode,
-  onSendToOutlineChat,
-}: {
-  menu: ContextMenuState
-  folders: FileNode[]
-  moveMenuOpen: boolean
-  onOpenMoveSubmenu: (sourcePath: string) => void
-  onMoveToFolder: (targetFolderPath: string) => void
-  onCreateFile?: (folderPath: string) => void
-  onCreateFolder?: (folderPath: string) => void
-  onStartRename: (sourcePath: string, sourceName: string, isDir: boolean) => void
-  onDeleteNode?: (sourcePath: string, isDir: boolean) => void
-  onSendToOutlineChat?: (sourcePath: string, sourceName: string) => void
-}) {
-  return createPortal(
-    <div
-      className="fixed z-50 w-44 rounded-md border bg-popover p-1 text-xs text-popover-foreground shadow-md"
-      data-testid="outline-file-context-menu"
-      data-outline-context-menu="true"
-      style={{ left: menu.x, top: menu.y }}
-    >
-      {menu.isDir ? (
-        <>
-          <button
-            type="button"
-            className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left hover:bg-accent"
-            onClick={() => onCreateFile?.(menu.sourcePath)}
-          >
-            <FilePlus className="h-3.5 w-3.5" />
-            新建文档
-          </button>
-          <button
-            type="button"
-            className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left hover:bg-accent"
-            onClick={() => onCreateFolder?.(menu.sourcePath)}
-          >
-            <FolderPlus className="h-3.5 w-3.5" />
-            新建文件夹
-          </button>
-          <button
-            type="button"
-            className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left hover:bg-accent"
-            onClick={() => onStartRename(menu.sourcePath, menu.sourceName, true)}
-          >
-            <Pencil className="h-3.5 w-3.5" />
-            重命名
-          </button>
-          <button
-            type="button"
-            className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-destructive hover:bg-destructive/10"
-            onClick={() => onDeleteNode?.(menu.sourcePath, true)}
-          >
-            <Trash2 className="h-3.5 w-3.5" />
-            删除
-          </button>
-        </>
-      ) : (
-        <>
-          <button
-            type="button"
-            className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left hover:bg-accent"
-            onClick={() => onStartRename(menu.sourcePath, menu.sourceName, false)}
-          >
-            <Pencil className="h-3.5 w-3.5" />
-            重命名
-          </button>
-          <div className="relative">
-            <button
-              type="button"
-              className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left hover:bg-accent"
-              onClick={() => onOpenMoveSubmenu(menu.sourcePath)}
-            >
-              <FolderInput className="h-3.5 w-3.5" />
-              移动
-              <ChevronRight className="ml-auto h-3 w-3" />
-            </button>
-            {moveMenuOpen ? (
-              <div
-                className="absolute left-full top-0 z-50 flex max-h-44 w-44 flex-col gap-1 overflow-y-auto rounded-md border bg-popover p-1 shadow-md"
-                data-testid="outline-move-submenu"
-              >
-                {folders.map((folder) => (
-                  <button
-                    key={folder.path}
-                    type="button"
-                    className="flex items-center gap-2 rounded px-2 py-1.5 text-left hover:bg-accent"
-                    onClick={() => onMoveToFolder(folder.path)}
-                    title={folder.path}
-                  >
-                    <Folder className="h-3.5 w-3.5 shrink-0 text-blue-500" />
-                    <span className="truncate">{folder.name}</span>
-                  </button>
-                ))}
-              </div>
-            ) : null}
-          </div>
-          {onSendToOutlineChat ? (
-            <button
-              type="button"
-              className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left hover:bg-accent"
-              onClick={() => onSendToOutlineChat(menu.sourcePath, menu.sourceName)}
-            >
-              <MessageCircle className="h-3.5 w-3.5" />
-              发送到AI大纲会话
-            </button>
-          ) : null}
-          <button
-            type="button"
-            className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-destructive hover:bg-destructive/10"
-            onClick={() => onDeleteNode?.(menu.sourcePath, false)}
-          >
-            <Trash2 className="h-3.5 w-3.5" />
-            删除
-          </button>
-        </>
-      )}
-    </div>,
-    document.body,
-  )
-}
-
-function TreeNode({
-  node,
-  depth,
-  folders,
-  expandedPaths,
-  contextMenu,
-  moveSubmenuPath,
-  renaming,
-  renameValue,
-  dragSourcePath,
-  selectedPath,
-  onToggle,
-  onSelectFile,
-  onOpenContextMenu,
-  onOpenFolderContextMenu,
-  onOpenMoveSubmenu,
-  onCreateFile,
-  onCreateFolder,
-  onMoveToFolder,
-  onStartRename,
-  onDeleteNode,
-  onRenameValueChange,
-  onSubmitRename,
-  onCancelRename,
-  onSendToOutlineChat,
-  onDragSourceChange,
-}: {
-  node: FileNode
-  depth: number
-  folders: FileNode[]
-  expandedPaths: Set<string>
-  contextMenu: ContextMenuState | null
-  moveSubmenuPath: string | null
-  renaming: RenameState | null
-  renameValue: string
-  dragSourcePath: string | null
-  selectedPath: string | null
-  onToggle: (path: string) => void
-  onSelectFile: (path: string) => void
-  onOpenContextMenu: (sourcePath: string, sourceName: string, x: number, y: number) => void
-  onOpenFolderContextMenu: (sourcePath: string, sourceName: string, x: number, y: number) => void
-  onOpenMoveSubmenu: (sourcePath: string) => void
-  onCreateFile?: (folderPath: string) => void
-  onCreateFolder?: (folderPath: string) => void
-  onMoveToFolder: (targetFolderPath: string) => void
-  onStartRename: (sourcePath: string, sourceName: string, isDir: boolean) => void
-  onDeleteNode?: (sourcePath: string, isDir: boolean) => void
-  onRenameValueChange: (value: string) => void
-  onSubmitRename: (value?: string) => void
-  onCancelRename: () => void
-  onSendToOutlineChat?: (sourcePath: string, sourceName: string) => void
-  onDragSourceChange: (sourcePath: string | null) => void
-}) {
-  const paddingLeft = 10 + depth * 14
-
-  if (node.is_dir) {
-    const expanded = expandedPaths.has(node.path)
-    const dropActive = dragSourcePath !== null
-    const contextMenuOpen = contextMenu?.sourcePath === node.path
-    const isRenaming = renaming?.sourcePath === node.path
-    return (
-      <div>
-        <button
-          type="button"
-          className={`flex w-full min-w-0 items-center gap-1 rounded px-1 py-1 text-left text-xs text-muted-foreground hover:bg-accent hover:text-accent-foreground ${
-            dropActive ? "outline outline-1 outline-primary/25" : ""
-          }`}
-          style={{ paddingLeft }}
-          onClick={() => {
-            if (isRenaming) return
-            onToggle(node.path)
-          }}
-          onContextMenu={(event) => {
-            event.preventDefault()
-            onOpenFolderContextMenu(node.path, node.name, event.clientX, event.clientY)
-          }}
-          onDragOver={(event) => {
-            if (!dragSourcePath) return
-            event.preventDefault()
-          }}
-          onDrop={(event) => {
-            event.preventDefault()
-            if (!dragSourcePath) return
-            onMoveToFolder(node.path)
-            onDragSourceChange(null)
-          }}
-          title={node.name}
-        >
-          {expanded ? <ChevronDown className="h-3.5 w-3.5 shrink-0" /> : <ChevronRight className="h-3.5 w-3.5 shrink-0" />}
-          <Folder className="h-3.5 w-3.5 shrink-0 text-blue-500" />
-          {isRenaming ? (
-            <input
-              data-testid="outline-rename-input"
-              className="min-w-0 flex-1 rounded border bg-background px-1 py-0.5 text-xs text-foreground outline-none focus:ring-1 focus:ring-ring"
-              value={renameValue}
-              onChange={(event) => onRenameValueChange(event.target.value)}
-              onClick={(event) => event.stopPropagation()}
-              onKeyDown={(event) => {
-                event.stopPropagation()
-                if (event.key === "Enter") {
-                  event.preventDefault()
-                  onSubmitRename(event.currentTarget.value)
-                } else if (event.key === "Escape") {
-                  event.preventDefault()
-                  onCancelRename()
-                }
-              }}
-              autoFocus
-            />
-          ) : (
-            <span className="truncate">{node.name}</span>
-          )}
-        </button>
-        {contextMenuOpen ? (
-          <OutlineContextMenu
-            menu={contextMenu}
-            folders={folders}
-            moveMenuOpen={false}
-            onOpenMoveSubmenu={onOpenMoveSubmenu}
-            onMoveToFolder={onMoveToFolder}
-            onCreateFile={onCreateFile}
-            onCreateFolder={onCreateFolder}
-            onStartRename={onStartRename}
-            onDeleteNode={onDeleteNode}
-          />
-        ) : null}
-        {expanded && node.children?.map((child) => (
-          <TreeNode
-            key={child.path}
-            node={child}
-            depth={depth + 1}
-            folders={folders}
-            expandedPaths={expandedPaths}
-            contextMenu={contextMenu}
-            moveSubmenuPath={moveSubmenuPath}
-            renaming={renaming}
-            renameValue={renameValue}
-            dragSourcePath={dragSourcePath}
-            selectedPath={selectedPath}
-            onToggle={onToggle}
-            onSelectFile={onSelectFile}
-            onOpenContextMenu={onOpenContextMenu}
-            onOpenFolderContextMenu={onOpenFolderContextMenu}
-            onOpenMoveSubmenu={onOpenMoveSubmenu}
-            onCreateFile={onCreateFile}
-            onCreateFolder={onCreateFolder}
-            onMoveToFolder={onMoveToFolder}
-            onStartRename={onStartRename}
-            onDeleteNode={onDeleteNode}
-            onRenameValueChange={onRenameValueChange}
-            onSubmitRename={onSubmitRename}
-            onCancelRename={onCancelRename}
-            onSendToOutlineChat={onSendToOutlineChat}
-            onDragSourceChange={onDragSourceChange}
-          />
-        ))}
-      </div>
-    )
-  }
-
-  const selected = selectedPath === node.path
-  const contextMenuOpen = contextMenu?.sourcePath === node.path
-  const moveMenuOpen = moveSubmenuPath === node.path
-  const isRenaming = renaming?.sourcePath === node.path
-  return (
-    <div className="relative flex min-w-0 items-start gap-1">
-      <button
-        type="button"
-        draggable={!isRenaming}
-        className={`flex min-w-0 flex-1 items-center gap-1 rounded px-1 py-1 text-left text-xs ${
-          selected ? "bg-primary/10 text-primary" : "text-muted-foreground hover:bg-accent hover:text-accent-foreground"
-        }`}
-        style={{ paddingLeft: paddingLeft + 18 }}
-        onClick={() => {
-          if (isRenaming) return
-          onSelectFile(node.path)
-        }}
-        onContextMenu={(event) => {
-          event.preventDefault()
-          onOpenContextMenu(node.path, node.name, event.clientX, event.clientY)
-        }}
-        onDragStart={() => {
-          onDragSourceChange(node.path)
-        }}
-        onDragEnd={() => {
-          onDragSourceChange(null)
-        }}
-        title={`${node.name}。右键可重命名、移动或发送到AI大纲会话。`}
-      >
-        <FileText className="h-3.5 w-3.5 shrink-0" />
-        {isRenaming ? (
-          <input
-            data-testid="outline-rename-input"
-            className="min-w-0 flex-1 rounded border bg-background px-1 py-0.5 text-xs text-foreground outline-none focus:ring-1 focus:ring-ring"
-            value={renameValue}
-            onChange={(event) => onRenameValueChange(event.target.value)}
-            onClick={(event) => event.stopPropagation()}
-            onKeyDown={(event) => {
-              event.stopPropagation()
-              if (event.key === "Enter") {
-                event.preventDefault()
-                onSubmitRename(event.currentTarget.value)
-              } else if (event.key === "Escape") {
-                event.preventDefault()
-                onCancelRename()
-              }
-            }}
-            autoFocus
-          />
-        ) : (
-          <span className="truncate">{node.name}</span>
-        )}
-      </button>
-      {contextMenuOpen ? (
-        <OutlineContextMenu
-          menu={contextMenu}
-          folders={folders}
-          moveMenuOpen={moveMenuOpen}
-          onOpenMoveSubmenu={onOpenMoveSubmenu}
-          onMoveToFolder={onMoveToFolder}
-          onStartRename={onStartRename}
-          onDeleteNode={onDeleteNode}
-          onSendToOutlineChat={onSendToOutlineChat}
-        />
-      ) : null}
-    </div>
-  )
-}
-
-export function OutlineFileTree({
-  nodes,
-  selectedPath,
-  onSelectFile,
-  onMoveFile,
-  onRenameFile,
-  onRenameFolder,
-  onDeleteFile,
-  onDeleteFolder,
-  onCreateFile,
-  onCreateFolder,
-  onSendToOutlineChat,
-  showHeader = true,
-}: OutlineFileTreeProps) {
-  const [expandedPaths, setExpandedPaths] = useState<Set<string>>(() => collectFolderPaths(nodes))
-  const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null)
-  const [moveSubmenuPath, setMoveSubmenuPath] = useState<string | null>(null)
-  const [renaming, setRenaming] = useState<RenameState | null>(null)
-  const [dragSourcePath, setDragSourcePath] = useState<string | null>(null)
-  const [error, setError] = useState<string | null>(null)
-  const folders = useMemo(() => collectFolders(nodes), [nodes])
-
-  useEffect(() => {
-    setExpandedPaths((current) => {
-      const next = new Set(current)
-      for (const path of collectFolderPaths(nodes)) next.add(path)
-      return next
-    })
-  }, [nodes])
-
-  useEffect(() => {
-    if (!contextMenu) return
-    function handleDocumentMouseDown(event: MouseEvent) {
-      const target = event.target
-      if (target instanceof Element && target.closest("[data-outline-context-menu='true']")) {
-        return
-      }
-      setContextMenu(null)
-      setMoveSubmenuPath(null)
-    }
-    document.addEventListener("mousedown", handleDocumentMouseDown)
-    return () => {
-      document.removeEventListener("mousedown", handleDocumentMouseDown)
-    }
-  }, [contextMenu])
-
-  function toggle(path: string) {
-    setExpandedPaths((current) => {
-      const next = new Set(current)
-      if (next.has(path)) next.delete(path)
-      else next.add(path)
-      return next
-    })
-  }
-
-  async function handleMove(targetFolderPath: string) {
-    if (!contextMenu && !dragSourcePath) return
-    const sourcePath = contextMenu?.sourcePath ?? dragSourcePath
-    if (!sourcePath) return
-    setError(null)
-    try {
-      await onMoveFile(sourcePath, targetFolderPath)
-      setContextMenu(null)
-      setMoveSubmenuPath(null)
-      setDragSourcePath(null)
-    } catch (err) {
-      setError(err instanceof Error ? err.message : String(err))
-    }
-  }
-
-  async function submitRename(valueOverride?: string) {
-    if (!renaming) return
-    const nextName = (valueOverride ?? renaming.value).trim()
-    if (!nextName) return
-    setError(null)
-    try {
-      if (renaming.isDir) {
-        if (!onRenameFolder) return
-        await onRenameFolder(renaming.sourcePath, nextName)
-      } else {
-        if (!onRenameFile) return
-        await onRenameFile(renaming.sourcePath, nextName)
-      }
-      setRenaming(null)
-      setContextMenu(null)
-    } catch (err) {
-      setError(err instanceof Error ? err.message : String(err))
-    }
-  }
-
-  async function handleCreateFile(folderPath: string) {
-    if (!onCreateFile) return
-    setError(null)
-    try {
-      await onCreateFile(folderPath)
-      setContextMenu(null)
-    } catch (err) {
-      setError(err instanceof Error ? err.message : String(err))
-    }
-  }
-
-  async function handleCreateFolder(folderPath: string) {
-    if (!onCreateFolder) return
-    setError(null)
-    try {
-      await onCreateFolder(folderPath)
-      setContextMenu(null)
-    } catch (err) {
-      setError(err instanceof Error ? err.message : String(err))
-    }
-  }
-
-  async function handleDeleteNode(sourcePath: string, isDir: boolean) {
-    setError(null)
-    try {
-      if (isDir) {
-        if (!onDeleteFolder) return
-        await onDeleteFolder(sourcePath)
-      } else {
-        if (!onDeleteFile) return
-        await onDeleteFile(sourcePath)
-      }
-      setContextMenu(null)
-      setMoveSubmenuPath(null)
-    } catch (err) {
-      setError(err instanceof Error ? err.message : String(err))
-    }
-  }
-
-  return (
-    <div className="flex h-full min-h-0 flex-col overflow-hidden" data-testid="outline-file-tree">
-      {showHeader ? (
-          <div className="shrink-0 border-b px-2 py-2">
-          <div className="text-xs font-semibold text-foreground">大纲文件树</div>
-          <div className="mt-0.5 text-[11px] text-muted-foreground">右键文件可重命名、移动或发送到AI大纲会话</div>
-        </div>
-      ) : null}
-
-      {error ? (
-        <div className="shrink-0 border-b border-destructive/30 bg-destructive/10 px-2 py-1 text-xs text-destructive">
-          {error}
-        </div>
-      ) : null}
-
-      <div className="min-h-0 flex-1 overflow-y-auto p-2">
-        {nodes.length === 0 ? (
-          <div className="px-2 py-8 text-center text-xs text-muted-foreground">
-            暂无大纲文件
-          </div>
-        ) : (
-          nodes.map((node) => (
-            <TreeNode
-              key={node.path}
-              node={node}
-              depth={0}
-              folders={folders}
-              expandedPaths={expandedPaths}
-              contextMenu={contextMenu}
-              moveSubmenuPath={moveSubmenuPath}
-              renaming={renaming}
-              renameValue={renaming?.value ?? ""}
-              dragSourcePath={dragSourcePath}
-              selectedPath={selectedPath}
-              onToggle={toggle}
-              onSelectFile={onSelectFile}
-              onOpenContextMenu={(sourcePath, sourceName, x, y) => {
-                setError(null)
-                setRenaming(null)
-                setMoveSubmenuPath(null)
-                setContextMenu({ sourcePath, sourceName, isDir: false, x, y })
-              }}
-              onOpenFolderContextMenu={(sourcePath, sourceName, x, y) => {
-                setError(null)
-                setRenaming(null)
-                setMoveSubmenuPath(null)
-                setContextMenu({ sourcePath, sourceName, isDir: true, x, y })
-              }}
-              onOpenMoveSubmenu={setMoveSubmenuPath}
-              onCreateFile={(folderPath) => void handleCreateFile(folderPath)}
-              onCreateFolder={(folderPath) => void handleCreateFolder(folderPath)}
-              onMoveToFolder={(targetFolderPath) => void handleMove(targetFolderPath)}
-              onStartRename={(sourcePath, sourceName, isDir) => {
-                setMoveSubmenuPath(null)
-                setContextMenu(null)
-                setRenaming({ sourcePath, isDir, value: sourceName })
-              }}
-              onDeleteNode={(sourcePath, isDir) => void handleDeleteNode(sourcePath, isDir)}
-              onRenameValueChange={(value) => {
-                setRenaming((current) => current ? { ...current, value } : current)
-              }}
-              onSubmitRename={(value) => void submitRename(value)}
-              onCancelRename={() => setRenaming(null)}
-              onSendToOutlineChat={(sourcePath, sourceName) => {
-                onSendToOutlineChat?.(sourcePath, sourceName)
-                setContextMenu(null)
-              }}
-              onDragSourceChange={setDragSourcePath}
-            />
-          ))
-        )}
-      </div>
-    </div>
-  )
-}

+ 1 - 1
src/components/ui/button.tsx

@@ -55,4 +55,4 @@ function Button({
   )
 }
 
-export { Button, buttonVariants }
+export { Button }

+ 0 - 12
src/components/ui/dialog.tsx

@@ -11,18 +11,10 @@ function Dialog({ ...props }: DialogPrimitive.Root.Props) {
   return <DialogPrimitive.Root data-slot="dialog" {...props} />
 }
 
-function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
-  return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
-}
-
 function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
   return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
 }
 
-function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
-  return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
-}
-
 function DialogOverlay({
   className,
   ...props
@@ -148,13 +140,9 @@ function DialogDescription({
 
 export {
   Dialog,
-  DialogClose,
   DialogContent,
   DialogDescription,
   DialogFooter,
   DialogHeader,
-  DialogOverlay,
-  DialogPortal,
   DialogTitle,
-  DialogTrigger,
 }

+ 1 - 1
src/components/ui/scroll-area.tsx

@@ -51,4 +51,4 @@ function ScrollBar({
   )
 }
 
-export { ScrollArea, ScrollBar }
+export { ScrollArea }

+ 6 - 6
src/lib/agent/activity-trace.ts

@@ -11,7 +11,7 @@ const EMPTY_CONTENT = "本阶段未返回可展示内容。"
 const AGGREGATE_STAGE_IDS = new Set(["chapter_workflow", "react_tools"])
 
 /** 细粒度章节流水线阶段;存在任一此类阶段时隐藏冗余的 chapter_workflow 聚合阶段。 */
-export const DETAILED_CHAPTER_STAGE_IDS = new Set([
+const DETAILED_CHAPTER_STAGE_IDS = new Set([
   "read_context",
   "plot_analysis",
   "generate_draft",
@@ -25,7 +25,7 @@ export const DETAILED_CHAPTER_STAGE_IDS = new Set([
   "final_output",
 ])
 
-export const AGENT_STAGE_DISPLAY_ORDER = [
+const AGENT_STAGE_DISPLAY_ORDER = [
   "task_understanding",
   "capability_selection",
   "read_context",
@@ -49,7 +49,7 @@ const STAGE_DISPLAY_ORDER_INDEX = new Map<string, number>(
   AGENT_STAGE_DISPLAY_ORDER.map((id, index) => [id, index]),
 )
 
-export interface CreateAgentActivityEventInput {
+interface CreateAgentActivityEventInput {
   id: string
   stageId: string
   kind: AgentActivityKind
@@ -60,7 +60,7 @@ export interface CreateAgentActivityEventInput {
   timestamp?: number
 }
 
-export interface CreateStageStartedEventInput {
+interface CreateStageStartedEventInput {
   stageId: string
   title: string
   summary: string
@@ -129,13 +129,13 @@ export function getDefaultOpenAgentStageId(stages: AgentStageTrace[]): string |
     ?? null
 }
 
-export function filterAgentStagesForDisplay(stages: AgentStageTrace[]): AgentStageTrace[] {
+function filterAgentStagesForDisplay(stages: AgentStageTrace[]): AgentStageTrace[] {
   const hasDetailedChapterStage = stages.some((stage) => DETAILED_CHAPTER_STAGE_IDS.has(stage.id))
   if (!hasDetailedChapterStage) return stages
   return stages.filter((stage) => stage.id !== "chapter_workflow")
 }
 
-export function sortAgentStagesForDisplay(stages: AgentStageTrace[]): AgentStageTrace[] {
+function sortAgentStagesForDisplay(stages: AgentStageTrace[]): AgentStageTrace[] {
   const unknownBase = AGENT_STAGE_DISPLAY_ORDER.length
   return stages
     .map((stage, originalIndex) => ({ stage, originalIndex }))

+ 2 - 2
src/lib/agent/ai-chat-session.ts

@@ -3,7 +3,7 @@ import type { ToolRegistry } from "./registry"
 import type { AgentConfig, AgentMessage, AgentRunCallbacks, AgentRunRecord } from "./types"
 import { scopeAgentConfigTools } from "./tool-scope"
 
-export interface RunAiChatSessionCallbacks {
+interface RunAiChatSessionCallbacks {
   onText: (chunk: string) => void
   onReasoningToken?: (chunk: string) => void
   onToolEvent?: AgentRunCallbacks["onToolEvent"]
@@ -15,7 +15,7 @@ export interface RunAiChatSessionCallbacks {
   onError: (error: Error) => void
 }
 
-export interface RunAiChatSessionInput {
+interface RunAiChatSessionInput {
   userMessage: string
   projectPath?: string
   agentConfig: AgentConfig

+ 1 - 1
src/lib/agent/capabilities/registry.ts

@@ -55,7 +55,7 @@ const TOOL_LABELS: Record<string, string> = {
   run_chapter_workflow: "Chapter Workflow",
 }
 
-export interface BuildAvailableCapabilitiesOptions {
+interface BuildAvailableCapabilitiesOptions {
   toolNames?: string[]
   selectedSkills?: UserSkill[]
   mcpCapabilities?: AiCapability[]

+ 1 - 1
src/lib/agent/capabilities/selector.ts

@@ -44,7 +44,7 @@ const STRICT_EXTRA_TOOLS = new Set([
 
 const ENTITY_QUERY_INTENTS = new Set<NovelTaskIntent>(["character_query", "setting_query"])
 
-export interface SelectCapabilitiesInput {
+interface SelectCapabilitiesInput {
   capabilities: AiCapability[]
   intent: NovelTaskIntent
   mode: AiWorkflowMode

+ 2 - 2
src/lib/agent/config.ts

@@ -5,7 +5,7 @@ import { DEFAULT_MAX_ROUNDS } from "./types"
 import { registerAllBuiltInTools } from "./tools"
 import type { ToolFactoryOptions } from "./tools"
 
-export const TOOL_UNSUPPORTED_MODEL_PREFIXES: string[] = [
+const TOOL_UNSUPPORTED_MODEL_PREFIXES: string[] = [
   "o1",
   "o3-mini",
   "deepseek-reasoner",
@@ -23,7 +23,7 @@ export function providerUsesTextToolCalls(provider: LlmConfig["provider"]): bool
   return provider === "cursor-cli"
 }
 
-export interface BuildAgentConfigOptions extends ToolFactoryOptions {
+interface BuildAgentConfigOptions extends ToolFactoryOptions {
   llmConfig: LlmConfig
   requestOverrides?: AgentConfig["requestOverrides"]
 }

+ 1 - 21
src/lib/agent/context-trace.ts

@@ -6,7 +6,7 @@ import type { SkillKind, SkillMode, SkillStage } from "@/lib/novel/skill-library
 import type { CapabilityKind, CapabilityPermission } from "./capabilities/types"
 import type { ContextHubStats } from "@/lib/context-hub/types"
 
-export type TraceToolCategory = "read" | "write" | "action" | "virtual"
+type TraceToolCategory = "read" | "write" | "action" | "virtual"
 
 export interface TraceToolCall {
   id: string
@@ -151,26 +151,6 @@ export function createContextTrace(id: string): ContextTrace {
   }
 }
 
-export function addToolCallToTrace(trace: ContextTrace, toolCall: TraceToolCall): ContextTrace {
-  return {
-    ...trace,
-    toolCalls: [...trace.toolCalls, toolCall],
-  }
-}
-
-export function updateToolCallInTrace(
-  trace: ContextTrace,
-  callId: string,
-  updates: Partial<TraceToolCall>,
-): ContextTrace {
-  return {
-    ...trace,
-    toolCalls: trace.toolCalls.map((call) =>
-      call.id === callId ? { ...call, ...updates } : call,
-    ),
-  }
-}
-
 export function setContextInfo(trace: ContextTrace, info: TraceContextInfo): ContextTrace {
   return {
     ...trace,

+ 2 - 2
src/lib/agent/novel-pre-plugin-chain.ts

@@ -17,7 +17,7 @@ import { createTrimContextPlugin } from "./plugins/trim-context-plugin"
 import { createBuildSystemPromptPlugin } from "./plugins/build-system-prompt-plugin"
 import type { BuildContextPackPluginDeps } from "./plugins/build-context-pack-plugin"
 
-export interface NovelPrePluginDeps {
+interface NovelPrePluginDeps {
   selectedFile?: string | null
   lastGeneratedChapterNumber?: number
   requestSoulDialog?: (summary: string) => Promise<boolean>
@@ -80,7 +80,7 @@ export function createNovelPrePluginChain(deps: NovelPrePluginDeps = {}): PrePlu
   return plugins
 }
 
-export interface RunNovelPrePluginChainOptions {
+interface RunNovelPrePluginChainOptions {
   input: PrePluginInput
   deps?: NovelPrePluginDeps
   config?: PluginConfig

+ 1 - 1
src/lib/agent/pipeline.ts

@@ -66,7 +66,7 @@ export interface PrePluginChainResult extends PrePluginOutput {
   executedPlugins: string[]
 }
 
-export interface PrePluginChain {
+interface PrePluginChain {
   run(input: PrePluginInput, config?: PluginConfig): Promise<PrePluginChainResult>
 }
 

+ 1 - 1
src/lib/agent/plugins/build-system-prompt-plugin.ts

@@ -8,7 +8,7 @@ import {
 import { buildSelectedSkillsPrompt } from "./select-skills-plugin"
 import { getWorkflowModeLabel, type AiWorkflowMode } from "../workflow-mode"
 
-export interface BuildSystemPromptPluginDeps {
+interface BuildSystemPromptPluginDeps {
   baseSystemPrompt?: string
   buildTaskDirectiveFn?: typeof buildTaskDirective
   onError?: (error: Error) => void

+ 2 - 2
src/lib/agent/plugins/confidence-gate-plugin.ts

@@ -3,13 +3,13 @@ import type { NovelTaskIntent } from "@/lib/novel/task-router"
 
 export const DEFAULT_CONFIDENCE_THRESHOLD = 0.5
 
-export interface ClarificationCandidate {
+interface ClarificationCandidate {
   intent: NovelTaskIntent
   label: string
   confidence: number
 }
 
-export interface ConfidenceGatePluginDeps {
+interface ConfidenceGatePluginDeps {
   threshold?: number
   getCandidates?: (userMessage: string) => ClarificationCandidate[]
   onError?: (error: Error) => void

+ 1 - 1
src/lib/agent/plugins/post-write-check-ai.ts

@@ -8,7 +8,7 @@ import { hasUsableLlm } from "@/lib/has-usable-llm"
 import { runPostWriteCheck } from "./post-write-check-plugin"
 import { useWikiStore } from "@/stores/wiki-store"
 
-export interface PostWriteCheckAIResult {
+interface PostWriteCheckAIResult {
   check: PostWriteCheck
   source: "ai" | "rule"
   fallbackReason?: string

+ 1 - 1
src/lib/agent/plugins/post-write-check-plugin.ts

@@ -1,7 +1,7 @@
 import type { PostWriteCheck, PostWriteCheckItem } from "../context-trace"
 import type { PrePlugin, PrePluginInput, PrePluginOutput } from "../pipeline"
 
-export interface PostWriteCheckDeps {
+interface PostWriteCheckDeps {
   chapterContent?: string
 }
 

+ 1 - 1
src/lib/agent/plugins/resolve-chapter-plugin.ts

@@ -1,7 +1,7 @@
 import type { PrePlugin, PrePluginInput, PrePluginOutput } from "../pipeline"
 import { resolveTargetChapterNumberForChat } from "@/lib/novel/chapter-utils"
 
-export interface ResolveChapterPluginDeps {
+interface ResolveChapterPluginDeps {
   selectedFile?: string | null
   lastGeneratedChapterNumber?: number
   onError?: (error: Error) => void

+ 1 - 1
src/lib/agent/plugins/route-task-plugin.ts

@@ -1,7 +1,7 @@
 import type { PrePlugin, PrePluginInput, PrePluginOutput } from "../pipeline"
 import { routeTask } from "@/lib/novel/task-router"
 
-export interface RouteTaskPluginDeps {
+interface RouteTaskPluginDeps {
   onVirtualTool?: (
     event: "start" | "end",
     name: string,

+ 1 - 1
src/lib/agent/plugins/soul-dialog-plugin.ts

@@ -1,6 +1,6 @@
 import type { PrePlugin, PrePluginInput, PrePluginOutput } from "../pipeline"
 
-export interface SoulDialogPluginDeps {
+interface SoulDialogPluginDeps {
   shouldRequestSoulDialog?: (contextPack: any) => boolean
   onError?: (error: Error) => void
 }

+ 1 - 1
src/lib/agent/plugins/trim-context-plugin.ts

@@ -3,7 +3,7 @@ import type { ContextPack, TrimResult } from "@/lib/novel/context-engine"
 import { resolveContextPackTokenBudget } from "@/lib/context-budget"
 import { getEffectiveMaxContextSize } from "@/lib/llm-providers"
 
-export interface TrimContextPluginDeps {
+interface TrimContextPluginDeps {
   contextPackToPromptFn?: (pack: ContextPack, tokenBudget?: number, options?: { excludeOutline?: boolean }) => string
   trimContextPackFn?: (pack: ContextPack, tokenBudget?: number, options?: { excludeOutline?: boolean }) => TrimResult
   tokenBudget?: number

+ 1 - 1
src/lib/agent/required-tool-fallback.ts

@@ -3,7 +3,7 @@ import type { AgentRunCallbacks, AgentRunRecord, Tool } from "./types"
 import { executeAgentTool, type ExecuteAgentToolResult } from "./tool-executor"
 import { RequiredToolFallbackError } from "./required-tools-gate"
 
-export interface RequiredToolFallbackResult {
+interface RequiredToolFallbackResult {
   attempted: boolean
   toolName?: string
   satisfiedTools: string[]

+ 2 - 2
src/lib/agent/required-tools-gate.ts

@@ -2,7 +2,7 @@ import type { NovelTaskIntent } from "@/lib/novel/task-router"
 import { OUTLINE_FIND_CHAPTER_INTENTS } from "@/lib/novel/outline-find-protocol"
 import type { AiWorkflowMode } from "./workflow-mode"
 
-export interface RequiredToolsGateInput {
+interface RequiredToolsGateInput {
   requiredToolsOnce?: string[] | null
   availableToolNames: Iterable<string>
   calledToolNames: Iterable<string>
@@ -57,7 +57,7 @@ export class RequiredToolFallbackError extends Error {
   }
 }
 
-export interface ResolveRequiredToolsOnceInput {
+interface ResolveRequiredToolsOnceInput {
   novelMode: boolean
   intent?: string | null
   mode?: AiWorkflowMode | null

+ 1 - 1
src/lib/agent/skills/draft-review-skill.ts

@@ -41,7 +41,7 @@ export interface Deviation {
   repairAction?: string;
 }
 
-export interface DraftReviewInput {
+interface DraftReviewInput {
   projectPath: string;
   draftChapterText: string;
   draftChapterNumber: number;

+ 2 - 2
src/lib/agent/tool-events.ts

@@ -2,7 +2,7 @@ import type { AgentRunRecord, AgentStageTrace, AgentToolEvent } from "./types"
 import { activityEventFromToolEvent, applyAgentActivityEvent } from "./activity-trace"
 import { getWorkflowToolDescription, isWebResearchToolName } from "./workflow-trace"
 
-export type ToolCallRecord = AgentRunRecord["toolCalls"][number]
+type ToolCallRecord = AgentRunRecord["toolCalls"][number]
 type SettledToolCallStatus = "done" | "error" | "cancelled"
 
 export function applyAgentToolEvent(
@@ -70,7 +70,7 @@ export function settleRunningAgentToolCalls(
 }
 
 /** chapter_* 子步骤已在工具时间线展示,且细粒度阶段由 onActivityEvent 写入,避免双轨重复。 */
-export function shouldSkipToolActivityForStages(event: AgentToolEvent): boolean {
+function shouldSkipToolActivityForStages(event: AgentToolEvent): boolean {
   return Boolean(event.parentCallId) && event.name.startsWith("chapter_")
 }
 

+ 1 - 1
src/lib/agent/tool-executor.ts

@@ -3,7 +3,7 @@ import type { AgentRunCallbacks, AgentRunRecord, ToolCall } from "./types"
 import { TOOL_EXECUTE_TIMEOUT_MS } from "./types"
 import { isToolErrorResult } from "./tool-result"
 
-export function withToolTimeout<T>(operation: Promise<T>, timeoutMs: number | undefined): Promise<T> {
+function withToolTimeout<T>(operation: Promise<T>, timeoutMs: number | undefined): Promise<T> {
   const resolvedTimeoutMs = timeoutMs ?? TOOL_EXECUTE_TIMEOUT_MS
   if (resolvedTimeoutMs <= 0) return operation
   return new Promise<T>((resolve, reject) => {

+ 1 - 1
src/lib/agent/tools/list-chapters.ts

@@ -3,7 +3,7 @@ import { listDirectory } from "@/commands/fs"
 import { flattenMdFiles } from "@/lib/novel/chapter-utils"
 
 /** 仅匹配「第N章/节/回」,避免 backup-2024 等文件名污染最新章号 */
-export function extractStrictChapterNumber(text: string): number | null {
+function extractStrictChapterNumber(text: string): number | null {
   const m = text.match(/第\s*(\d+)\s*[章节回]/)
   if (!m?.[1]) return null
   const n = Number.parseInt(m[1], 10)

+ 1 - 1
src/lib/agent/tools/list-outlines.ts

@@ -5,7 +5,7 @@ import {
   listOutlineEntries,
 } from "./outline-list-helpers"
 
-export interface ListOutlinesToolOptions {
+interface ListOutlinesToolOptions {
   readTextFile?: (path: string) => Promise<string>
   /** 模型未传 chapterNumber 时的默认目标章号(通常来自当前任务路由) */
   getDefaultChapterNumber?: () => number | undefined

+ 0 - 53
src/lib/agent/tools/mcp-tool.spec.ts

@@ -1,53 +0,0 @@
-import { describe, expect, it, vi } from "vitest"
-import { createMcpTool } from "./mcp-tool"
-import type { McpToolDescriptor } from "@/lib/mcp/types"
-
-const descriptor: McpToolDescriptor = {
-  serverId: "graph",
-  serverName: "Graph MCP",
-  name: "query",
-  description: "Query graph",
-  operation: "read",
-  inputSchema: {
-    type: "object",
-    properties: {
-      query: { type: "string", description: "Query text" },
-    },
-    required: ["query"],
-  },
-}
-
-describe("createMcpTool", () => {
-  it("wraps an MCP caller as a QMai tool", async () => {
-    const caller = vi.fn().mockResolvedValue({
-      status: "ok",
-      content: "graph result",
-      summary: "graph result",
-    })
-    const tool = createMcpTool(descriptor, caller)
-
-    expect(tool.name).toBe("mcp_graph_query")
-    expect(tool.category).toBe("read")
-    expect(tool.permission).toBe("auto")
-
-    const result = JSON.parse(await tool.execute({ query: "hero" }))
-
-    expect(caller).toHaveBeenCalledOnce()
-    expect(result).toMatchObject({
-      status: "ok",
-      serverId: "graph",
-      toolName: "query",
-      content: "graph result",
-    })
-  })
-
-  it("returns a Chinese degradation message when MCP call fails", async () => {
-    const tool = createMcpTool(descriptor, vi.fn().mockRejectedValue(new Error("offline")))
-
-    const result = JSON.parse(await tool.execute({ query: "hero" }))
-
-    expect(result.status).toBe("error")
-    expect(result.message).toContain("MCP 调用失败")
-    expect(result.message).toContain("普通 AI 会话可以继续")
-  })
-})

+ 0 - 1
src/lib/agent/tools/mcp-tool.ts

@@ -1 +0,0 @@
-export { createMcpTool } from "@/lib/mcp/adapter"

+ 0 - 9
src/lib/agent/tools/outline-list-helpers.ts

@@ -20,10 +20,6 @@ export interface OutlineListEntry {
   outlineType?: string
 }
 
-function stripMarkdownExt(name: string): string {
-  return name.replace(/\.md$/i, "")
-}
-
 function scalarFrontmatterString(value: unknown): string | undefined {
   if (typeof value === "string" && value.trim()) return value.trim()
   if (typeof value === "number" && Number.isFinite(value)) return String(value)
@@ -159,8 +155,3 @@ export function buildOutlineListToolResult(
 
   return lines.join("\n")
 }
-
-/** 供测试与调用方:从文件名推导展示名(无扩展名) */
-export function outlineDisplayName(relativePath: string): string {
-  return stripMarkdownExt(relativePath.split("/").pop() ?? relativePath)
-}

+ 2 - 2
src/lib/agent/tools/read-markdown-resource.ts

@@ -15,7 +15,7 @@ interface DirectoryCandidate {
 
 export type ReadTextFile = (path: string) => Promise<string>
 
-export type ReadMarkdownResourceOutcome =
+type ReadMarkdownResourceOutcome =
   | { kind: "file"; content: string; resolvedPath: string }
   | { kind: "directory"; content: string }
   | { kind: "error"; content: string }
@@ -172,7 +172,7 @@ export async function readMarkdownResource(
   return outcome.content
 }
 
-export async function resolveMarkdownResource(
+async function resolveMarkdownResource(
   baseDir: string,
   params: Record<string, unknown>,
   label: string,

+ 1 - 1
src/lib/agent/tools/read-web-page.ts

@@ -14,7 +14,7 @@ interface ReadWebPageDeps {
   fetchPage?: (url: string, init?: RequestInit) => Promise<FetchResponseLike>
 }
 
-export interface ReadWebPageToolResult {
+interface ReadWebPageToolResult {
   status: "ok" | "error"
   url: string
   title?: string

+ 1 - 1
src/lib/agent/tools/run-chapter-workflow.ts

@@ -16,7 +16,7 @@ export type RunDeepChapterGeneration = (
   signal?: AbortSignal,
 ) => Promise<DeepChapterGenerationResult>
 
-export interface RunChapterWorkflowToolOptions {
+interface RunChapterWorkflowToolOptions {
   projectPath: string
   llmConfig: LlmConfig
   aiWorkflowMode: AiWorkflowMode

+ 1 - 1
src/lib/agent/tools/summarize-search-results.ts

@@ -1,6 +1,6 @@
 import type { Tool } from "../types"
 
-export interface SummarizeSearchResultsResult {
+interface SummarizeSearchResultsResult {
   status: "ok" | "not_configured" | "error"
   query: string
   sourceCount: number

+ 1 - 1
src/lib/agent/tools/web-search.ts

@@ -2,7 +2,7 @@ import type { Tool } from "../types"
 import { providerRequiresApiKey, resolveSearchConfig, webSearch } from "@/lib/web-search"
 import type { SearchApiConfig } from "@/stores/wiki-store"
 
-export interface WebSearchToolResult {
+interface WebSearchToolResult {
   status: "ok" | "not_configured" | "error"
   query: string
   provider: string

+ 1 - 1
src/lib/agent/tools/write-chapter.ts

@@ -2,7 +2,7 @@ import type { Tool } from "../types"
 import { readFile, writeFile, fileExists, createDirectory } from "@/commands/fs"
 import { writeDraft } from "@/lib/novel/draft-manager"
 
-export interface WriteChapterOptions {
+interface WriteChapterOptions {
   draftMode?: boolean
   projectPath?: string
   sourceConversationId?: string

+ 4 - 4
src/lib/agent/workflow-trace.ts

@@ -1,7 +1,7 @@
 import type { ContextTrace } from "./context-trace"
 import type { ToolCallStatus } from "./types"
 
-export type AgentWorkflowStepStatus =
+type AgentWorkflowStepStatus =
   | "pending"
   | "running"
   | "done"
@@ -9,7 +9,7 @@ export type AgentWorkflowStepStatus =
   | "error"
   | "cancelled"
 
-export type AgentWorkflowStepKind =
+type AgentWorkflowStepKind =
   | "intent"
   | "skill"
   | "context"
@@ -29,13 +29,13 @@ export interface WorkflowToolCall {
   finishedAt?: number
 }
 
-export interface AgentWorkflowDetail {
+interface AgentWorkflowDetail {
   label: string
   value: string
   tone?: "default" | "muted" | "warning" | "error" | "success"
 }
 
-export interface AgentWorkflowStep {
+interface AgentWorkflowStep {
   id: string
   kind: AgentWorkflowStepKind
   title: string

+ 1 - 1
src/lib/app-title.ts

@@ -1,4 +1,4 @@
-export const APP_NAME = "青幕AI写作"
+const APP_NAME = "青幕AI写作"
 
 export function formatAppTitle(projectName: string | null | undefined, totalWordCount?: number | null): string {
   const name = projectName?.trim()

+ 1 - 1
src/lib/app-update-support.ts

@@ -5,7 +5,7 @@ export const APP_AUTO_UPDATE_RELEASES_URL = "https://github.com/Mochocyang/QMAI/
 export const APP_AUTO_UPDATE_UNSUPPORTED_MESSAGE =
   "当前平台暂不支持应用内自动更新,请前往 GitHub Releases 手动下载安装包。"
 
-export function getDesktopPlatform(): "windows" | "macos" | "linux" | "unknown" {
+function getDesktopPlatform(): "windows" | "macos" | "linux" | "unknown" {
   if (!isTauri()) return "unknown"
 
   const ua = navigator.userAgent

+ 1 - 1
src/lib/app-updater.ts

@@ -11,7 +11,7 @@ type UpdaterBindings = {
 
 let updateCheckStarted = false
 
-export async function runAppUpdateFlow(bindings: UpdaterBindings) {
+async function runAppUpdateFlow(bindings: UpdaterBindings) {
   if (!bindings.isTauri) return
 
   const update = await bindings.check()

+ 1 - 1
src/lib/async-pool.ts

@@ -1,4 +1,4 @@
-export interface MapWithConcurrencyOptions {
+interface MapWithConcurrencyOptions {
   signal?: AbortSignal
   onItemComplete?: (index: number) => void
 }

+ 2 - 2
src/lib/azure-openai.ts

@@ -11,7 +11,7 @@ export function isAzureOpenAiEndpoint(endpoint: string): boolean {
   }
 }
 
-export interface AzureParsedEndpoint {
+interface AzureParsedEndpoint {
   resourceBase: string
   deployment: string
   apiVersion: string
@@ -22,7 +22,7 @@ export interface AzureParsedEndpoint {
  * common shapes (bare host, host + deployment path, full chat URL).
  * The deployment embedded in the path wins over `fallbackDeployment`.
  */
-export function parseAzureOpenAiEndpoint(
+function parseAzureOpenAiEndpoint(
   endpoint: string,
   fallbackDeployment: string,
   fallbackApiVersion: string,

+ 0 - 9
src/lib/backup/types.ts

@@ -85,15 +85,6 @@ export interface BackupContents {
   credentials: boolean
 }
 
-/** 备份清单(zip 内 manifest.json) */
-export interface BackupManifest {
-  backupVersion: number
-  createdAt: string
-  appVersion: string
-  contents?: BackupContents
-  projects: Array<ProjectBackupInfo & { sections?: ProjectBackupSection[] }>
-}
-
 /** manifest 项目条目(含路径可达性) */
 export interface ProjectManifestEntry {
   id: string

+ 2 - 2
src/lib/changelog.ts

@@ -1,4 +1,4 @@
-export interface ChangelogEntry {
+interface ChangelogEntry {
   version: string;
   date: string;
   highlights: {
@@ -1279,7 +1279,7 @@ function isMergedOnePointRelease(version: string): boolean {
   return patch >= 8 && patch <= 32;
 }
 
-export const CHANGELOG: ChangelogEntry[] = [
+const CHANGELOG: ChangelogEntry[] = [
   THREE_POINT_TWO_SEVEN_CHANGELOG,
   THREE_POINT_TWO_SIX_CHANGELOG,
   THREE_POINT_TWO_FIVE_CHANGELOG,

+ 0 - 4
src/lib/chapter-display.ts

@@ -19,7 +19,3 @@ export function getChapterStatusLabel(status: unknown): string {
 export function buildChapterWordCountLabel(wordCount: number): string {
   return `${wordCount}字`
 }
-
-export function buildChapterTotalWordCountLabel(wordCount: number): string {
-  return `总字数:${wordCount}字`
-}

+ 1 - 2
src/lib/chapter-external-update-coordinator.ts

@@ -1,6 +1,5 @@
 import { normalizePath } from "@/lib/path-utils"
-
-export interface ChapterExternalUpdateCoordinator {
+ interface ChapterExternalUpdateCoordinator {
   runExternalUpdate(path: string, write: () => Promise<void>): Promise<number>
   flushBeforeLeave(path: string, write: () => Promise<void>): Promise<boolean>
   markEditorSession(path: string, version?: number): void

+ 3 - 3
src/lib/chat-copy-content.ts

@@ -5,11 +5,11 @@ const WORKFLOW_FINAL_CONTENT_MARKER = "最终正文:"
 const ASSISTANT_ERROR_SUFFIX_RE = /(?:^|\n{1,2})出错:[\s\S]*$/
 
 /** 助手可见内容短于此(去空白)时,视为不足以作为章节正文。 */
-export const THIN_CHAPTER_CONTENT_CHAR_LIMIT = 80
+const THIN_CHAPTER_CONTENT_CHAR_LIMIT = 80
 /** workflow 正文至少这么长(去空白)才值得回退。 */
-export const WORKFLOW_BODY_MIN_CHAR_COUNT = 500
+const WORKFLOW_BODY_MIN_CHAR_COUNT = 500
 /** workflow 相对助手内容的最小倍数,避免用略长草稿覆盖短但有效的改写。 */
-export const WORKFLOW_BODY_MIN_RATIO = 5
+const WORKFLOW_BODY_MIN_RATIO = 5
 
 const COMPLETION_NOTICE_RE =
   /(?:已按章纲|重写完成|章节工作流完成|正文已.{0,12}完成|已(?:生成|重写|完成).{0,8}正文)/

+ 2 - 2
src/lib/claude-cli-transport.ts

@@ -27,7 +27,7 @@ import type { StreamCallbacks } from "./llm-client"
  * real token-level deltas. To avoid double-counting, we prefer deltas
  * when they arrive and skip the fat `assistant` events after seeing one.
  */
-export function createClaudeCodeStreamParser() {
+function createClaudeCodeStreamParser() {
   let sawDelta = false
   // Track the running text we have emitted for the current assistant
   // turn via `assistant` events so we can diff new content off the end
@@ -297,7 +297,7 @@ export async function streamClaudeCodeCli(
  *      telling the user to reproduce in a terminal where they can
  *      see whatever output the CLI does produce.
  */
-export function buildExitError(
+function buildExitError(
   code: number,
   stderr: string,
   unparsedStdout: string = "",

+ 3 - 3
src/lib/codex-app-server-client.ts

@@ -32,7 +32,7 @@ interface PendingRequest {
   timer: ReturnType<typeof setTimeout>
 }
 
-export interface DynamicToolCallRequest {
+interface DynamicToolCallRequest {
   threadId: string
   turnId: string
   callId: string
@@ -41,12 +41,12 @@ export interface DynamicToolCallRequest {
   arguments: unknown
 }
 
-export interface DynamicToolCallResponse {
+interface DynamicToolCallResponse {
   contentItems: Array<{ type: "inputText"; text: string }>
   success: boolean
 }
 
-export interface CodexThreadHandler {
+interface CodexThreadHandler {
   onEnvelope?: (envelope: CodexAppServerEnvelope) => void
   onDynamicToolCall?: (request: DynamicToolCallRequest) => Promise<DynamicToolCallResponse>
 }

+ 1 - 1
src/lib/codex-cli-model.ts

@@ -1,5 +1,5 @@
 export const DEFAULT_CODEX_CLI_MODEL = "gpt-5.6-terra"
-export const LEGACY_DEFAULT_CODEX_CLI_MODEL = "gpt-5.4-mini"
+const LEGACY_DEFAULT_CODEX_CLI_MODEL = "gpt-5.4-mini"
 
 export const CODEX_CLI_SUGGESTED_MODELS = [
   DEFAULT_CODEX_CLI_MODEL,

+ 1 - 1
src/lib/codex-cli-timeout.ts

@@ -1,5 +1,5 @@
 export const DEFAULT_CODEX_CLI_TIMEOUT_MINUTES = 40
-export const MAX_CODEX_CLI_TIMEOUT_MINUTES = 240
+const MAX_CODEX_CLI_TIMEOUT_MINUTES = 240
 
 export function resolveCodexCliTimeoutMinutes(value: number | undefined): number {
   if (!Number.isFinite(value)) return DEFAULT_CODEX_CLI_TIMEOUT_MINUTES

+ 2 - 56
src/lib/connection-tests.ts

@@ -1,5 +1,4 @@
-import type { EmbeddingConfig, LlmConfig } from "@/stores/wiki-store"
-import { fetchEmbedding, getLastEmbeddingError } from "@/lib/embedding"
+import type { LlmConfig } from "@/stores/wiki-store"
 import { streamChat } from "@/lib/llm-client"
 
 export interface ProviderTestResult {
@@ -7,60 +6,7 @@ export interface ProviderTestResult {
   message: string
 }
 
-export const LLM_PROVIDER_TEST_MAX_TOKENS = 512
-
-export async function testEmbeddingConnection(cfg: EmbeddingConfig): Promise<ProviderTestResult> {
-  if (!cfg.endpoint.trim()) {
-    return { ok: false, message: "Embedding endpoint is empty." }
-  }
-  if (!cfg.model.trim()) {
-    return { ok: false, message: "Embedding model is empty." }
-  }
-
-  const started = performance.now()
-  const vector = await fetchEmbedding("LLM Wiki embedding connection test.", cfg, 0)
-  if (!vector) {
-    return {
-      ok: false,
-      message: getLastEmbeddingError() ?? "Embedding endpoint returned no vector.",
-    }
-  }
-
-  return {
-    ok: true,
-    message: `Connected. Returned ${vector.length} dimensions in ${Math.round(performance.now() - started)} ms.`,
-  }
-}
-
-export async function testEmbeddingFunction(cfg: EmbeddingConfig): Promise<ProviderTestResult> {
-  const first = await fetchEmbedding("LLM Wiki functional embedding test: apple banana graph.", cfg, 0)
-  const second = await fetchEmbedding("LLM Wiki functional embedding test: apple banana graph.", cfg, 0)
-  if (!first || !second) {
-    return {
-      ok: false,
-      message: getLastEmbeddingError() ?? "Embedding endpoint did not return vectors.",
-    }
-  }
-  if (first.length !== second.length) {
-    return {
-      ok: false,
-      message: `Embedding dimension changed between calls (${first.length} vs ${second.length}).`,
-    }
-  }
-  if (first.length === 0 || first.some((v) => !Number.isFinite(v)) || second.some((v) => !Number.isFinite(v))) {
-    return { ok: false, message: "Embedding endpoint returned an empty or non-finite vector." }
-  }
-
-  const norm = Math.sqrt(first.reduce((sum, v) => sum + v * v, 0))
-  if (!Number.isFinite(norm) || norm <= 0) {
-    return { ok: false, message: "Embedding vector norm is zero or invalid." }
-  }
-
-  return {
-    ok: true,
-    message: `Functional test passed. Stable ${first.length}-dimension finite vectors returned.`,
-  }
-}
+const LLM_PROVIDER_TEST_MAX_TOKENS = 512
 
 export async function testLlmConnection(cfg: LlmConfig): Promise<ProviderTestResult> {
   const started = performance.now()

+ 10 - 10
src/lib/context-budget.ts

@@ -22,7 +22,7 @@ import i18n from "@/i18n"
 import { normalizeUserLlmContextSize } from "@/lib/llm-context-size"
 
 /** Result of `computeContextBudget`. All values are character counts. */
-export interface ContextBudget {
+interface ContextBudget {
   /** How many characters of prompt text the model's token window holds,
    *  at the active language's density. Falls back to a sensible default
    *  when the caller passes 0/undefined. */
@@ -131,7 +131,7 @@ export function computeNovelContextTokenBudget(
   return cap
 }
 
-export interface ResolveContextPackTokenBudgetInput {
+interface ResolveContextPackTokenBudgetInput {
   maxContextSize?: number
   /**
    * Optional precomputed allocation (planner / composer). Not a user setting.
@@ -163,7 +163,7 @@ export const ANALYSIS_OUTPUT_FRAC = 0.04
  * 1-char-per-token estimator. Window-fraction planning may raise this, but
  * never drops below it — unless the user's maxOutputTokens cap is lower.
  */
-export const CHAPTER_GENERATION_OUTPUT_FLOOR = 15_360
+const CHAPTER_GENERATION_OUTPUT_FLOOR = 15_360
 
 export class LlmContextBudgetError extends Error {
   constructor(message = "模型上下文不足:无法同时容纳系统提示、当前用户请求和最小输出空间。") {
@@ -182,7 +182,7 @@ export class LlmContextBudgetError extends Error {
  */
 const LLM_WINDOW_SAFETY_FRAC = 0.9
 
-export interface LlmRequestBudgetInput {
+interface LlmRequestBudgetInput {
   maxContextSize?: number
   desiredOutputTokens: number
   requestedContextTokens?: number
@@ -199,7 +199,7 @@ export interface LlmRequestBudgetInput {
   thinkingFloorTokens?: number
 }
 
-export interface LlmRequestBudgetPlan {
+interface LlmRequestBudgetPlan {
   windowTokens: number
   outputTokens: number
   contextTokenBudget: number
@@ -259,9 +259,9 @@ export function planLlmRequestBudget(input: LlmRequestBudgetInput): LlmRequestBu
   }
 }
 
-export type ChapterBudgetStage = "analysis" | "generation"
+type ChapterBudgetStage = "analysis" | "generation"
 
-export interface PlanChapterRequestBudgetInput {
+interface PlanChapterRequestBudgetInput {
   maxContextSize?: number
   contextTokenBudget?: number
   chapterTargetChars?: number
@@ -300,7 +300,7 @@ export function planChapterRequestBudget(
 
 export type OutlineBudgetStage = "analysis" | "generation"
 
-export interface PlanOutlineRequestBudgetInput {
+interface PlanOutlineRequestBudgetInput {
   maxContextSize?: number
   contextTokenBudget?: number
   stage: OutlineBudgetStage
@@ -332,7 +332,7 @@ export function planOutlineRequestBudget(
   })
 }
 
-export interface ComputeWritingContextPackTokenBudgetInput {
+interface ComputeWritingContextPackTokenBudgetInput {
   maxContextSize?: number
   contextTokenBudget?: number
   chapterTargetChars?: number
@@ -357,7 +357,7 @@ export function computeWritingContextPackTokenBudget(
 /** Legacy single-pass outline ingest floor; kept so small windows still behave predictably. */
 export const OUTLINE_INGEST_MIN_BODY_BUDGET = 8_000
 /** Upper cap aligned with wiki long-source ingest. */
-export const OUTLINE_INGEST_MAX_BODY_BUDGET = 300_000
+const OUTLINE_INGEST_MAX_BODY_BUDGET = 300_000
 
 function clampBudget(value: number, min: number, max: number): number {
   return Math.max(min, Math.min(max, value))

+ 3 - 3
src/lib/context-hub/chapter-body-injection.ts

@@ -12,7 +12,7 @@ export interface ChapterRef {
   savedAt: number
 }
 
-export interface ChapterHistoryMessage {
+interface ChapterHistoryMessage {
   id?: string
   role: string
   content: string
@@ -26,7 +26,7 @@ export interface ChapterHistoryMessage {
   reasoning_content?: string
 }
 
-export interface BuildHistoryContentDeps {
+interface BuildHistoryContentDeps {
   projectPath: string
   novelMode: boolean
   /** When false, skip folding even if the chapter is on disk. */
@@ -80,7 +80,7 @@ export function resolveChapterNumberFromMessage(
   return fromText && fromText > 0 ? fromText : undefined
 }
 
-export async function resolveSavedChapterPath(
+async function resolveSavedChapterPath(
   projectPath: string,
   chapterNumber: number,
   pathMemo?: Map<number, string | null>,

+ 2 - 2
src/lib/context-hub/composer.ts

@@ -8,7 +8,7 @@ import {
   type DependencyStamp,
 } from "./types"
 
-export interface ComposeContextInput {
+interface ComposeContextInput {
   contextPack: ContextPack
   sessionSummary?: string
   dependencyStamp: DependencyStamp
@@ -19,7 +19,7 @@ export interface ComposeContextInput {
   /** Model context window in tokens (wiki-store `maxContextSize`). */
   maxContextSize?: number
 }
-export interface ComposedContext {
+interface ComposedContext {
   stableCore: string
   sessionSummary: string
   dynamicContext: string

Algunos archivos no se mostraron porque demasiados archivos cambiaron en este cambio