Răsfoiți Sursa

feat(reference): complete reference container

Mochocyang 2 luni în urmă
părinte
comite
3978a084ae

+ 52 - 1
src/components/layout/knowledge-tree.tsx

@@ -1,5 +1,5 @@
 import { useCallback, useEffect, useMemo, useRef, useState } from "react"
-import { BookOpen, ChevronDown, ChevronRight, FileText, Folder, FolderInput, FolderOpen, Globe, Loader2, Pencil, Plus, Trash2, Check, X } from "lucide-react"
+import { BookOpen, ChevronDown, ChevronRight, FileText, Folder, FolderInput, FolderOpen, Globe, Loader2, MessageCircle, Pencil, Plus, Trash2, Check, X } from "lucide-react"
 import { useTranslation } from "react-i18next"
 import { ScrollArea } from "@/components/ui/scroll-area"
 import { Button } from "@/components/ui/button"
@@ -14,6 +14,7 @@ import { moveFileToTrash } from "@/lib/trash"
 import { makeChapterFileName, makeDefaultChapterTitle, makeSafeFileSlug } from "@/lib/wiki-filename"
 import { useImportProgressStore } from "@/stores/import-progress-store"
 import { saveLastReadChapter } from "@/lib/project-store"
+import type { ReferenceToken } from "@/lib/reference/types"
 
 interface WikiPageInfo {
   path: string
@@ -39,6 +40,8 @@ interface KnowledgeTreeProps {
   pendingPages?: WikiPageInfo[]
   onRemovePendingPage?: (pagePath: string) => void
   onRequestCreate?: (request: KnowledgeCreateRequest) => void
+  onSendToChat?: (token: ReferenceToken) => void
+  onSendToOutline?: (token: ReferenceToken) => void
 }
 
 interface CreateMenuState {
@@ -110,6 +113,20 @@ function getDirName(path: string): string {
   return index >= 0 ? normalized.slice(0, index) : ""
 }
 
+function truncateReferenceTitle(title: string, maxLen = 20): string {
+  return title.length > maxLen ? `${title.slice(0, maxLen)}...` : title
+}
+
+function createChapterReferenceToken(page: WikiPageInfo): ReferenceToken {
+  return {
+    id: globalThis.crypto?.randomUUID?.() || Math.random().toString(36).slice(2),
+    category: "chapter",
+    title: page.title,
+    path: page.path,
+    displayTitle: truncateReferenceTitle(page.title),
+  }
+}
+
 async function getUniquePagePath(dir: string, fileName: string, excludePath?: string): Promise<string> {
   const firstPath = `${dir}/${fileName}`
   if (firstPath === excludePath || !(await fileExists(firstPath))) return firstPath
@@ -261,6 +278,8 @@ export function KnowledgeTree({
   pendingPages = [],
   onRemovePendingPage,
   onRequestCreate,
+  onSendToChat,
+  onSendToOutline,
 }: KnowledgeTreeProps) {
   const { t } = useTranslation()
   const project = useWikiStore((s) => s.project)
@@ -1392,6 +1411,38 @@ export function KnowledgeTree({
               <FolderOpen className="h-4 w-4" />
               打开文件所在位置
             </button>
+            {filterType === "chapter" && (onSendToChat || onSendToOutline) && (
+              <div className="mt-1 border-t pt-1">
+                {onSendToChat && (
+                  <button
+                    type="button"
+                    className="flex w-full items-center gap-2 px-3 py-2 text-left hover:bg-accent"
+                    onClick={() => {
+                      const target = pageInfoByPath.get(pageMenu.path)
+                      if (target) onSendToChat(createChapterReferenceToken(target))
+                      setPageMenu(null)
+                    }}
+                  >
+                    <MessageCircle className="h-3.5 w-3.5" />
+                    发送到AI会话
+                  </button>
+                )}
+                {onSendToOutline && (
+                  <button
+                    type="button"
+                    className="flex w-full items-center gap-2 px-3 py-2 text-left hover:bg-accent"
+                    onClick={() => {
+                      const target = pageInfoByPath.get(pageMenu.path)
+                      if (target) onSendToOutline(createChapterReferenceToken(target))
+                      setPageMenu(null)
+                    }}
+                  >
+                    <FileText className="h-3.5 w-3.5" />
+                    发送到AI大纲
+                  </button>
+                )}
+              </div>
+            )}
           </div>
         )}
       </div>

+ 64 - 0
src/components/reference/ReferenceChip.spec.tsx

@@ -0,0 +1,64 @@
+// @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 { ReferenceChip } from "./ReferenceChip"
+import type { ReferenceToken } from "@/lib/reference/types"
+
+let host: HTMLDivElement
+let root: Root
+
+const token: ReferenceToken = {
+  id: "ref-1",
+  category: "chapter",
+  title: "第一章",
+  displayTitle: "第一章",
+  path: "C:/Novel/wiki/chapters/第一章.md",
+}
+
+beforeEach(() => {
+  host = document.createElement("div")
+  document.body.appendChild(host)
+  root = createRoot(host)
+})
+
+afterEach(() => {
+  act(() => {
+    root.unmount()
+  })
+  host.remove()
+})
+
+describe("ReferenceChip", () => {
+  it("renders editable chip metadata and calls onRemove", async () => {
+    const onRemove = vi.fn()
+
+    await act(async () => {
+      root.render(<ReferenceChip token={token} onRemove={onRemove} />)
+    })
+
+    const chip = host.querySelector("[data-reference-id='ref-1']")
+    expect(chip?.textContent).toContain("@第一章")
+    expect(chip?.getAttribute("data-reference-category")).toBe("chapter")
+    expect(chip?.getAttribute("contenteditable")).toBe("false")
+
+    const button = host.querySelector("button")
+    expect(button?.getAttribute("aria-label")).toBe("移除引用 第一章")
+
+    await act(async () => {
+      button?.click()
+    })
+
+    expect(onRemove).toHaveBeenCalledWith("ref-1")
+  })
+
+  it("renders readonly chip without a remove button", async () => {
+    await act(async () => {
+      root.render(<ReferenceChip token={token} readonly />)
+    })
+
+    expect(host.textContent).toBe("@第一章")
+    expect(host.querySelector("button")).toBeNull()
+  })
+})

+ 50 - 0
src/components/reference/ReferenceChip.tsx

@@ -0,0 +1,50 @@
+import type { ReferenceToken } from "@/lib/reference/types"
+
+const categoryColors: Record<ReferenceToken["category"], string> = {
+  chapter: "bg-purple-100 text-purple-700 border-purple-300",
+  memory: "bg-green-100 text-green-700 border-green-300",
+  outline: "bg-blue-100 text-blue-700 border-blue-300",
+  deduction: "bg-orange-100 text-orange-700 border-orange-300",
+  skill: "bg-yellow-100 text-yellow-700 border-yellow-300",
+  chat_history: "bg-cyan-100 text-cyan-700 border-cyan-300",
+  outline_history: "bg-indigo-100 text-indigo-700 border-indigo-300",
+}
+
+interface ReferenceChipProps {
+  token: ReferenceToken
+  readonly?: boolean
+  onRemove?: (id: string) => void
+}
+
+export function ReferenceChip({
+  token,
+  readonly = false,
+  onRemove,
+}: ReferenceChipProps) {
+  const colorClass = categoryColors[token.category]
+
+  return (
+    <span
+      className={`inline-flex items-center gap-1 rounded-md border px-2 py-0.5 text-xs font-medium ${colorClass} mx-0.5`}
+      contentEditable={readonly ? undefined : false}
+      data-reference-id={token.id}
+      data-reference-category={token.category}
+    >
+      @{token.displayTitle}
+      {!readonly && (
+        <button
+          type="button"
+          className="ml-0.5 cursor-pointer text-gray-400 hover:text-red-500"
+          onClick={(event) => {
+            event.preventDefault()
+            onRemove?.(token.id)
+          }}
+          tabIndex={-1}
+          aria-label={`移除引用 ${token.displayTitle}`}
+        >
+          ×
+        </button>
+      )}
+    </span>
+  )
+}

+ 146 - 0
src/components/reference/ReferenceInput.spec.tsx

@@ -0,0 +1,146 @@
+// @vitest-environment jsdom
+
+import { act, useRef, useState } from "react"
+import { createRoot, type Root } from "react-dom/client"
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
+import { ReferenceInput, type InsertReferenceTokens } from "./ReferenceInput"
+import type { ReferenceToken } from "@/lib/reference/types"
+
+let host: HTMLDivElement
+let root: Root
+
+const token: ReferenceToken = {
+  id: "ref-1",
+  category: "chapter",
+  title: "第一章",
+  displayTitle: "第一章",
+  path: "C:/Novel/wiki/chapters/第一章.md",
+}
+
+function dispatchInput(element: HTMLElement, text: string) {
+  element.textContent = text
+  element.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText", data: text }))
+}
+
+beforeEach(() => {
+  host = document.createElement("div")
+  document.body.appendChild(host)
+  root = createRoot(host)
+})
+
+afterEach(() => {
+  act(() => {
+    root.unmount()
+  })
+  host.remove()
+})
+
+describe("ReferenceInput", () => {
+  it("triggers reference picker from @ key and button", async () => {
+    const onAtTrigger = vi.fn()
+
+    await act(async () => {
+      root.render(
+        <ReferenceInput
+          tokens={[]}
+          onSubmit={vi.fn()}
+          onAtTrigger={onAtTrigger}
+        />,
+      )
+    })
+
+    const editor = host.querySelector("[contenteditable='true']") as HTMLElement
+    await act(async () => {
+      editor.dispatchEvent(new KeyboardEvent("keydown", { key: "@", bubbles: true, cancelable: true }))
+    })
+    await act(async () => {
+      host.querySelector<HTMLButtonElement>("[aria-label='引用内容']")?.click()
+    })
+
+    expect(onAtTrigger).toHaveBeenCalledTimes(2)
+  })
+
+  it("does not submit when only references are present", async () => {
+    const onSubmit = vi.fn()
+
+    await act(async () => {
+      root.render(<ReferenceInput tokens={[token]} onSubmit={onSubmit} />)
+    })
+
+    const send = host.querySelector<HTMLButtonElement>("[aria-label='发送消息']")
+    expect(send?.disabled).toBe(true)
+
+    const editor = host.querySelector("[contenteditable='true']") as HTMLElement
+    await act(async () => {
+      editor.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }))
+    })
+
+    expect(onSubmit).not.toHaveBeenCalled()
+  })
+
+  it("submits plain text with current tokens", async () => {
+    const onSubmit = vi.fn()
+
+    await act(async () => {
+      root.render(<ReferenceInput tokens={[token]} onSubmit={onSubmit} />)
+    })
+
+    const editor = host.querySelector("[contenteditable='true']") as HTMLElement
+    await act(async () => {
+      dispatchInput(editor, "请参考这章")
+    })
+
+    await act(async () => {
+      host.querySelector<HTMLButtonElement>("[aria-label='发送消息']")?.click()
+    })
+
+    expect(onSubmit).toHaveBeenCalledWith("请参考这章", [token])
+  })
+
+  it("supports inserting and removing tokens through callbacks", async () => {
+    const inserted: ReferenceToken = {
+      ...token,
+      id: "ref-2",
+      title: "第二章",
+      displayTitle: "第二章",
+    }
+    const onChange = vi.fn()
+
+    function Harness() {
+      const [tokens, setTokens] = useState<ReferenceToken[]>([])
+      const insertTokensRef = useRef<InsertReferenceTokens>(null)
+      return (
+        <>
+          <ReferenceInput
+            tokens={tokens}
+            onTokensChange={setTokens}
+            onChange={onChange}
+            onSubmit={vi.fn()}
+            insertTokensRef={insertTokensRef}
+          />
+          <button type="button" onClick={() => insertTokensRef.current?.([inserted])}>
+            插入
+          </button>
+        </>
+      )
+    }
+
+    await act(async () => {
+      root.render(<Harness />)
+    })
+
+    await act(async () => {
+      Array.from(host.querySelectorAll("button")).find((button) => button.textContent === "插入")?.click()
+    })
+
+    expect(host.textContent).toContain("@第二章")
+    expect(onChange).toHaveBeenLastCalledWith("", [inserted])
+
+    await act(async () => {
+      host.querySelector<HTMLButtonElement>("[aria-label='移除引用 第二章']")?.click()
+    })
+
+    expect(host.textContent).not.toContain("@第二章")
+    expect(onChange).toHaveBeenLastCalledWith("", [])
+  })
+})

+ 180 - 0
src/components/reference/ReferenceInput.tsx

@@ -0,0 +1,180 @@
+import {
+  useCallback,
+  useEffect,
+  useMemo,
+  useRef,
+  useState,
+  type MutableRefObject,
+} from "react"
+import { ArrowUp, AtSign } from "lucide-react"
+import { isImeComposing } from "@/lib/keyboard-utils"
+import type { ReferenceToken } from "@/lib/reference/types"
+import { ReferenceChip } from "./ReferenceChip"
+
+export type InsertReferenceTokens = ((tokens: ReferenceToken[]) => void) | null
+
+interface ReferenceInputProps {
+  value?: string
+  tokens: ReferenceToken[]
+  placeholder?: string
+  disabled?: boolean
+  onChange?: (plainText: string, tokens: ReferenceToken[]) => void
+  onTokensChange?: (tokens: ReferenceToken[]) => void
+  onSubmit: (plainText: string, tokens: ReferenceToken[]) => void
+  onAtTrigger?: () => void
+  insertTokensRef?: MutableRefObject<InsertReferenceTokens>
+}
+
+function extractPlainText(editor: HTMLDivElement | null): string {
+  if (!editor) return ""
+  return Array.from(editor.childNodes)
+    .filter((node) => {
+      if (node.nodeType === Node.TEXT_NODE) return true
+      if (node instanceof HTMLElement) {
+        return !node.hasAttribute("data-reference-id")
+      }
+      return false
+    })
+    .map((node) => node.textContent ?? "")
+    .join("")
+    .replace(/\u00a0/g, " ")
+}
+
+export function ReferenceInput({
+  value,
+  tokens,
+  placeholder = "输入提示词,或 @ 引用内容...",
+  disabled = false,
+  onChange,
+  onTokensChange,
+  onSubmit,
+  onAtTrigger,
+  insertTokensRef,
+}: ReferenceInputProps) {
+  const editorRef = useRef<HTMLDivElement>(null)
+  const isControlled = value !== undefined
+  const [draft, setDraft] = useState("")
+  const text = isControlled ? value : draft
+  const canSubmit = text.trim().length > 0 && !disabled
+
+  const notifyChange = useCallback(
+    (nextText: string, nextTokens: ReferenceToken[]) => {
+      if (!isControlled) setDraft(nextText)
+      onChange?.(nextText, nextTokens)
+    },
+    [isControlled, onChange],
+  )
+
+  const updateTokens = useCallback(
+    (nextTokens: ReferenceToken[]) => {
+      onTokensChange?.(nextTokens)
+      onChange?.(text, nextTokens)
+    },
+    [onChange, onTokensChange, text],
+  )
+
+  useEffect(() => {
+    if (!insertTokensRef) return
+    insertTokensRef.current = (nextTokens) => {
+      if (nextTokens.length === 0) return
+      updateTokens([...tokens, ...nextTokens])
+      editorRef.current?.focus()
+    }
+    return () => {
+      insertTokensRef.current = null
+    }
+  }, [insertTokensRef, tokens, updateTokens])
+
+  const handleInput = useCallback(() => {
+    notifyChange(extractPlainText(editorRef.current), tokens)
+  }, [notifyChange, tokens])
+
+  const handleRemoveToken = useCallback(
+    (id: string) => {
+      updateTokens(tokens.filter((token) => token.id !== id))
+    },
+    [tokens, updateTokens],
+  )
+
+  const handleSubmit = useCallback(() => {
+    const plainText = text.trim()
+    if (!plainText || disabled) return
+    onSubmit(plainText, tokens)
+  }, [disabled, onSubmit, text, tokens])
+
+  const handleKeyDown = useCallback(
+    (event: React.KeyboardEvent<HTMLDivElement>) => {
+      if (isImeComposing(event)) return
+
+      if (event.key === "@" && !event.shiftKey && !event.ctrlKey && !event.metaKey) {
+        event.preventDefault()
+        onAtTrigger?.()
+        return
+      }
+
+      if (event.key === "Enter" && !event.shiftKey) {
+        event.preventDefault()
+        handleSubmit()
+      }
+    },
+    [handleSubmit, onAtTrigger],
+  )
+
+  const renderedTokens = useMemo(
+    () => tokens.map((token) => (
+      <ReferenceChip
+        key={token.id}
+        token={token}
+        onRemove={handleRemoveToken}
+      />
+    )),
+    [handleRemoveToken, tokens],
+  )
+
+  return (
+    <div className="rounded-lg border bg-background shadow-sm focus-within:ring-2 focus-within:ring-blue-400">
+      <div className="relative px-3 py-2">
+        <div
+          ref={editorRef}
+          className="min-h-[48px] max-h-[200px] overflow-y-auto whitespace-pre-wrap break-words text-sm outline-none disabled:cursor-not-allowed"
+          contentEditable={!disabled}
+          suppressContentEditableWarning
+          onInput={handleInput}
+          onKeyDown={handleKeyDown}
+          aria-label="引用输入框"
+        >
+          {renderedTokens}
+          {text}
+        </div>
+        {!text.trim() && tokens.length === 0 && (
+          <div className="pointer-events-none absolute left-3 top-2 text-sm text-muted-foreground">
+            {placeholder}
+          </div>
+        )}
+      </div>
+
+      <div className="flex items-center justify-between border-t px-2 py-1.5">
+        <button
+          type="button"
+          className="rounded-md p-1.5 text-gray-500 hover:bg-accent hover:text-blue-500 disabled:cursor-not-allowed disabled:opacity-50"
+          onClick={() => onAtTrigger?.()}
+          disabled={disabled}
+          title="引用内容"
+          aria-label="引用内容"
+        >
+          <AtSign className="h-4 w-4" />
+        </button>
+        <button
+          type="button"
+          className="rounded-md bg-blue-500 p-1.5 text-white hover:bg-blue-600 disabled:cursor-not-allowed disabled:bg-gray-200 disabled:text-gray-400"
+          disabled={!canSubmit}
+          onClick={handleSubmit}
+          title="发送消息"
+          aria-label="发送消息"
+        >
+          <ArrowUp className="h-4 w-4" />
+        </button>
+      </div>
+    </div>
+  )
+}

+ 154 - 0
src/components/reference/ReferencePickerDialog.spec.tsx

@@ -0,0 +1,154 @@
+// @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 { ReferencePickerDialog } from "./ReferencePickerDialog"
+import type { ReferenceProvider } from "@/lib/reference/providers"
+import type { ReferenceToken } from "@/lib/reference/types"
+
+let host: HTMLDivElement
+let root: Root
+
+function makeToken(index: number): ReferenceToken {
+  return {
+    id: `ref-${index}`,
+    category: "chapter",
+    title: `章节${index}`,
+    displayTitle: `章节${index}`,
+    path: `C:/Novel/wiki/chapters/章节${index}.md`,
+  }
+}
+
+const chapterItems = Array.from({ length: 11 }, (_, index) => makeToken(index + 1))
+
+const providers: ReferenceProvider[] = [
+  {
+    category: "chapter",
+    fetchItems: vi.fn(async () => chapterItems),
+  },
+  {
+    category: "memory",
+    fetchItems: vi.fn(async () => [{
+      id: "memory-1",
+      category: "memory" as const,
+      title: "人物",
+      displayTitle: "人物",
+      path: "C:/Novel/wiki/memory/人物.md",
+    }]),
+  },
+]
+
+async function flush() {
+  await act(async () => {
+    await Promise.resolve()
+  })
+}
+
+beforeEach(() => {
+  host = document.createElement("div")
+  document.body.appendChild(host)
+  root = createRoot(host)
+})
+
+afterEach(() => {
+  act(() => {
+    root.unmount()
+  })
+  host.remove()
+})
+
+describe("ReferencePickerDialog", () => {
+  it("renders nothing when closed", async () => {
+    await act(async () => {
+      root.render(
+        <ReferencePickerDialog
+          open={false}
+          providers={providers}
+          projectPath="C:/Novel"
+          onConfirm={vi.fn()}
+          onClose={vi.fn()}
+        />,
+      )
+    })
+
+    expect(host.textContent).toBe("")
+  })
+
+  it("loads items, filters search, and confirms selected tokens", async () => {
+    const onConfirm = vi.fn()
+    const onClose = vi.fn()
+
+    await act(async () => {
+      root.render(
+        <ReferencePickerDialog
+          open
+          providers={providers}
+          projectPath="C:/Novel"
+          onConfirm={onConfirm}
+          onClose={onClose}
+        />,
+      )
+    })
+    await flush()
+
+    expect(providers[0].fetchItems).toHaveBeenCalledWith("C:/Novel")
+    expect(host.textContent).toContain("选择引用内容")
+    expect(host.textContent).toContain("章节1")
+
+    const search = host.querySelector("input[type='search']") as HTMLInputElement
+    await act(async () => {
+      const valueSetter = Object.getOwnPropertyDescriptor(
+        window.HTMLInputElement.prototype,
+        "value",
+      )?.set
+      valueSetter?.call(search, "章节11")
+      search.dispatchEvent(new Event("input", { bubbles: true }))
+    })
+
+    expect(host.textContent).toContain("章节11")
+    expect(host.textContent).not.toContain("章节1章节2")
+
+    const checkbox = host.querySelector("input[type='checkbox']") as HTMLInputElement
+    await act(async () => {
+      checkbox.click()
+    })
+
+    const confirm = Array.from(host.querySelectorAll("button")).find(
+      (button) => button.textContent === "确认",
+    )
+    await act(async () => {
+      confirm?.click()
+    })
+
+    expect(onConfirm).toHaveBeenCalledWith([expect.objectContaining({ title: "章节11" })])
+    expect(onClose).toHaveBeenCalled()
+  })
+
+  it("does not select more than the maximum reference count", async () => {
+    await act(async () => {
+      root.render(
+        <ReferencePickerDialog
+          open
+          providers={providers}
+          projectPath="C:/Novel"
+          onConfirm={vi.fn()}
+          onClose={vi.fn()}
+        />,
+      )
+    })
+    await flush()
+
+    const checkboxes = Array.from(host.querySelectorAll("input[type='checkbox']")) as HTMLInputElement[]
+    for (const checkbox of checkboxes) {
+      await act(async () => {
+        checkbox.click()
+      })
+    }
+
+    const checked = Array.from(host.querySelectorAll("input[type='checkbox']:checked"))
+    expect(checked).toHaveLength(10)
+    expect(host.textContent).toContain("已选 10/10")
+    expect(host.textContent).toContain("已达上限")
+  })
+})

+ 193 - 0
src/components/reference/ReferencePickerDialog.tsx

@@ -0,0 +1,193 @@
+import { useEffect, useMemo, useState } from "react"
+import type { ReferenceCategory, ReferenceToken } from "@/lib/reference/types"
+import { MAX_REFERENCE_COUNT, REFERENCE_TABS } from "@/lib/reference/types"
+import type { ReferenceProvider } from "@/lib/reference/providers"
+
+interface ReferencePickerDialogProps {
+  open: boolean
+  providers: ReferenceProvider[]
+  projectPath: string
+  onConfirm: (tokens: ReferenceToken[]) => void
+  onClose: () => void
+  defaultTab?: ReferenceCategory
+}
+
+export function ReferencePickerDialog({
+  open,
+  providers,
+  projectPath,
+  onConfirm,
+  onClose,
+  defaultTab = "chapter",
+}: ReferencePickerDialogProps) {
+  const [activeTab, setActiveTab] = useState<ReferenceCategory>(defaultTab)
+  const [items, setItems] = useState<ReferenceToken[]>([])
+  const [selected, setSelected] = useState<ReferenceToken[]>([])
+  const [search, setSearch] = useState("")
+  const [loading, setLoading] = useState(false)
+
+  useEffect(() => {
+    if (!open) return
+    setActiveTab(defaultTab)
+    setSelected([])
+    setSearch("")
+  }, [open, defaultTab])
+
+  useEffect(() => {
+    if (!open) return
+
+    const provider = providers.find((candidate) => candidate.category === activeTab)
+    if (!provider) {
+      setItems([])
+      return
+    }
+
+    let cancelled = false
+    setLoading(true)
+    provider
+      .fetchItems(projectPath)
+      .then((nextItems) => {
+        if (!cancelled) setItems(nextItems)
+      })
+      .catch(() => {
+        if (!cancelled) setItems([])
+      })
+      .finally(() => {
+        if (!cancelled) setLoading(false)
+      })
+
+    return () => {
+      cancelled = true
+    }
+  }, [activeTab, open, projectPath, providers])
+
+  const filteredItems = useMemo(() => {
+    const keyword = search.trim().toLowerCase()
+    if (!keyword) return items
+    return items.filter((item) => item.title.toLowerCase().includes(keyword))
+  }, [items, search])
+
+  function toggleItem(item: ReferenceToken) {
+    setSelected((prev) => {
+      const exists = prev.some((selectedItem) => selectedItem.id === item.id)
+      if (exists) return prev.filter((selectedItem) => selectedItem.id !== item.id)
+      if (prev.length >= MAX_REFERENCE_COUNT) return prev
+      return [...prev, item]
+    })
+  }
+
+  function handleConfirm() {
+    onConfirm(selected)
+    onClose()
+  }
+
+  if (!open) return null
+
+  return (
+    <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
+      <div className="flex max-h-[520px] w-[640px] max-w-[calc(100vw-32px)] flex-col overflow-hidden rounded-lg bg-white shadow-xl">
+        <div className="flex items-center justify-between border-b px-4 py-3">
+          <h3 className="text-base font-semibold">选择引用内容</h3>
+          <button
+            type="button"
+            onClick={onClose}
+            className="text-lg leading-none text-gray-400 hover:text-gray-600"
+            aria-label="关闭引用选择弹窗"
+          >
+            ×
+          </button>
+        </div>
+
+        <div className="flex min-h-0 flex-1 overflow-hidden">
+          <div className="flex w-28 flex-col gap-0.5 border-r bg-gray-50 py-2">
+            {REFERENCE_TABS.map((tab) => {
+              if (!providers.some((provider) => provider.category === tab.key)) return null
+              return (
+                <button
+                  key={tab.key}
+                  type="button"
+                  className={`px-3 py-2 text-left text-sm transition-colors ${
+                    activeTab === tab.key
+                      ? "border-r-2 border-blue-600 bg-white font-medium text-blue-600"
+                      : "text-gray-600 hover:bg-gray-100"
+                  }`}
+                  onClick={() => {
+                    setActiveTab(tab.key)
+                    setSearch("")
+                  }}
+                >
+                  {tab.icon} {tab.label}
+                </button>
+              )
+            })}
+          </div>
+
+          <div className="flex min-w-0 flex-1 flex-col">
+            <div className="border-b px-3 py-2">
+              <input
+                type="search"
+                className="w-full rounded-md border px-2 py-1.5 text-sm outline-none focus:ring-1 focus:ring-blue-400"
+                placeholder="搜索..."
+                value={search}
+                onChange={(event) => setSearch(event.target.value)}
+              />
+            </div>
+
+            <div className="min-h-[260px] flex-1 overflow-y-auto px-2 py-1">
+              {loading ? (
+                <div className="py-8 text-center text-sm text-gray-400">加载中...</div>
+              ) : filteredItems.length === 0 ? (
+                <div className="py-8 text-center text-sm text-gray-400">暂无内容</div>
+              ) : (
+                filteredItems.map((item) => {
+                  const isSelected = selected.some((selectedItem) => selectedItem.id === item.id)
+                  return (
+                    <label
+                      key={item.id}
+                      className={`flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-sm hover:bg-blue-50 ${
+                        isSelected ? "bg-blue-50" : ""
+                      }`}
+                    >
+                      <input
+                        type="checkbox"
+                        checked={isSelected}
+                        onChange={() => toggleItem(item)}
+                        className="accent-blue-500"
+                      />
+                      <span className="min-w-0 flex-1 truncate">{item.title}</span>
+                    </label>
+                  )
+                })
+              )}
+            </div>
+
+            <div className="border-t px-3 py-2 text-xs text-gray-400">
+              已选 {selected.length}/{MAX_REFERENCE_COUNT}
+              {selected.length >= MAX_REFERENCE_COUNT && (
+                <span className="ml-1 text-red-400">(已达上限)</span>
+              )}
+            </div>
+          </div>
+        </div>
+
+        <div className="flex justify-end gap-2 border-t px-4 py-3">
+          <button
+            type="button"
+            className="rounded-md border px-4 py-1.5 text-sm hover:bg-gray-50"
+            onClick={onClose}
+          >
+            取消
+          </button>
+          <button
+            type="button"
+            className="rounded-md bg-blue-500 px-4 py-1.5 text-sm text-white hover:bg-blue-600 disabled:cursor-not-allowed disabled:opacity-50"
+            disabled={selected.length === 0}
+            onClick={handleConfirm}
+          >
+            确认
+          </button>
+        </div>
+      </div>
+    </div>
+  )
+}

+ 97 - 0
src/lib/reference/providers.spec.ts

@@ -0,0 +1,97 @@
+import { beforeEach, describe, expect, it, vi } from "vitest"
+import {
+  chapterProvider,
+  createChatHistoryProvider,
+  createOutlineHistoryProvider,
+  createSkillProvider,
+  deductionProvider,
+  memoryProvider,
+  outlineProvider,
+} from "./providers"
+
+const mocks = vi.hoisted(() => ({
+  listDirectory: vi.fn(),
+}))
+
+vi.mock("@/commands/fs", () => ({
+  listDirectory: mocks.listDirectory,
+}))
+
+function file(name: string) {
+  return { name, path: name, is_dir: false }
+}
+
+function dir(name: string) {
+  return { name, path: name, is_dir: true }
+}
+
+describe("reference providers", () => {
+  beforeEach(() => {
+    mocks.listDirectory.mockReset()
+  })
+
+  it("loads chapter markdown files as reference tokens", async () => {
+    mocks.listDirectory.mockResolvedValue([
+      file("第一章.md"),
+      file("notes.txt"),
+      dir("nested"),
+    ])
+
+    const result = await chapterProvider.fetchItems("C:\\Novel")
+
+    expect(mocks.listDirectory).toHaveBeenCalledWith("C:/Novel/wiki/chapters")
+    expect(result).toMatchObject([
+      {
+        category: "chapter",
+        title: "第一章",
+        path: "C:/Novel/wiki/chapters/第一章.md",
+        displayTitle: "第一章",
+      },
+    ])
+    expect(result[0].id).toEqual(expect.any(String))
+  })
+
+  it("loads memory, outline, and deduction files from their project folders", async () => {
+    mocks.listDirectory
+      .mockResolvedValueOnce([file("人物.md")])
+      .mockResolvedValueOnce([file("主线.md")])
+      .mockResolvedValueOnce([file("推演.json")])
+
+    await expect(memoryProvider.fetchItems("C:/Novel")).resolves.toMatchObject([
+      { category: "memory", title: "人物", path: "C:/Novel/wiki/memory/人物.md" },
+    ])
+    await expect(outlineProvider.fetchItems("C:/Novel")).resolves.toMatchObject([
+      { category: "outline", title: "主线", path: "C:/Novel/wiki/outlines/主线.md" },
+    ])
+    await expect(deductionProvider.fetchItems("C:/Novel")).resolves.toMatchObject([
+      { category: "deduction", title: "推演", path: "C:/Novel/.qmai/simulations/推演.json" },
+    ])
+  })
+
+  it("returns an empty list when a file source cannot be read", async () => {
+    mocks.listDirectory.mockRejectedValue(new Error("missing"))
+
+    await expect(chapterProvider.fetchItems("C:/Novel")).resolves.toEqual([])
+  })
+
+  it("creates skill and conversation history providers", async () => {
+    const skills = createSkillProvider(() => [{ id: "s1", name: "长标题".repeat(12) }])
+    const chats = createChatHistoryProvider(() => [{ id: "c1", title: "对话一" }])
+    const outlines = createOutlineHistoryProvider(() => [{ id: "o1", title: "大纲一" }])
+
+    await expect(skills.fetchItems("")).resolves.toMatchObject([
+      {
+        category: "skill",
+        title: "长标题".repeat(12),
+        skillId: "s1",
+        displayTitle: "长标题长标题长标题长标题长标题长标题长标...",
+      },
+    ])
+    await expect(chats.fetchItems("")).resolves.toMatchObject([
+      { category: "chat_history", title: "对话一", conversationId: "c1" },
+    ])
+    await expect(outlines.fetchItems("")).resolves.toMatchObject([
+      { category: "outline_history", title: "大纲一", conversationId: "o1" },
+    ])
+  })
+})

+ 130 - 0
src/lib/reference/providers.ts

@@ -0,0 +1,130 @@
+import { listDirectory } from "@/commands/fs"
+import type { ReferenceCategory, ReferenceToken } from "./types"
+
+export interface ReferenceProvider {
+  category: ReferenceCategory
+  fetchItems: (projectPath: string) => Promise<ReferenceToken[]>
+}
+
+interface SkillSummary {
+  id: string
+  name: string
+}
+
+interface ConversationSummary {
+  id: string
+  title: string
+}
+
+function simpleId(): string {
+  return Math.random().toString(36).slice(2, 10)
+}
+
+function truncateTitle(title: string, maxLen = 20): string {
+  return title.length > maxLen ? title.slice(0, maxLen) + "..." : title
+}
+
+function normalizeProjectPath(projectPath: string): string {
+  return projectPath.replace(/\\/g, "/")
+}
+
+function createFileProvider(
+  category: Extract<ReferenceCategory, "chapter" | "memory" | "outline" | "deduction">,
+  relativeDir: string,
+  extension: ".md" | ".json",
+): ReferenceProvider {
+  return {
+    category,
+    fetchItems: async (projectPath) => {
+      try {
+        const pp = normalizeProjectPath(projectPath)
+        const dirPath = `${pp}/${relativeDir}`
+        const files = await listDirectory(dirPath)
+        return files
+          .filter((file) => !file.is_dir && file.name.endsWith(extension))
+          .map((file) => {
+            const title = file.name.slice(0, -extension.length)
+            return {
+              id: simpleId(),
+              category,
+              title,
+              path: `${dirPath}/${file.name}`,
+              displayTitle: truncateTitle(title),
+            }
+          })
+      } catch {
+        return []
+      }
+    },
+  }
+}
+
+export const chapterProvider = createFileProvider(
+  "chapter",
+  "wiki/chapters",
+  ".md",
+)
+
+export const memoryProvider = createFileProvider(
+  "memory",
+  "wiki/memory",
+  ".md",
+)
+
+export const outlineProvider = createFileProvider(
+  "outline",
+  "wiki/outlines",
+  ".md",
+)
+
+export const deductionProvider = createFileProvider(
+  "deduction",
+  ".qmai/simulations",
+  ".json",
+)
+
+export function createSkillProvider(getSkills: () => SkillSummary[]): ReferenceProvider {
+  return {
+    category: "skill",
+    fetchItems: async () =>
+      getSkills().map((skill) => ({
+        id: simpleId(),
+        category: "skill" as const,
+        title: skill.name,
+        skillId: skill.id,
+        displayTitle: truncateTitle(skill.name),
+      })),
+  }
+}
+
+export function createChatHistoryProvider(
+  getConversations: () => ConversationSummary[],
+): ReferenceProvider {
+  return {
+    category: "chat_history",
+    fetchItems: async () =>
+      getConversations().map((conversation) => ({
+        id: simpleId(),
+        category: "chat_history" as const,
+        title: conversation.title,
+        conversationId: conversation.id,
+        displayTitle: truncateTitle(conversation.title),
+      })),
+  }
+}
+
+export function createOutlineHistoryProvider(
+  getConversations: () => ConversationSummary[],
+): ReferenceProvider {
+  return {
+    category: "outline_history",
+    fetchItems: async () =>
+      getConversations().map((conversation) => ({
+        id: simpleId(),
+        category: "outline_history" as const,
+        title: conversation.title,
+        conversationId: conversation.id,
+        displayTitle: truncateTitle(conversation.title),
+      })),
+  }
+}

+ 72 - 0
src/lib/reference/resolve.spec.ts

@@ -0,0 +1,72 @@
+import { beforeEach, describe, expect, it, vi } from "vitest"
+import { resolveReference } from "./resolve"
+import type { ReferenceToken } from "./types"
+
+const mocks = vi.hoisted(() => ({
+  readFile: vi.fn(),
+}))
+
+vi.mock("@/commands/fs", () => ({
+  readFile: mocks.readFile,
+}))
+
+function token(overrides: Partial<ReferenceToken>): ReferenceToken {
+  return {
+    id: "r1",
+    category: "chapter",
+    title: "第一章",
+    displayTitle: "第一章",
+    ...overrides,
+  }
+}
+
+describe("resolveReference", () => {
+  beforeEach(() => {
+    mocks.readFile.mockReset()
+  })
+
+  it("reads file-backed references and returns metadata", async () => {
+    mocks.readFile.mockResolvedValue("正文内容")
+
+    const result = await resolveReference(token({ path: "C:/Novel/wiki/chapters/第一章.md" }))
+
+    expect(mocks.readFile).toHaveBeenCalledWith("C:/Novel/wiki/chapters/第一章.md")
+    expect(result.content).toBe("正文内容")
+    expect(result.metadata).toEqual({
+      byteLength: new TextEncoder().encode("正文内容").length,
+      charCount: 4,
+    })
+  })
+
+  it("returns a readable placeholder for conversation references", async () => {
+    const result = await resolveReference(token({
+      category: "chat_history",
+      conversationId: "c1",
+      title: "历史对话",
+    }))
+
+    expect(result.content).toBe("[跨会话引用: 历史对话, id=c1]")
+    expect(result.metadata).toEqual({ byteLength: 0, charCount: 0 })
+    expect(mocks.readFile).not.toHaveBeenCalled()
+  })
+
+  it("returns a readable placeholder for skill references", async () => {
+    const result = await resolveReference(token({
+      category: "skill",
+      skillId: "s1",
+      title: "润色技能",
+    }))
+
+    expect(result.content).toBe("[技能引用: 润色技能]")
+    expect(result.metadata).toEqual({ byteLength: 0, charCount: 0 })
+  })
+
+  it("returns a Chinese error placeholder when a file cannot be read", async () => {
+    mocks.readFile.mockRejectedValue(new Error("missing"))
+
+    const result = await resolveReference(token({ path: "C:/missing.md" }))
+
+    expect(result.content).toBe("[无法读取: C:/missing.md]")
+    expect(result.metadata).toEqual({ byteLength: 0, charCount: 0 })
+  })
+})

+ 57 - 0
src/lib/reference/resolve.ts

@@ -0,0 +1,57 @@
+import { readFile } from "@/commands/fs"
+import type { ReferenceToken } from "./types"
+
+export interface ResolvedReference {
+  token: ReferenceToken
+  content: string
+  metadata: {
+    byteLength: number
+    charCount: number
+  }
+}
+
+export async function resolveReference(
+  token: ReferenceToken,
+): Promise<ResolvedReference> {
+  if (token.conversationId) {
+    return {
+      token,
+      content: `[跨会话引用: ${token.title}, id=${token.conversationId}]`,
+      metadata: { byteLength: 0, charCount: 0 },
+    }
+  }
+
+  if (token.skillId) {
+    return {
+      token,
+      content: `[技能引用: ${token.title}]`,
+      metadata: { byteLength: 0, charCount: 0 },
+    }
+  }
+
+  if (token.path) {
+    try {
+      const content = await readFile(token.path)
+      return {
+        token,
+        content,
+        metadata: {
+          byteLength: new TextEncoder().encode(content).length,
+          charCount: content.length,
+        },
+      }
+    } catch {
+      return {
+        token,
+        content: `[无法读取: ${token.path}]`,
+        metadata: { byteLength: 0, charCount: 0 },
+      }
+    }
+  }
+
+  return {
+    token,
+    content: `[引用: ${token.title}]`,
+    metadata: { byteLength: 0, charCount: 0 },
+  }
+}

+ 18 - 0
src/lib/reference/types.ts

@@ -20,3 +20,21 @@ export interface ReferenceToken {
   /** 芯片显示用的截断标题 */
   displayTitle: string
 }
+
+export interface ReferencePickerTab {
+  key: ReferenceCategory
+  label: string
+  icon: string
+}
+
+export const REFERENCE_TABS: ReferencePickerTab[] = [
+  { key: "chapter", label: "章节", icon: "📄" },
+  { key: "memory", label: "记忆库", icon: "🧠" },
+  { key: "outline", label: "大纲", icon: "📋" },
+  { key: "deduction", label: "推演室", icon: "🔬" },
+  { key: "skill", label: "技能库", icon: "⚡" },
+  { key: "chat_history", label: "AI对话", icon: "💬" },
+  { key: "outline_history", label: "AI大纲", icon: "📝" },
+]
+
+export const MAX_REFERENCE_COUNT = 10