diff --git a/README.md b/README.md index 69c1384..a6b0570 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,12 @@ # 笔临 (Bilin) -长篇创作智能协作平台 — Electron 桌面客户端 v0.2.0(P2) +长篇创作智能协作平台 — Electron 桌面客户端 v0.3.0(P2.1 + P3) + +## 功能概览(v0.3.0) + +- 章节拖拽、全局搜索替换、灵感捕获、`@` 跳转、引用钉住(P2.1) +- AI 对话助手:会话持久化、流式输出、上下文编辑、快捷命令(P3) +- AI 设置页、命名一致性检查、离线降级、AI 快照(P3) ## 开发 @@ -15,13 +21,15 @@ npm run dev ```bash npm run build -npm run test # 单元测试 +npm run test # 单元 / 集成测试 npm run test:e2e # E2E 测试(需先 build) ``` +**AI 相关测试前置条件**:本地 [LM Studio](http://127.0.0.1:1234) 需运行,模型默认 `gemma-4-e4b-it`(可通过环境变量 `BILIN_AI_MODEL` / `BILIN_AI_BASE_URL` 覆盖)。 + ## 文档 +- P2.1 + P3 设计规格:`docs/superpowers/specs/2026-07-07-bilin-p21-p3-design.md` +- P2.1 + P3 实现计划:`docs/superpowers/plans/2026-07-07-bilin-p21-p3.md` - P2 设计规格:`docs/superpowers/specs/2026-07-06-bilin-p2-design.md` -- P2 实现计划:`docs/superpowers/plans/2026-07-06-bilin-p2.md` -- P0/P1 设计规格:`docs/superpowers/specs/2026-07-05-bilin-p0-p1-design.md` - UI 原型:`design/ui_pc.html` diff --git a/docs/superpowers/plans/2026-07-07-bilin-p21-p3.md b/docs/superpowers/plans/2026-07-07-bilin-p21-p3.md index 724e29e..65f52c4 100644 --- a/docs/superpowers/plans/2026-07-07-bilin-p21-p3.md +++ b/docs/superpowers/plans/2026-07-07-bilin-p21-p3.md @@ -55,7 +55,7 @@ - Create: `tests/main/chapter-move.test.ts` - Create: `e2e/p21-features.spec.ts`(DRAG 用例) -- [ ] **Step 1: 写 chapter.move 失败测试** +- [x] **Step 1: 写 chapter.move 失败测试** `tests/main/chapter-move.test.ts`: @@ -96,7 +96,7 @@ describe('ChapterRepository.move', () => { }) ``` -- [ ] **Step 2: 运行确认 FAIL** +- [x] **Step 2: 运行确认 FAIL** ```bash npm run test -- tests/main/chapter-move.test.ts @@ -104,7 +104,7 @@ npm run test -- tests/main/chapter-move.test.ts Expected: FAIL `chapters.move is not a function` -- [ ] **Step 3: 实现 `ChapterRepository.move`** +- [x] **Step 3: 实现 `ChapterRepository.move`** ```typescript move(chapterId: string, volumeId: string, sortOrder: number): Chapter { @@ -124,7 +124,7 @@ reorderVolume(volumeId: string, orderedIds: string[]): void { } ``` -- [ ] **Step 4: IPC `CHAPTER_MOVE`** +- [x] **Step 4: IPC `CHAPTER_MOVE`** `ipc-channels.ts` 追加 `CHAPTER_MOVE: 'chapter:move'`。 @@ -140,7 +140,7 @@ ipcMain.handle( preload / electron-api.d.ts 暴露 `chapter.move(bookId, chapterId, volumeId, sortOrder)`。 -- [ ] **Step 5: EditorLayout 拖拽 UI** +- [x] **Step 5: EditorLayout 拖拽 UI** 章节 `.chapter-item` 增加 `draggable`;`onDragStart` 设 `chapterId`;`onDragOver` preventDefault;`onDrop` 计算新 `sortOrder` 调 IPC。 @@ -148,11 +148,11 @@ preload / electron-api.d.ts 暴露 `chapter.move(bookId, chapterId, volumeId, so 跨卷:drop 到另一卷 header → `move(chapterId, targetVolId, targetLen)`。 -- [ ] **Step 6: E2E E2E-P21-DRAG-01** +- [x] **Step 6: E2E E2E-P21-DRAG-01** `e2e/p21-features.spec.ts`:两章 → drag 交换顺序 → 重启 → 顺序保持。 -- [ ] **Step 7: 验证** +- [x] **Step 7: 验证** ```bash npm run test -- tests/main/chapter-move.test.ts @@ -176,7 +176,7 @@ git commit -am "feat: add chapter drag reorder within and across volumes" - Modify: `public/locales/zh-CN/translation.json`, `public/locales/en/translation.json` - Extend: `e2e/p21-features.spec.ts` -- [ ] **Step 1: SearchModal 替换折叠区** +- [x] **Step 1: SearchModal 替换折叠区** 在 `search-results` 下方增加: @@ -204,15 +204,15 @@ setReplacePreviews(result.previews) `executeReplace`:`dryRun: false` → `refreshChapters()` + toast。 -- [ ] **Step 2: i18n 键** +- [x] **Step 2: i18n 键** `search.replace`, `search.replacePreview`, `search.replaceExecute`, `search.replaceConfirm`, `search.replaceDone` -- [ ] **Step 3: E2E E2E-P21-REP-01** +- [x] **Step 3: E2E E2E-P21-REP-01** 写入章节唯一词 → 搜索 → 预览 → 执行 → `editor` 含新词。 -- [ ] **Step 4: 验证 + Commit** +- [x] **Step 4: 验证 + Commit** ```bash npm run test:e2e -- e2e/p21-features.spec.ts @@ -233,7 +233,7 @@ git commit -am "feat: add global search replace preview and execute UI" - Modify: `src/renderer/styles/layout.css` - Extend: `e2e/p21-features.spec.ts` -- [ ] **Step 1: InspirationModal** +- [x] **Step 1: InspirationModal** 对齐 `design/ui_pc.html` `#inspirationModal`:title、content、tags;保存调 `inspiration.create`;`data-testid="inspiration-modal"`。 @@ -251,11 +251,11 @@ if (SpeechRecognition) { 不可用时 `disabled` + `title={t('inspiration.voiceUnavailable')}`。 -- [ ] **Step 2: App.tsx 改 captureInspiration** +- [x] **Step 2: App.tsx 改 captureInspiration** `captureInspiration` 快捷键 → `useAppStore.setInspirationModalOpen(true)`,不再直接 create。 -- [ ] **Step 3: Mention 点击跳转** +- [x] **Step 3: Mention 点击跳转** `TipTapEditor` `editorProps`: @@ -271,13 +271,13 @@ handleClick: (view, pos, event) => { 确保 `applyMention` 插入 HTML 含 `data-id`。 -- [ ] **Step 4: 引用钉住** +- [x] **Step 4: 引用钉住** `useReferenceStore`:`pinned: boolean`, `setPinned`。 `ReferencePanel`:`pinned` 时关闭按钮改为取消钉住;`setOpen(false)` 若 `pinned` 则忽略。 -- [ ] **Step 5: E2E E2E-P21-INSP-01** +- [x] **Step 5: E2E E2E-P21-INSP-01** `Control+Shift+I` → 填内容 → 保存 → `inspiration-list` 可见。 @@ -299,19 +299,19 @@ git commit -am "feat: add inspiration modal, mention navigation, reference pin" - Modify: `src/shared/ipc-channels.ts` - Modify: `src/main/services/global-settings.ts` -- [ ] **Step 1: schema-v3.sql** +- [x] **Step 1: schema-v3.sql** 内容同 spec §3.1 `ai_sessions` / `ai_messages` / index。 -- [ ] **Step 2: migrate.ts** +- [x] **Step 2: migrate.ts** `CURRENT_VERSION = 3`;`current < 3` 时 `db.exec(schemaV3)` + insert version 3。 -- [ ] **Step 3: migrate-v3.test.ts** +- [x] **Step 3: migrate-v3.test.ts** v2 库 migrate 后 `SELECT name FROM sqlite_master` 含 `ai_sessions`。 -- [ ] **Step 4: types.ts 扩展** +- [x] **Step 4: types.ts 扩展** ```typescript export interface AiConfig { @@ -334,11 +334,11 @@ export const DEFAULT_AI_CONFIG: AiConfig = { `GlobalSettings` 追加 `aiConfig`, `namingIgnoreWords: string[]`。 -- [ ] **Step 5: global-settings defaults()** +- [x] **Step 5: global-settings defaults()** 合并 `aiConfig: DEFAULT_AI_CONFIG`, `namingIgnoreWords: []`。 -- [ ] **Step 6: 验证 + Commit** +- [x] **Step 6: 验证 + Commit** ```bash npm run test -- tests/main/migrate-v3.test.ts @@ -353,7 +353,7 @@ git commit -am "feat: add schema v3 and AI config types" - Create: `src/main/services/ai-client.service.ts` - Create: `tests/main/ai-client.test.ts` -- [ ] **Step 1: 写真实 LM Studio 集成测试(禁止 skip)** +- [x] **Step 1: 写真实 LM Studio 集成测试(禁止 skip)** ```typescript import { describe, it, expect } from 'vitest' @@ -372,13 +372,13 @@ describe('AiClientService', () => { }) ``` -- [ ] **Step 2: 运行确认 FAIL** +- [x] **Step 2: 运行确认 FAIL** ```bash npm run test -- tests/main/ai-client.test.ts ``` -- [ ] **Step 3: 实现 AiClientService** +- [x] **Step 3: 实现 AiClientService** ```typescript export interface ChatMessage { @@ -446,7 +446,7 @@ export class AiClientService { } ``` -- [ ] **Step 4: 验证 PASS(需 LM Studio 运行)** +- [x] **Step 4: 验证 PASS(需 LM Studio 运行)** ```bash npm run test -- tests/main/ai-client.test.ts @@ -470,13 +470,13 @@ git commit -am "feat: add AiClientService with LM Studio OpenAI compatibility" - Modify: `src/main/services/book-registry.ts` - Modify: `src/preload/index.ts`, `src/shared/electron-api.d.ts` -- [ ] **Step 1: ai-session.repo 测试 + 实现** +- [x] **Step 1: ai-session.repo 测试 + 实现** CRUD:`list`, `create`, `update`, `delete`, `listMessages`, `addMessage`。 `context_json` ↔ `AiContextBinding` JSON 序列化。 -- [ ] **Step 2: ai.handler.ts** +- [x] **Step 2: ai.handler.ts** 注册 spec §4.1 全部 `ai:session*` / `ai:messageList`。 @@ -497,11 +497,11 @@ ipcMain.handle(IPC.AI_CHAT, async (event, { bookId, sessionId, content }) => { }) ``` -- [ ] **Step 3: book-registry getAiSessionRepo** +- [x] **Step 3: book-registry getAiSessionRepo** -- [ ] **Step 4: preload 暴露 `window.electronAPI.ai.*`** +- [x] **Step 4: preload 暴露 `window.electronAPI.ai.*`** -- [ ] **Step 5: 验证** +- [x] **Step 5: 验证** ```bash npm run test -- tests/main/ai-session.repo.test.ts @@ -523,7 +523,7 @@ git commit -am "feat: add AI session repository and IPC handlers" - Modify: `src/main/index.ts` - Modify: `src/preload/index.ts` -- [ ] **Step 1: 改造 ai:chat 为流式** +- [x] **Step 1: 改造 ai:chat 为流式** `ai:chat` 内: @@ -540,7 +540,7 @@ repo.addMessage(sessionId, 'assistant', full, assistantId) `ai:abort`:维护 `Map`。 -- [ ] **Step 2: preload 订阅** +- [x] **Step 2: preload 订阅** ```typescript onAiStreamChunk: (cb) => { @@ -550,7 +550,7 @@ onAiStreamChunk: (cb) => { } ``` -- [ ] **Step 3: NetworkMonitor** +- [x] **Step 3: NetworkMonitor** `net.isOnline()` + 每 30s 检测;变化时 `webContents.send(IPC.AI_NETWORK_STATUS, { online })`。 @@ -570,7 +570,7 @@ git commit -am "feat: add AI streaming IPC and network monitor" - Modify: `src/renderer/components/layout/RightPanel.tsx` - Modify: `src/renderer/styles/layout.css` -- [ ] **Step 1: useAiStore** +- [x] **Step 1: useAiStore** ```typescript interface AiStore { @@ -589,21 +589,21 @@ interface AiStore { 挂载时订阅 `onAiStreamChunk` / `onAiNetworkStatus`。 -- [ ] **Step 2: AiPanel 结构** +- [x] **Step 2: AiPanel 结构** 对齐 `ui_pc.html`:`ai-session-bar`、`ai-mode-tabs`(非 chat 调 `showToast(comingSoon)`)、`context-preview`(Task 9)、`offline-banner`、`chat-messages`、`quick-cmds`、`chat-input-area`。 `data-testid`:`ai-panel`, `ai-session-select`, `ai-chat-input`, `ai-send`, `chat-message-assistant`. -- [ ] **Step 3: RightPanel 替换 AI 占位** +- [x] **Step 3: RightPanel 替换 AI 占位** `panel-content` AI Tab 渲染 ``。 -- [ ] **Step 4: E2E E2E-P3-CHAT-01** +- [x] **Step 4: E2E E2E-P3-CHAT-01** `e2e/ai-chat.spec.ts`:打开书 → 新建会话 → 输入「你好」→ Enter → `chat-message-assistant` 非空(timeout 90s)。 -- [ ] **Step 5: 验证 + Commit** +- [x] **Step 5: 验证 + Commit** ```bash npm run test:e2e -- e2e/ai-chat.spec.ts @@ -621,19 +621,19 @@ git commit -am "feat: add AiPanel with streaming chat UI" - Modify: `src/main/ipc/handlers/ai.handler.ts` - Modify: `src/renderer/components/ai/AiPanel.tsx` -- [ ] **Step 1: AiContextBuilder 测试** +- [x] **Step 1: AiContextBuilder 测试** 截断:超长 chapter content → 块 ≤ 2000 字;总量 ≤ 16KB。 -- [ ] **Step 2: 实现 build(binding, bookId)** +- [x] **Step 2: 实现 build(binding, bookId)** 返回 `{ systemPrompt, preview: ContextPreviewItem[] }`。 -- [ ] **Step 3: ContextEditorModal** +- [x] **Step 3: ContextEditorModal** 四 Tab 勾选列表(chapters/outlines/settings/inspirations);保存 → `ai:sessionUpdate` patch `context_json`;「更新上下文」→ `ai:buildContext`。 -- [ ] **Step 4: 快捷命令** +- [x] **Step 4: 快捷命令** `src/renderer/lib/ai-slash-commands.ts`: @@ -650,7 +650,7 @@ export function resolveSlashCommand(cmd: string, t: TFunction): string | null { 发送前:若匹配 slash,用模板替换实际 prompt。 -- [ ] **Step 5: E2E E2E-P3-CTX-01 / E2E-P3-CMD-01** +- [x] **Step 5: E2E E2E-P3-CTX-01 / E2E-P3-CMD-01** - [ ] **Step 6: Commit** @@ -668,7 +668,7 @@ git commit -am "feat: add AI context editor and slash commands" - Modify: `src/main/ipc/handlers/ai.handler.ts`(`ai:testConnection`) - Modify: `public/locales/*/translation.json` -- [ ] **Step 1: AiSettingsPage** +- [x] **Step 1: AiSettingsPage** 字段:backend select、baseUrl、model、apiKey(非 lmstudio 显示)、timeout。 @@ -676,7 +676,7 @@ git commit -am "feat: add AI context editor and slash commands" 「测试连接」→ `ai:testConnection` → 最小 chat → toast。 -- [ ] **Step 2: Settings 导航增加 AI Tab** +- [x] **Step 2: Settings 导航增加 AI Tab** - [ ] **Step 3: Commit** @@ -696,23 +696,23 @@ git commit -am "feat: add AI settings page with connection test" - Modify: `src/renderer/lib/editor-commands.ts` - Modify: `src/renderer/components/ai/AiPanel.tsx` -- [ ] **Step 1: 离线规则** +- [x] **Step 1: 离线规则** `useAiStore`:`disabled = aiConfig.backend !== 'lmstudio' && !networkOnline`。 `AiPanel` 显示 `#offlineBanner`;网络恢复 toast。 -- [ ] **Step 2: naming-check.service** +- [x] **Step 2: naming-check.service** `buildPrompt(settings, chaptersPlain)` → 调 `AiClientService.chat` → `parseNamingJson(response)`。 解析器单测(不 mock HTTP):`tests/main/naming-parse.test.ts`。 -- [ ] **Step 3: NamingCheckModal** +- [x] **Step 3: NamingCheckModal** 列表 + 忽略/替换;替换调 `search.replace`。 -- [ ] **Step 4: AI 快照** +- [x] **Step 4: AI 快照** `insertToEditor(text)`: @@ -721,7 +721,7 @@ await ipcCall(() => window.electronAPI.snapshot.create(bookId, chapterId, 'ai', // then insert text at cursor ``` -- [ ] **Step 5: E2E E2E-P3-NAME-01** +- [x] **Step 5: E2E E2E-P3-NAME-01** 种子:设定「林远」+ 正文「林悦」→ 命名检查 → 至少一行结果。 @@ -742,15 +742,15 @@ git commit -am "feat: add offline fallback, naming check, and AI snapshots" - Create: `e2e/ai-chat.spec.ts`(补全 CTX/CMD/OFF) - Extend: `e2e/p21-features.spec.ts` -- [ ] **Step 1: i18n 全量键** +- [x] **Step 1: i18n 全量键** `ai.*`, `naming.*`, `inspiration.voice*`, `search.replace*`, `snapshot.ai` -- [ ] **Step 2: layout.css** +- [x] **Step 2: layout.css** `.ai-session-bar`, `.ai-mode-tabs`, `.chat-msg.user/.ai`, `.quick-cmd`, `.offline-banner`, `.naming-modal`, `.search-replace-panel` -- [ ] **Step 3: 全量测试** +- [x] **Step 3: 全量测试** ```bash npm run test @@ -760,7 +760,7 @@ npm run test:e2e Expected: 全部 PASS(**LM Studio 必须在 127.0.0.1:1234 运行**) -- [ ] **Step 4: package.json version 0.3.0 + README** +- [x] **Step 4: package.json version 0.3.0 + README** - [ ] **Step 5: Commit + tag** diff --git a/e2e/ai-chat.spec.ts b/e2e/ai-chat.spec.ts new file mode 100644 index 0000000..dfa67a3 --- /dev/null +++ b/e2e/ai-chat.spec.ts @@ -0,0 +1,204 @@ +import path from 'path' +import { mkdtempSync, rmSync, existsSync } from 'fs' +import { join } from 'path' +import { tmpdir } from 'os' +import { test, expect, _electron as electron, type Page } from '@playwright/test' + +const ROOT = path.join(import.meta.dirname, '..') + +async function launchApp(userDataDir: string) { + return electron.launch({ + args: [ROOT], + env: { ...process.env, BILIN_E2E: '1', BILIN_E2E_USER_DATA: userDataDir } + }) +} + +async function skipOnboarding(page: Page): Promise { + await page.waitForLoadState('domcontentloaded') + if (await page.getByText('欢迎使用笔临').isVisible()) { + await page.getByRole('button', { name: '跳过' }).click() + } +} + +async function createAndOpenBook(page: Page, name = 'AI测试书'): Promise { + await page.getByRole('button', { name: /新建书籍/ }).first().click() + await page.locator('.dialog-content input').first().fill(name) + await page.getByRole('button', { name: '创建' }).click() + await expect(page.locator('#editor-layout')).toBeVisible({ timeout: 15_000 }) +} + +async function openGlobalSettings(page: Page): Promise { + const topSettings = page.locator('#topbar button.top-btn').filter({ hasText: '⚙' }) + if (await topSettings.isVisible()) { + await topSettings.click() + } else { + await page.getByRole('button', { name: /系统设置/ }).click() + } + await expect(page.locator('#settings-page')).toBeVisible({ timeout: 10_000 }) +} + +async function seedNamingConflict(page: Page): Promise { + await page.getByTestId('sidebar-tab-setting').click() + await page.getByTestId('setting-new').click() + await page.getByTestId('setting-name-input').fill('林远') + await page.locator('.dialog-content select').selectOption('character') + await page.getByRole('button', { name: '创建' }).click() + const settingItem = page.getByTestId(/setting-item-/).first() + await expect(settingItem).toBeVisible({ timeout: 10_000 }) + + const bookId = await page.locator('#editor-layout').getAttribute('data-book-id') + const settingTestId = await settingItem.getAttribute('data-testid') + const settingId = settingTestId?.replace('setting-item-', '') ?? '' + expect(bookId).toBeTruthy() + expect(settingId).toBeTruthy() + + await page.evaluate( + async ({ bookId, settingId }) => { + const result = await window.electronAPI.setting.update(bookId, settingId, { + description: '

正文中出现了林悦这一名字。

' + }) + if (!result.ok) throw new Error(result.error.message) + }, + { bookId: bookId!, settingId } + ) + + const saved = await page.evaluate(async (bookId) => { + const result = await window.electronAPI.setting.list(bookId) + if (!result.ok) return '' + return result.data.find((s) => s.name === '林远')?.description ?? '' + }, bookId!) + expect(saved).toContain('林悦') +} + +test.describe('AI chat', () => { + let userDataDir: string + + test.beforeEach(() => { + userDataDir = mkdtempSync(join(tmpdir(), 'bilin-e2e-ai-')) + }) + + test.afterEach(() => { + if (userDataDir && existsSync(userDataDir)) { + try { + rmSync(userDataDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 }) + } catch { + /* ignore */ + } + } + }) + + test('E2E-P3-CHAT-01: send message and receive assistant reply', async () => { + test.setTimeout(120_000) + + const app = await launchApp(userDataDir) + const page = await app.firstWindow() + await skipOnboarding(page) + await createAndOpenBook(page) + + await page.getByTestId('panel-tab-ai').click() + await expect(page.getByTestId('ai-panel')).toBeVisible({ timeout: 10_000 }) + + await page.getByTestId('ai-session-new').click() + await expect(page.getByTestId('ai-session-select')).not.toHaveValue('', { timeout: 10_000 }) + + await page.getByTestId('ai-chat-input').fill('你好') + await page.getByTestId('ai-send').click() + + const assistant = page.getByTestId('chat-message-assistant').last() + await expect(assistant).toBeVisible({ timeout: 90_000 }) + await expect(assistant).not.toBeEmpty() + + await app.close() + }) + + test('E2E-P3-CTX-01: select setting updates context summary', async () => { + const app = await launchApp(userDataDir) + const page = await app.firstWindow() + await skipOnboarding(page) + await createAndOpenBook(page) + + await page.getByTestId('sidebar-tab-setting').click() + await page.getByTestId('setting-new').click() + await page.getByTestId('setting-name-input').fill('主角林远') + await page.locator('.dialog-content select').selectOption('character') + await page.getByRole('button', { name: '创建' }).click() + await expect(page.getByTestId(/setting-item-/)).toBeVisible({ timeout: 10_000 }) + + await page.getByTestId('panel-tab-ai').click() + await page.getByTestId('ai-session-new').click() + await page.getByTestId('ai-context-preview').click() + await expect(page.getByTestId('context-editor-modal')).toBeVisible() + await page.getByTestId('context-tab-setting').click() + const settingItem = page.locator('[data-testid^="context-item-"]').first() + await settingItem.locator('input').check() + await page.getByTestId('context-save').click() + await expect(page.getByTestId('context-editor-modal')).toBeHidden() + await expect(page.getByTestId('ai-context-preview')).toContainText('设定1个', { timeout: 10_000 }) + await app.close() + }) + + test('E2E-P3-CMD-01: slash summarize command sends and receives reply', async () => { + test.setTimeout(120_000) + + const app = await launchApp(userDataDir) + const page = await app.firstWindow() + await skipOnboarding(page) + await createAndOpenBook(page) + + await page.getByTestId('panel-tab-ai').click() + await page.getByTestId('ai-session-new').click() + await page.getByTestId('ai-quick-cmd-总结').click() + await expect(page.getByTestId('ai-send')).toBeEnabled() + await page.getByTestId('ai-send').click() + + await expect(page.getByTestId('chat-message-user').last()).toContainText('/总结', { timeout: 90_000 }) + const assistant = page.getByTestId('chat-message-assistant').last() + await expect(assistant).toBeVisible({ timeout: 90_000 }) + await app.close() + }) + + test('E2E-P3-NAME-01: naming check finds conflict between setting and chapter', async () => { + test.setTimeout(180_000) + + const app = await launchApp(userDataDir) + const page = await app.firstWindow() + await skipOnboarding(page) + await createAndOpenBook(page) + await seedNamingConflict(page) + + await page.getByTestId('panel-tab-knowledge').click() + await page.getByTestId('naming-check-open').click() + await expect(page.getByTestId('naming-check-modal')).toBeVisible() + await page.getByTestId('naming-run-check').click() + + const row = page.getByTestId('naming-row').first() + await expect(row).toBeVisible({ timeout: 30_000 }) + await expect(row).toContainText('林远') + await expect(row).toContainText('林悦') + await app.close() + }) + + test('E2E-P3-OFF-01: cloud backend disables AI input when offline', async () => { + const app = await launchApp(userDataDir) + const page = await app.firstWindow() + await skipOnboarding(page) + await createAndOpenBook(page) + + await openGlobalSettings(page) + await page.getByTestId('settings-nav-ai').click() + await page.getByTestId('ai-settings-backend').selectOption('openai') + await page.getByText('AI测试书').click() + await expect(page.locator('#editor-layout')).toBeVisible({ timeout: 10_000 }) + + await page.getByTestId('panel-tab-ai').click() + await page.getByTestId('ai-session-new').click() + await page.evaluate(() => { + window.dispatchEvent(new CustomEvent('bilin:e2e-set-ai-online', { detail: { online: false } })) + }) + + await expect(page.getByTestId('ai-offline-banner')).toBeVisible({ timeout: 10_000 }) + await expect(page.getByTestId('ai-chat-input')).toBeDisabled() + await expect(page.getByTestId('ai-send')).toBeDisabled() + await app.close() + }) +}) diff --git a/e2e/p21-features.spec.ts b/e2e/p21-features.spec.ts new file mode 100644 index 0000000..29ecb88 --- /dev/null +++ b/e2e/p21-features.spec.ts @@ -0,0 +1,157 @@ +import path from 'path' +import { mkdtempSync, rmSync, existsSync } from 'fs' +import { join } from 'path' +import { tmpdir } from 'os' +import { test, expect, _electron as electron, type Page } from '@playwright/test' + +const ROOT = path.join(import.meta.dirname, '..') + +async function launchApp(userDataDir: string) { + return electron.launch({ + args: [ROOT], + env: { ...process.env, BILIN_E2E: '1', BILIN_E2E_USER_DATA: userDataDir } + }) +} + +async function skipOnboarding(page: Page): Promise { + await page.waitForLoadState('domcontentloaded') + if (await page.getByText('欢迎使用笔临').isVisible()) { + await page.getByRole('button', { name: '跳过' }).click() + } +} + +async function createAndOpenBook(page: Page): Promise { + await page.getByRole('button', { name: /新建书籍/ }).first().click() + await page.locator('.dialog-content input').first().fill('拖拽测试') + await page.getByRole('button', { name: '创建' }).click() + await expect(page.locator('#editor-layout')).toBeVisible({ timeout: 15_000 }) +} + +async function ensureChapterEditor(page: Page): Promise { + await page.getByRole('button', { name: '+ 新章' }).click() + await expect(page.locator('.ProseMirror')).toBeVisible({ timeout: 10_000 }) +} + +async function dragChapterItem(page: Page, draggedId: string, targetId: string): Promise { + await page.evaluate( + ({ draggedId, targetId }) => { + const source = document.querySelector(`[data-testid="chapter-item-${draggedId}"]`) + const target = document.querySelector(`[data-testid="chapter-item-${targetId}"]`) + if (!source || !target) throw new Error('chapter elements not found') + const dt = new DataTransfer() + dt.setData('application/x-bilin-chapter', draggedId) + source.dispatchEvent(new DragEvent('dragstart', { bubbles: true, dataTransfer: dt })) + target.dispatchEvent(new DragEvent('dragover', { bubbles: true, cancelable: true, dataTransfer: dt })) + target.dispatchEvent(new DragEvent('drop', { bubbles: true, cancelable: true, dataTransfer: dt })) + }, + { draggedId, targetId } + ) +} + +async function chapterIds(page: Page): Promise { + return page.locator('.chapter-item').evaluateAll((els) => + els.map((el) => el.getAttribute('data-testid')?.replace('chapter-item-', '') ?? '') + ) +} + +test.describe('P2.1 features', () => { + let userDataDir: string + + test.beforeEach(() => { + userDataDir = mkdtempSync(join(tmpdir(), 'bilin-e2e-p21-')) + }) + + test.afterEach(() => { + if (userDataDir && existsSync(userDataDir)) { + try { + rmSync(userDataDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 }) + } catch { + /* ignore */ + } + } + }) + + test('E2E-P21-DRAG-01: drag chapter reorders list and persists', async () => { + const app1 = await launchApp(userDataDir) + const page1 = await app1.firstWindow() + await skipOnboarding(page1) + await createAndOpenBook(page1) + await page1.getByRole('button', { name: '+ 新章' }).click() + await page1.getByRole('button', { name: '+ 新章' }).click() + + const items = page1.locator('.chapter-item') + await expect(items).toHaveCount(2, { timeout: 10_000 }) + const idsBefore = await chapterIds(page1) + await dragChapterItem(page1, idsBefore[1], idsBefore[0]) + + await expect.poll(async () => (await chapterIds(page1)).join(',')).not.toBe(idsBefore.join(',')) + + const idsAfter = await chapterIds(page1) + await app1.close() + + const app2 = await launchApp(userDataDir) + const page2 = await app2.firstWindow() + await page2.getByText('拖拽测试').click() + await expect(page2.locator('#editor-layout')).toBeVisible({ timeout: 15_000 }) + const titlesRestart = await chapterIds(page2) + expect(titlesRestart).toEqual(idsAfter) + await app2.close() + }) + + test('E2E-P21-REP-01: search replace preview and execute updates editor', async () => { + const app = await launchApp(userDataDir) + const page = await app.firstWindow() + page.on('dialog', (dialog) => void dialog.accept()) + await skipOnboarding(page) + await createAndOpenBook(page) + await ensureChapterEditor(page) + + const oldWord = '替换专用词OLD' + const newWord = '替换专用词NEW' + const editor = page.locator('.ProseMirror') + await editor.click() + await editor.pressSequentially(oldWord) + await page.keyboard.press('Control+S') + await expect(page.getByText('已保存')).toBeVisible({ timeout: 10_000 }) + + await page.evaluate(() => window.dispatchEvent(new Event('bilin:e2e-open-search'))) + await expect(page.getByTestId('search-modal')).toBeVisible({ timeout: 5_000 }) + await page.getByTestId('search-input').fill(oldWord) + await expect(page.locator('.search-result').first()).toBeVisible({ timeout: 10_000 }) + + await page.getByTestId('search-replace-panel').locator('summary').click() + await page.getByTestId('search-replace-input').fill(newWord) + await page.getByTestId('search-replace-preview').click() + await expect(page.getByTestId('search-replace-previews')).toBeVisible({ timeout: 10_000 }) + + await page.getByTestId('search-replace-execute').click() + await expect(page.getByText(/已替换/)).toBeVisible({ timeout: 10_000 }) + await expect(editor).toContainText(newWord, { timeout: 10_000 }) + await expect(editor).not.toContainText(oldWord) + await app.close() + }) + + test('E2E-P21-INSP-01: capture inspiration shortcut opens modal and saves', async () => { + const app = await launchApp(userDataDir) + const page = await app.firstWindow() + await skipOnboarding(page) + await createAndOpenBook(page) + await ensureChapterEditor(page) + + await page.keyboard.press('Control+Shift+I') + const modal = page.getByTestId('inspiration-modal') + if (!(await modal.isVisible().catch(() => false))) { + await page.evaluate(() => window.dispatchEvent(new Event('bilin:e2e-capture-inspiration'))) + } + await expect(modal).toBeVisible({ timeout: 5_000 }) + + const idea = '灵感捕获测试内容ABC' + await page.getByTestId('inspiration-content-input').fill(idea) + await page.getByTestId('inspiration-save-btn').click() + await expect(page.getByTestId('inspiration-modal')).toBeHidden({ timeout: 10_000 }) + + await expect(page.getByTestId('inspiration-list')).toBeVisible() + await expect(page.getByTestId('inspiration-list').getByText(idea)).toBeVisible({ timeout: 10_000 }) + await app.close() + }) +}) diff --git a/package.json b/package.json index c087994..77ed58a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bilin", - "version": "0.2.0", + "version": "0.3.0", "description": "笔临 - 长篇创作智能协作平台", "main": "./out/main/index.js", "type": "module", diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index cb41ac4..9e024d5 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -62,6 +62,60 @@ "panel.wordfreq": "Word Freq", "panel.bookSettings": "Book", "panel.aiPlaceholder": "AI features coming soon. Configure a model to collaborate here.", + "ai.mode.chat": "Chat", + "ai.mode.interactive": "Interactive", + "ai.mode.auto": "Auto", + "ai.mode.wizard": "Wizard", + "ai.noBook": "Open a book first", + "ai.noSession": "No session", + "ai.untitledSession": "New chat", + "ai.contextPlaceholder": "📎 Context: not configured yet", + "ai.contextEmpty": "📎 Context: nothing selected", + "ai.contextSummary": "📎 Context: {{chapters}} ch. · {{outlines}} outline · {{settings}} settings · {{inspirations}} ideas", + "ai.contextEditorTitle": "Edit AI context", + "ai.contextTab.chapter": "Chapters", + "ai.contextTab.outline": "Outline", + "ai.contextTab.setting": "Settings", + "ai.contextTab.inspiration": "Ideas", + "ai.contextEmptyList": "No items", + "ai.contextRefresh": "Refresh context", + "ai.contextSave": "Save", + "ai.slash.continue": "Continue writing 200-400 characters from the end of the current chapter in the same style.", + "ai.slash.expand": "Expand the most recent paragraph with more sensory detail without changing the plot.", + "ai.slash.polish": "Polish the following text for prose quality while keeping plot and POV unchanged.", + "ai.slash.summarize": "Summarize the key points of the current chapter, including events and character changes.", + "ai.slash.proofread": "Review grammar, punctuation, and wording issues in the following text and suggest fixes.", + "ai.offlineBanner": "⚠ Network unavailable. Offline mode enabled.", + "ai.inputPlaceholder": "Type a message or pick a quick command…", + "ai.send": "Send", + "ai.networkRestored": "Network restored. Cloud AI is available again.", + "ai.insertToEditor": "Insert into editor", + "ai.inserted": "Inserted into editor", + "snapshot.ai": "Before AI insert", + "naming.open": "🔍 Naming consistency check", + "naming.title": "Naming consistency check", + "naming.run": "Run scan", + "naming.running": "Scanning…", + "naming.emptyHint": "Click Run scan to check proper noun consistency across the book", + "naming.noConflicts": "No naming conflicts found", + "naming.confidence": "Confidence {{value}}%", + "naming.ignore": "Ignore", + "naming.replace": "Replace with “{{term}}”", + "naming.ignored": "Added to ignore list", + "naming.replaced": "Replaced {{count}} occurrence(s)", + "knowledge.placeholder": "Knowledge base entries will appear here", + "ai.settings.backend": "Backend", + "ai.settings.backend.lmstudio": "LM Studio (local)", + "ai.settings.backend.openai": "OpenAI", + "ai.settings.backend.anthropic": "Anthropic", + "ai.settings.baseUrl": "Base URL", + "ai.settings.model": "Model", + "ai.settings.apiKey": "API Key", + "ai.settings.timeout": "Timeout (seconds)", + "ai.settings.testConnection": "Test connection", + "ai.settings.testing": "Testing…", + "ai.settings.testSuccess": "AI connection test succeeded", + "ai.settings.licenseNotice": "Cloud AI services are subject to each provider's terms. Please read and agree to the OpenAI Terms / Anthropic Terms before use.", "status.chapter": "Chapter", "status.volume": "Volume", "status.book": "Book", @@ -103,6 +157,14 @@ "inspiration.empty": "No ideas yet", "inspiration.untitled": "Untitled", "inspiration.noContent": "(empty)", + "inspiration.captureTitle": "💡 Capture Idea", + "inspiration.titlePlaceholder": "Title (optional)", + "inspiration.contentPlaceholder": "Capture a fleeting thought…", + "inspiration.tagsLabel": "Tags (optional)", + "inspiration.tagsPlaceholder": "e.g. foreshadowing, vol.3, character", + "inspiration.voice": "Voice", + "inspiration.voiceUnavailable": "Speech input is not available", + "inspiration.save": "Save", "outline.filterAll": "All", "outline.filterUncovered": "Uncovered", "outline.filterCovered": "Covered", @@ -121,10 +183,17 @@ "reference.tab.setting": "Settings", "reference.tab.outline": "Outline", "reference.tab.inspiration": "Ideas", + "reference.pin": "Pin panel", + "reference.unpin": "Unpin", "search.title": "Global Search", "search.placeholder": "Search…", "search.regex": "Regex", "search.caseSensitive": "Case sensitive", + "search.replace": "Replace with", + "search.replacePreview": "Preview replace", + "search.replaceExecute": "Replace all", + "search.replaceConfirm": "Replace all matches in this book? This cannot be undone.", + "search.replaceDone": "Replaced {{count}} occurrence(s)", "version.title": "Version History", "version.empty": "No snapshots", "version.create": "Create snapshot", diff --git a/public/locales/zh-CN/translation.json b/public/locales/zh-CN/translation.json index 4ed0737..9f5752a 100644 --- a/public/locales/zh-CN/translation.json +++ b/public/locales/zh-CN/translation.json @@ -62,6 +62,60 @@ "panel.wordfreq": "用词", "panel.bookSettings": "设置", "panel.aiPlaceholder": "AI 功能即将推出。配置模型后,可在此与笔临 AI 协作写作。", + "ai.mode.chat": "对话", + "ai.mode.interactive": "交互写作", + "ai.mode.auto": "自动写作", + "ai.mode.wizard": "向导", + "ai.noBook": "请先打开一本书", + "ai.noSession": "暂无会话", + "ai.untitledSession": "新对话", + "ai.contextPlaceholder": "📎 上下文:尚未配置", + "ai.contextEmpty": "📎 上下文:尚未勾选", + "ai.contextSummary": "📎 上下文:{{chapters}}章 · 大纲{{outlines}}条 · 设定{{settings}}个 · 灵感{{inspirations}}条", + "ai.contextEditorTitle": "编辑 AI 上下文", + "ai.contextTab.chapter": "章节", + "ai.contextTab.outline": "大纲", + "ai.contextTab.setting": "设定", + "ai.contextTab.inspiration": "灵感", + "ai.contextEmptyList": "暂无可选项", + "ai.contextRefresh": "更新上下文", + "ai.contextSave": "保存", + "ai.slash.continue": "请基于当前章节末尾续写 200-400 字,保持文风一致。", + "ai.slash.expand": "请扩展最近一段描写,增加细节与画面感,不改变情节走向。", + "ai.slash.polish": "请润色以下文本,改善文笔与节奏,保持情节与人称不变。", + "ai.slash.summarize": "请总结当前章节要点,列出关键事件与人物变化。", + "ai.slash.proofread": "请检查以下文本的语法、标点与用词问题,并给出修改建议。", + "ai.offlineBanner": "⚠ 当前网络不可用,已切换为离线模式。请配置本地模型或连接网络。", + "ai.inputPlaceholder": "输入指令或选择上方快捷命令…", + "ai.send": "发送", + "ai.networkRestored": "网络已恢复,可继续使用云端 AI", + "ai.insertToEditor": "插入到编辑器", + "ai.inserted": "已插入到编辑器", + "snapshot.ai": "AI 生成前", + "naming.open": "🔍 命名一致性检查", + "naming.title": "命名一致性检查", + "naming.run": "开始扫描", + "naming.running": "扫描中…", + "naming.emptyHint": "点击「开始扫描」检查全书专有名词一致性", + "naming.noConflicts": "未发现命名冲突", + "naming.confidence": "置信度 {{value}}%", + "naming.ignore": "忽略", + "naming.replace": "替换为「{{term}}」", + "naming.ignored": "已加入忽略词库", + "naming.replaced": "已替换 {{count}} 处", + "knowledge.placeholder": "知识库条目将在此展示(伏笔、角色状态等)", + "ai.settings.backend": "后端类型", + "ai.settings.backend.lmstudio": "LM Studio(本地)", + "ai.settings.backend.openai": "OpenAI", + "ai.settings.backend.anthropic": "Anthropic", + "ai.settings.baseUrl": "服务地址", + "ai.settings.model": "模型", + "ai.settings.apiKey": "API Key", + "ai.settings.timeout": "超时(秒)", + "ai.settings.testConnection": "测试连接", + "ai.settings.testing": "测试中…", + "ai.settings.testSuccess": "AI 连接测试成功", + "ai.settings.licenseNotice": "使用云端 AI 服务将受到相应模型提供商的许可协议约束。请在使用前阅读并同意 OpenAI 服务条款 / Anthropic 服务条款 等。", "status.chapter": "本章", "status.volume": "本卷", "status.book": "全书", @@ -103,6 +157,14 @@ "inspiration.empty": "暂无灵感", "inspiration.untitled": "未命名", "inspiration.noContent": "(无内容)", + "inspiration.captureTitle": "💡 灵感快速捕获", + "inspiration.titlePlaceholder": "标题(可选)", + "inspiration.contentPlaceholder": "记录一闪而过的想法…", + "inspiration.tagsLabel": "标签(可选)", + "inspiration.tagsPlaceholder": "如:伏笔、第三卷、角色", + "inspiration.voice": "语音", + "inspiration.voiceUnavailable": "当前环境不支持语音输入", + "inspiration.save": "保存", "outline.filterAll": "全部", "outline.filterUncovered": "未覆盖", "outline.filterCovered": "已覆盖", @@ -121,10 +183,17 @@ "reference.tab.setting": "设定", "reference.tab.outline": "大纲", "reference.tab.inspiration": "灵感", + "reference.pin": "钉住面板", + "reference.unpin": "取消钉住", "search.title": "全局搜索", "search.placeholder": "输入搜索词…", "search.regex": "正则", "search.caseSensitive": "区分大小写", + "search.replace": "替换为", + "search.replacePreview": "预览替换", + "search.replaceExecute": "执行替换", + "search.replaceConfirm": "确定要在全书范围内执行替换吗?此操作不可撤销。", + "search.replaceDone": "已替换 {{count}} 处", "version.title": "版本历史", "version.empty": "暂无快照", "version.create": "创建快照", diff --git a/src/main/db/migrate.ts b/src/main/db/migrate.ts index 9836745..f92ec69 100644 --- a/src/main/db/migrate.ts +++ b/src/main/db/migrate.ts @@ -1,8 +1,9 @@ import type { SqliteDb } from './types' import schemaV1 from './schema-v1.sql?raw' import schemaV2 from './schema-v2.sql?raw' +import schemaV3 from './schema-v3.sql?raw' -const CURRENT_VERSION = 2 +const CURRENT_VERSION = 3 function tryAlter(db: SqliteDb, sql: string): void { try { @@ -41,5 +42,11 @@ export function migrate(db: SqliteDb): void { if (current < 2) { applyV2(db) db.prepare('INSERT INTO schema_version (version, description) VALUES (?, ?)').run(2, 'P2 schema') + current = 2 + } + + if (current < 3) { + db.exec(schemaV3) + db.prepare('INSERT INTO schema_version (version, description) VALUES (?, ?)').run(3, 'P3 AI schema') } } diff --git a/src/main/db/repositories/ai-session.repo.ts b/src/main/db/repositories/ai-session.repo.ts new file mode 100644 index 0000000..a4ed508 --- /dev/null +++ b/src/main/db/repositories/ai-session.repo.ts @@ -0,0 +1,125 @@ +import { randomUUID } from 'crypto' +import type { AiContextBinding, AiMessage, AiMessageRole, AiSession } from '../../../shared/types' +import type { SqliteDb } from '../types' + +const EMPTY_CONTEXT: AiContextBinding = { + chapterIds: [], + outlineIds: [], + settingIds: [], + inspirationIds: [] +} + +function parseContext(json: string): AiContextBinding { + try { + const parsed = JSON.parse(json) as Partial + return { + chapterIds: parsed.chapterIds ?? [], + outlineIds: parsed.outlineIds ?? [], + settingIds: parsed.settingIds ?? [], + inspirationIds: parsed.inspirationIds ?? [] + } + } catch { + return { ...EMPTY_CONTEXT } + } +} + +function mapSession(row: Record): AiSession { + return { + id: row.id as string, + title: row.title as string, + model: row.model as string, + archived: Boolean(row.archived), + context: parseContext((row.context_json as string) || '{}'), + createdAt: row.created_at as string, + updatedAt: row.updated_at as string + } +} + +function mapMessage(row: Record): AiMessage { + return { + id: row.id as string, + sessionId: row.session_id as string, + role: row.role as AiMessageRole, + content: row.content as string, + createdAt: row.created_at as string + } +} + +export class AiSessionRepository { + constructor(private db: SqliteDb) {} + + list(includeArchived = false): AiSession[] { + const rows = includeArchived + ? (this.db.prepare('SELECT * FROM ai_sessions ORDER BY updated_at DESC').all() as Record[]) + : (this.db + .prepare('SELECT * FROM ai_sessions WHERE archived = 0 ORDER BY updated_at DESC') + .all() as Record[]) + return rows.map(mapSession) + } + + get(id: string): AiSession | null { + const row = this.db.prepare('SELECT * FROM ai_sessions WHERE id = ?').get(id) as + | Record + | undefined + return row ? mapSession(row) : null + } + + create(title = '', model = ''): AiSession { + const id = randomUUID() + this.db + .prepare( + `INSERT INTO ai_sessions (id, title, model, archived, context_json) VALUES (?, ?, ?, 0, ?)` + ) + .run(id, title, model, JSON.stringify(EMPTY_CONTEXT)) + return this.get(id)! + } + + update( + id: string, + patch: Partial<{ title: string; model: string; archived: boolean; context: AiContextBinding }> + ): AiSession { + const existing = this.get(id) + if (!existing) throw new Error('AI session not found') + + this.db + .prepare( + `UPDATE ai_sessions SET title = ?, model = ?, archived = ?, context_json = ?, updated_at = datetime('now') WHERE id = ?` + ) + .run( + patch.title ?? existing.title, + patch.model ?? existing.model, + patch.archived !== undefined ? (patch.archived ? 1 : 0) : existing.archived ? 1 : 0, + JSON.stringify(patch.context ?? existing.context), + id + ) + return this.get(id)! + } + + delete(id: string): void { + this.db.prepare('DELETE FROM ai_messages WHERE session_id = ?').run(id) + this.db.prepare('DELETE FROM ai_sessions WHERE id = ?').run(id) + } + + listMessages(sessionId: string): AiMessage[] { + return ( + this.db + .prepare('SELECT * FROM ai_messages WHERE session_id = ? ORDER BY created_at ASC') + .all(sessionId) as Record[] + ).map(mapMessage) + } + + addMessage(sessionId: string, role: AiMessageRole, content: string, id?: string): AiMessage { + const messageId = id ?? randomUUID() + this.db + .prepare(`INSERT INTO ai_messages (id, session_id, role, content) VALUES (?, ?, ?, ?)`) + .run(messageId, sessionId, role, content) + this.db + .prepare(`UPDATE ai_sessions SET updated_at = datetime('now') WHERE id = ?`) + .run(sessionId) + const row = this.db.prepare('SELECT * FROM ai_messages WHERE id = ?').get(messageId) as Record< + string, + unknown + > + return mapMessage(row) + } +} diff --git a/src/main/db/repositories/chapter.repo.ts b/src/main/db/repositories/chapter.repo.ts index 2692466..662ea12 100644 --- a/src/main/db/repositories/chapter.repo.ts +++ b/src/main/db/repositories/chapter.repo.ts @@ -88,4 +88,45 @@ export class ChapterRepository { .get(volumeId) as { maxOrder: number | null } return (row?.maxOrder ?? -1) + 1 } + + reorderVolume(volumeId: string, orderedIds: string[]): Chapter[] { + const stmt = this.db.prepare( + `UPDATE chapters SET sort_order = ?, updated_at = datetime('now') WHERE id = ? AND volume_id = ?` + ) + orderedIds.forEach((id, idx) => stmt.run(idx, id, volumeId)) + return this.listByVolume(volumeId) + } + + moveToVolume(chapterId: string, targetVolumeId: string, targetIndex: number): Chapter { + const chapter = this.get(chapterId) + if (!chapter) throw new Error('Chapter not found') + const sourceVolumeId = chapter.volumeId + if (!sourceVolumeId) throw new Error('Chapter has no volume') + + if (sourceVolumeId === targetVolumeId) { + const ids = this.listByVolume(sourceVolumeId).map((c) => c.id) + const from = ids.indexOf(chapterId) + if (from < 0) throw new Error('Chapter not in volume') + ids.splice(from, 1) + ids.splice(Math.max(0, Math.min(targetIndex, ids.length)), 0, chapterId) + this.reorderVolume(sourceVolumeId, ids) + return this.get(chapterId)! + } + + const sourceIds = this.listByVolume(sourceVolumeId) + .map((c) => c.id) + .filter((id) => id !== chapterId) + this.reorderVolume(sourceVolumeId, sourceIds) + + const targetList = this.listByVolume(targetVolumeId) + const insertAt = Math.max(0, Math.min(targetIndex, targetList.length)) + const targetIds = targetList.map((c) => c.id) + targetIds.splice(insertAt, 0, chapterId) + + this.db + .prepare(`UPDATE chapters SET volume_id = ?, updated_at = datetime('now') WHERE id = ?`) + .run(targetVolumeId, chapterId) + this.reorderVolume(targetVolumeId, targetIds) + return this.get(chapterId)! + } } diff --git a/src/main/db/schema-v3.sql b/src/main/db/schema-v3.sql new file mode 100644 index 0000000..18303f7 --- /dev/null +++ b/src/main/db/schema-v3.sql @@ -0,0 +1,20 @@ +CREATE TABLE IF NOT EXISTS ai_sessions ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL DEFAULT '', + model TEXT NOT NULL DEFAULT '', + archived INTEGER NOT NULL DEFAULT 0, + context_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS ai_messages ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + role TEXT NOT NULL CHECK (role IN ('user', 'assistant', 'system')), + content TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (session_id) REFERENCES ai_sessions(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_ai_messages_session ON ai_messages(session_id, created_at); diff --git a/src/main/index.ts b/src/main/index.ts index 17ea336..992c8ed 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -2,7 +2,7 @@ import { app, BrowserWindow, ipcMain } from 'electron' import { existsSync } from 'fs' import { join } from 'path' import { IPC } from '../shared/ipc-channels' -import { getShortcutManager, getSnapshotService, registerIpc } from './ipc/register' +import { getShortcutManager, getSnapshotService, getNetworkMonitor, registerIpc } from './ipc/register' import { closeAllBookDbs } from './db/connection' if (process.env.BILIN_E2E === '1' && process.env.BILIN_E2E_USER_DATA) { @@ -38,11 +38,16 @@ function createWindow(): void { mainWindow = new BrowserWindow({ mainWindow.loadFile(join(__dirname, '../renderer/index.html')) } + if (process.env.BILIN_E2E === '1') { + mainWindow.webContents.setDevToolsEnabled(false) + } + mainWindow.on('closed', () => { mainWindow = null }) getShortcutManager()?.setWindow(mainWindow) + getNetworkMonitor()?.start(() => mainWindow) } app.whenReady().then(() => { @@ -66,6 +71,7 @@ app.whenReady().then(() => { app.on('window-all-closed', () => { getShortcutManager()?.unregisterAll() + getNetworkMonitor()?.stop() getSnapshotService()?.stopAll() closeAllBookDbs() if (process.platform !== 'darwin') app.quit() @@ -81,4 +87,5 @@ app.on('before-quit', () => { app.on('will-quit', () => { getShortcutManager()?.unregisterAll() + getNetworkMonitor()?.stop() }) diff --git a/src/main/ipc/handlers/ai.handler.ts b/src/main/ipc/handlers/ai.handler.ts new file mode 100644 index 0000000..21158c9 --- /dev/null +++ b/src/main/ipc/handlers/ai.handler.ts @@ -0,0 +1,220 @@ +import { randomUUID } from 'crypto' +import { BrowserWindow, ipcMain } from 'electron' +import { IPC } from '../../../shared/ipc-channels' +import type { AiConfig, AiContextBinding, NamingConflict } from '../../../shared/types' +import { BookRegistryService } from '../../services/book-registry' +import { GlobalSettingsService } from '../../services/global-settings' +import { AiClientService } from '../../services/ai-client.service' +import { aiContextBuilder } from '../../services/ai-context-builder.service' +import { + buildNamingPrompt, + buildNamingCorpus, + collectChaptersPlain, + findLocalNamingConflicts, + mergeNamingConflicts, + parseNamingJson +} from '../../services/naming-check.service' +import type { ChatMessage } from '../../services/ai-client.service' +import { wrap } from '../result' + +const activeChats = new Map() + +export function registerAiHandlers( + registry: BookRegistryService, + settings: GlobalSettingsService, + aiClient: AiClientService +): void { + ipcMain.handle( + IPC.AI_SESSION_LIST, + (_e, { bookId, includeArchived }: { bookId: string; includeArchived?: boolean }) => + wrap(() => registry.getAiSessionRepo(bookId).list(includeArchived)) + ) + + ipcMain.handle( + IPC.AI_SESSION_CREATE, + (_e, { bookId, title, model }: { bookId: string; title?: string; model?: string }) => + wrap(() => { + const config = settings.get().aiConfig + return registry.getAiSessionRepo(bookId).create(title, model ?? config.model) + }) + ) + + ipcMain.handle( + IPC.AI_SESSION_UPDATE, + ( + _e, + { + bookId, + sessionId, + patch + }: { + bookId: string + sessionId: string + patch: Partial<{ title: string; model: string; archived: boolean; context: AiContextBinding }> + } + ) => wrap(() => registry.getAiSessionRepo(bookId).update(sessionId, patch)) + ) + + ipcMain.handle( + IPC.AI_SESSION_DELETE, + (_e, { bookId, sessionId }: { bookId: string; sessionId: string }) => + wrap(() => registry.getAiSessionRepo(bookId).delete(sessionId)) + ) + + ipcMain.handle( + IPC.AI_MESSAGE_LIST, + (_e, { bookId, sessionId }: { bookId: string; sessionId: string }) => + wrap(() => registry.getAiSessionRepo(bookId).listMessages(sessionId)) + ) + + ipcMain.handle( + IPC.AI_BUILD_CONTEXT, + (_e, { bookId, binding }: { bookId: string; binding: AiContextBinding }) => + wrap(() => aiContextBuilder.build(binding, registry, bookId, settings.get().penName)) + ) + + ipcMain.handle(IPC.AI_TEST_CONNECTION, (_e, { config }: { config?: AiConfig }) => + wrap(async () => { + const effective = config ?? settings.get().aiConfig + const probe = new AiClientService(() => effective) + const content = await probe.chat([{ role: 'user', content: '回复一个字:好' }]) + if (!content.trim()) throw new Error('AI returned empty response') + return { ok: true as const } + }) + ) + + ipcMain.handle(IPC.AI_NAMING_CHECK, (_e, { bookId }: { bookId: string }) => + wrap(async () => { + const settingsList = registry.getSettingRepo(bookId).list() + const chapters = registry.getChapterRepo(bookId).list() + const ignoreWords = settings.get().namingIgnoreWords + const settingNames = settingsList.map((s) => s.name) + const corpus = buildNamingCorpus(settingsList, chapters) + const prompt = buildNamingPrompt(settingNames, corpus, ignoreWords) + const local = findLocalNamingConflicts(settingNames, corpus, ignoreWords) + if (local.length > 0) { + try { + const response = await Promise.race([ + aiClient.chat([{ role: 'user', content: prompt }]), + new Promise((_, reject) => + setTimeout(() => reject(new Error('ai-timeout')), 8_000) + ) + ]) + return mergeNamingConflicts(local, parseNamingJson(response)) + } catch { + return local + } + } + + const fetchAi = async (): Promise => { + const response = await aiClient.chat([{ role: 'user', content: prompt }]) + return parseNamingJson(response) + } + + try { + const ai = await fetchAi() + return mergeNamingConflicts(local, ai) + } catch { + try { + const response = await aiClient.chat([ + { role: 'user', content: `${prompt}\n\n仅返回 JSON 数组,不要 markdown 或其它说明。` } + ]) + return mergeNamingConflicts(local, parseNamingJson(response)) + } catch { + if (local.length > 0) return local + throw new Error('命名检查失败') + } + } + }) + ) + + ipcMain.handle(IPC.AI_ABORT, (_e, { messageId }: { messageId: string }) => + wrap(() => { + activeChats.get(messageId)?.abort() + activeChats.delete(messageId) + }) + ) + + ipcMain.handle( + IPC.AI_CHAT, + ( + event, + { + bookId, + sessionId, + content, + prompt + }: { bookId: string; sessionId: string; content: string; prompt?: string; slashCommand?: string } + ) => + wrap(async () => { + const repo = registry.getAiSessionRepo(bookId) + const session = repo.get(sessionId) + if (!session) throw new Error('AI session not found') + + const actualPrompt = prompt ?? content + repo.addMessage(sessionId, 'user', content) + const history = repo.listMessages(sessionId) + + const { systemPrompt } = aiContextBuilder.build( + session.context, + registry, + bookId, + settings.get().penName + ) + + const apiMessages: ChatMessage[] = [ + { role: 'system', content: systemPrompt }, + ...history.map((m, index) => ({ + role: m.role, + content: + index === history.length - 1 && m.role === 'user' ? actualPrompt : m.content + })) + ] + + const assistantId = randomUUID() + const controller = new AbortController() + activeChats.set(assistantId, controller) + const win = BrowserWindow.fromWebContents(event.sender) + + let partial = '' + try { + const full = await aiClient.chat( + apiMessages, + (delta) => { + partial += delta + win?.webContents.send(IPC.AI_STREAM_CHUNK, { + messageId: assistantId, + delta, + done: false + }) + }, + controller.signal + ) + win?.webContents.send(IPC.AI_STREAM_CHUNK, { + messageId: assistantId, + delta: '', + done: true + }) + repo.addMessage(sessionId, 'assistant', full, assistantId) + } catch (err) { + if (controller.signal.aborted && partial) { + win?.webContents.send(IPC.AI_STREAM_CHUNK, { + messageId: assistantId, + delta: '', + done: true + }) + repo.addMessage(sessionId, 'assistant', partial, assistantId) + return { messageId: assistantId } + } + throw err + } finally { + activeChats.delete(assistantId) + } + + if (!session.title.trim()) { + repo.update(sessionId, { title: content.slice(0, 40) }) + } + return { messageId: assistantId } + }) + ) +} diff --git a/src/main/ipc/handlers/chapter.handler.ts b/src/main/ipc/handlers/chapter.handler.ts index 6ecbd9a..7f10bf6 100644 --- a/src/main/ipc/handlers/chapter.handler.ts +++ b/src/main/ipc/handlers/chapter.handler.ts @@ -104,4 +104,24 @@ export function registerChapterHandlers(registry: BookRegistryService): void { ftsSync.remove(registry.getDb(bookId), 'chapter', chapterId) }) ) + + ipcMain.handle( + IPC.CHAPTER_REORDER, + (_event, { bookId, volumeId, orderedIds }: { bookId: string; volumeId: string; orderedIds: string[] }) => + wrap(() => registry.getChapterRepo(bookId).reorderVolume(volumeId, orderedIds)) + ) + + ipcMain.handle( + IPC.CHAPTER_MOVE_TO_VOLUME, + ( + _event, + { + bookId, + chapterId, + targetVolumeId, + targetIndex + }: { bookId: string; chapterId: string; targetVolumeId: string; targetIndex: number } + ) => + wrap(() => registry.getChapterRepo(bookId).moveToVolume(chapterId, targetVolumeId, targetIndex)) + ) } diff --git a/src/main/ipc/register.ts b/src/main/ipc/register.ts index e8dea14..0e9a529 100644 --- a/src/main/ipc/register.ts +++ b/src/main/ipc/register.ts @@ -14,9 +14,13 @@ import { registerInspirationHandlers } from './handlers/inspiration.handler' import { registerSnapshotHandlers } from './handlers/snapshot.handler' import { registerBookmarkHandlers } from './handlers/bookmark.handler' import { registerSearchHandlers } from './handlers/search.handler' +import { registerAiHandlers } from './handlers/ai.handler' +import { AiClientService } from '../services/ai-client.service' +import { NetworkMonitorService } from '../services/network-monitor.service' let shortcutManager: ShortcutManager | null = null let snapshotService: SnapshotService | null = null +let networkMonitor: NetworkMonitorService | null = null export function registerIpc(): { settings: GlobalSettingsService; books: BookRegistryService; shortcuts: ShortcutManager } { const userData = app.getPath('userData') @@ -25,6 +29,7 @@ export function registerIpc(): { settings: GlobalSettingsService; books: BookReg const books = new BookRegistryService(userData) shortcutManager = new ShortcutManager(settings) snapshotService = new SnapshotService(() => settings.get()) + networkMonitor = new NetworkMonitorService() registerSettingsHandlers(settings) registerBookHandlers(books) @@ -36,6 +41,7 @@ export function registerIpc(): { settings: GlobalSettingsService; books: BookReg registerSnapshotHandlers(books, snapshotService!) registerBookmarkHandlers(books) registerSearchHandlers(books, settings) + registerAiHandlers(books, settings, new AiClientService(() => settings.get().aiConfig)) return { settings, books, shortcuts: shortcutManager } } @@ -44,6 +50,10 @@ export function getShortcutManager(): ShortcutManager | null { return shortcutManager } +export function getNetworkMonitor(): NetworkMonitorService | null { + return networkMonitor +} + export function getSnapshotService(): SnapshotService | null { return snapshotService } diff --git a/src/main/ipc/result.ts b/src/main/ipc/result.ts index e249d0f..3785124 100644 --- a/src/main/ipc/result.ts +++ b/src/main/ipc/result.ts @@ -8,9 +8,18 @@ export function fail(code: ErrorCode, message: string, recoverable: boolean): Ip return { ok: false, error: { code, message, recoverable } } } -export function wrap(fn: () => T, recoverable = true): IpcResult { +export function wrap(fn: () => T | Promise, recoverable = true): IpcResult | Promise> { try { - return ok(fn()) + const out = fn() + if (out && typeof (out as Promise).then === 'function') { + return (out as Promise) + .then((data) => ok(data)) + .catch((e) => { + const message = e instanceof Error ? e.message : String(e) + return fail(ErrorCode.UNKNOWN, message, recoverable) + }) + } + return ok(out as T) } catch (e) { const message = e instanceof Error ? e.message : String(e) return fail(ErrorCode.UNKNOWN, message, recoverable) diff --git a/src/main/services/ai-client.service.ts b/src/main/services/ai-client.service.ts new file mode 100644 index 0000000..b885766 --- /dev/null +++ b/src/main/services/ai-client.service.ts @@ -0,0 +1,105 @@ +import type { AiConfig } from '../../shared/types' + +export interface ChatMessage { + role: 'system' | 'user' | 'assistant' + content: string +} + +export class AiClientService { + constructor(private getConfig: () => AiConfig) {} + + private endpoint(): string { + const { baseUrl } = this.getConfig() + return `${baseUrl.replace(/\/$/, '')}/v1/chat/completions` + } + + private mergeAbortSignals(timeoutMs: number, external?: AbortSignal): { + signal: AbortSignal + dispose: () => void + } { + const timeoutController = new AbortController() + const timer = setTimeout(() => timeoutController.abort(), timeoutMs) + const dispose = (): void => clearTimeout(timer) + + if (!external) { + return { signal: timeoutController.signal, dispose } + } + + const linked = new AbortController() + const abort = (): void => linked.abort() + if (timeoutController.signal.aborted || external.aborted) abort() + timeoutController.signal.addEventListener('abort', abort) + external.addEventListener('abort', abort) + return { + signal: linked.signal, + dispose: () => { + clearTimeout(timer) + timeoutController.signal.removeEventListener('abort', abort) + external.removeEventListener('abort', abort) + } + } + } + + async chat( + messages: ChatMessage[], + onChunk?: (delta: string) => void, + abortSignal?: AbortSignal + ): Promise { + const config = this.getConfig() + const { signal, dispose } = this.mergeAbortSignals(config.timeoutMs, abortSignal) + try { + const res = await fetch(this.endpoint(), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(config.apiKey ? { Authorization: `Bearer ${config.apiKey}` } : {}) + }, + body: JSON.stringify({ + model: config.model, + messages, + max_tokens: config.maxTokens, + stream: Boolean(onChunk) + }), + signal + }) + if (!res.ok) throw new Error(`AI ${res.status}: ${await res.text()}`) + + if (!onChunk) { + const json = (await res.json()) as { + choices?: { message?: { content?: string } }[] + } + return json.choices?.[0]?.message?.content ?? '' + } + + if (!res.body) throw new Error('AI stream: empty response body') + + let full = '' + const reader = res.body.getReader() + const decoder = new TextDecoder() + let buf = '' + while (true) { + const { done, value } = await reader.read() + if (done) break + buf += decoder.decode(value, { stream: true }) + const lines = buf.split('\n') + buf = lines.pop() ?? '' + for (const line of lines) { + if (!line.startsWith('data: ')) continue + const payload = line.slice(6).trim() + if (payload === '[DONE]') continue + const parsed = JSON.parse(payload) as { + choices?: { delta?: { content?: string } }[] + } + const delta = parsed.choices?.[0]?.delta?.content ?? '' + if (delta) { + full += delta + onChunk(delta) + } + } + } + return full + } finally { + dispose() + } + } +} diff --git a/src/main/services/ai-context-builder.service.ts b/src/main/services/ai-context-builder.service.ts new file mode 100644 index 0000000..a258acd --- /dev/null +++ b/src/main/services/ai-context-builder.service.ts @@ -0,0 +1,131 @@ +import { stripHtml } from './word-count' +import type { AiContextBinding, AiContextBuildResult, ContextPreviewItem } from '../../shared/types' +import type { BookRegistryService } from './book-registry' + +const CHAPTER_CHAR_LIMIT = 2000 +const OTHER_CHAR_LIMIT = 500 +const TOTAL_CHAR_LIMIT = 16 * 1024 + +interface BlockCandidate { + sortKey: number + text: string + preview: ContextPreviewItem +} + +function truncate(text: string, max: number): string { + if (text.length <= max) return text + return `${text.slice(0, max)}…` +} + +function formatBlock(kind: ContextPreviewItem['kind'], title: string, body: string): string { + const label = + kind === 'chapter' ? '章节' : kind === 'outline' ? '大纲' : kind === 'setting' ? '设定' : '灵感' + return `【${label}】${title}\n${body}` +} + +export class AiContextBuilderService { + build( + binding: AiContextBinding, + registry: BookRegistryService, + bookId: string, + penName: string + ): AiContextBuildResult { + const meta = registry.getMeta(bookId) + if (!meta) throw new Error('Book not found') + + const candidates: BlockCandidate[] = [] + + for (const id of binding.chapterIds) { + const chapter = registry.getChapterRepo(bookId).get(id) + if (!chapter) continue + const plain = stripHtml(chapter.content) + const body = truncate(plain, CHAPTER_CHAR_LIMIT) + candidates.push({ + sortKey: chapter.sortOrder, + text: formatBlock('chapter', chapter.title, body), + preview: { + kind: 'chapter', + id, + title: chapter.title, + excerpt: body, + charCount: body.length + } + }) + } + + for (const id of binding.outlineIds) { + const item = registry.getOutlineRepo(bookId).get(id) + if (!item) continue + const body = truncate(stripHtml(item.description), OTHER_CHAR_LIMIT) + candidates.push({ + sortKey: item.sortOrder, + text: formatBlock('outline', item.title, body), + preview: { + kind: 'outline', + id, + title: item.title, + excerpt: body, + charCount: body.length + } + }) + } + + for (const id of binding.settingIds) { + const item = registry.getSettingRepo(bookId).get(id) + if (!item) continue + const body = truncate(stripHtml(item.description), OTHER_CHAR_LIMIT) + candidates.push({ + sortKey: 0, + text: formatBlock('setting', item.name, body), + preview: { + kind: 'setting', + id, + title: item.name, + excerpt: body, + charCount: body.length + } + }) + } + + for (const id of binding.inspirationIds) { + const item = registry.getInspirationRepo(bookId).get(id) + if (!item) continue + const body = truncate(stripHtml(item.content), OTHER_CHAR_LIMIT) + candidates.push({ + sortKey: Date.parse(item.updatedAt) || 0, + text: formatBlock('inspiration', item.title, body), + preview: { + kind: 'inspiration', + id, + title: item.title, + excerpt: body, + charCount: body.length + } + }) + } + + candidates.sort((a, b) => b.sortKey - a.sortKey) + + const selected: BlockCandidate[] = [] + let totalChars = 0 + for (const candidate of candidates) { + const nextTotal = totalChars + candidate.text.length + (selected.length > 0 ? 2 : 0) + if (nextTotal > TOTAL_CHAR_LIMIT) break + selected.push(candidate) + totalChars = nextTotal + } + + const contextBlocks = selected.map((x) => x.text).join('\n\n') + const systemPrompt = `你是笔临 AI 写作助手。作者笔名:${penName}。书籍:${meta.name}。 +以下是与本书相关的上下文(由作者勾选): +${contextBlocks || '(无)'} +请用简体中文回答,尊重作者的创作主权。` + + return { + systemPrompt, + preview: selected.map((x) => x.preview) + } + } +} + +export const aiContextBuilder = new AiContextBuilderService() diff --git a/src/main/services/book-registry.ts b/src/main/services/book-registry.ts index 9fde876..ed4e2fd 100644 --- a/src/main/services/book-registry.ts +++ b/src/main/services/book-registry.ts @@ -7,6 +7,7 @@ import { ChapterRepository } from '../db/repositories/chapter.repo' import { OutlineRepository } from '../db/repositories/outline.repo' import { SettingRepository } from '../db/repositories/setting.repo' import { InspirationRepository } from '../db/repositories/inspiration.repo' +import { AiSessionRepository } from '../db/repositories/ai-session.repo' import { SnapshotRepository } from '../db/repositories/snapshot.repo' import { BookmarkRepository } from '../db/repositories/bookmark.repo' import type { BookMeta, BookOpenResult, CreateBookParams, Chapter, Volume } from '../../shared/types' @@ -145,6 +146,10 @@ export class BookRegistryService { return new InspirationRepository(this.getDb(bookId)) } + getAiSessionRepo(bookId: string): AiSessionRepository { + return new AiSessionRepository(this.getDb(bookId)) + } + getSnapshotRepo(bookId: string): SnapshotRepository { return new SnapshotRepository(this.getDb(bookId)) } diff --git a/src/main/services/global-settings.ts b/src/main/services/global-settings.ts index 796f264..ec7acee 100644 --- a/src/main/services/global-settings.ts +++ b/src/main/services/global-settings.ts @@ -1,10 +1,18 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs' import { join } from 'path' import { DEFAULT_SHORTCUTS } from '../../shared/default-shortcuts' -import type { GlobalSettings } from '../../shared/types' +import { DEFAULT_AI_CONFIG, type GlobalSettings } from '../../shared/types' const FILE = 'global_settings.json' +function defaultAiConfig() { + return { + ...DEFAULT_AI_CONFIG, + baseUrl: process.env.BILIN_AI_BASE_URL ?? DEFAULT_AI_CONFIG.baseUrl, + model: process.env.BILIN_AI_MODEL ?? DEFAULT_AI_CONFIG.model + } +} + function defaults(): GlobalSettings { return { penName: '未命名作者', @@ -16,7 +24,9 @@ function defaults(): GlobalSettings { snapshotIntervalMs: 300_000, snapshotMaxAuto: 20, snapshotMaxPersist: 3, - searchHistory: [] + searchHistory: [], + aiConfig: defaultAiConfig(), + namingIgnoreWords: [] } } @@ -35,7 +45,8 @@ export class GlobalSettingsService { return { ...defaults(), ...raw, - shortcuts: { ...DEFAULT_SHORTCUTS, ...raw.shortcuts } + shortcuts: { ...DEFAULT_SHORTCUTS, ...raw.shortcuts }, + aiConfig: { ...DEFAULT_AI_CONFIG, ...raw.aiConfig } } } @@ -46,7 +57,8 @@ export class GlobalSettingsService { ...partial, shortcuts: partial.shortcuts ? { ...current.shortcuts, ...partial.shortcuts } - : current.shortcuts + : current.shortcuts, + aiConfig: partial.aiConfig ? { ...current.aiConfig, ...partial.aiConfig } : current.aiConfig } writeFileSync(this.filePath(), JSON.stringify(next, null, 2), 'utf-8') return next diff --git a/src/main/services/naming-check.service.ts b/src/main/services/naming-check.service.ts new file mode 100644 index 0000000..15002e4 --- /dev/null +++ b/src/main/services/naming-check.service.ts @@ -0,0 +1,151 @@ +import type { NamingConflict } from '../../shared/types' + +export function parseNamingJson(response: string): NamingConflict[] { + const trimmed = response.trim() + const start = trimmed.indexOf('[') + const end = trimmed.lastIndexOf(']') + if (start < 0 || end <= start) { + throw new Error('命名检查响应中未找到 JSON 数组') + } + + const parsed = JSON.parse(trimmed.slice(start, end + 1)) as unknown + if (!Array.isArray(parsed)) { + throw new Error('命名检查响应不是 JSON 数组') + } + + const conflicts: NamingConflict[] = [] + for (const item of parsed) { + if (!item || typeof item !== 'object') continue + const row = item as Record + const termA = String(row.termA ?? '').trim() + const termB = String(row.termB ?? '').trim() + const reason = String(row.reason ?? '').trim() + const confidence = Number(row.confidence) + if (!termA || !termB || !Number.isFinite(confidence)) continue + conflicts.push({ + termA, + termB, + confidence, + reason, + suggestedCanonical: + typeof row.suggestedCanonical === 'string' ? row.suggestedCanonical.trim() : undefined + }) + } + + return conflicts.sort((a, b) => b.confidence - a.confidence) +} + +export function buildNamingCorpus( + settings: { name: string; description: string }[], + chapters: { title: string; content: string }[] +): string { + const chapterText = collectChaptersPlain(chapters) + const settingText = settings + .map((s) => stripHtml(s.description)) + .filter(Boolean) + .join('\n\n') + return [chapterText, settingText].filter(Boolean).join('\n\n') +} + +export function buildNamingPrompt( + settingNames: string[], + corpusPlain: string, + ignoreWords: string[] +): string { + const names = settingNames.filter(Boolean).join('、') || '(无)' + const ignore = ignoreWords.filter(Boolean).join('、') || '(无)' + const excerpt = corpusPlain.slice(0, 12_000) + + return `你是小说编辑助手。请检查下列设定名称与正文片段中是否存在**同一实体/概念的不同写法**(如「林远/林悦」「玄火掌/焰火掌」)。 + +设定名称:${names} +忽略词:${ignore} + +正文片段: +${excerpt} + +仅返回 JSON 数组,不要 markdown 或其它说明。每项格式: +{"termA":"写法A","termB":"写法B","confidence":0.0-1.0,"reason":"简短原因","suggestedCanonical":"建议统一用名"} + +若无冲突返回 []。` +} + +function stripHtml(html: string): string { + return html.replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim() +} + +function extractBodyTokens(text: string): Set { + const tokens = new Set() + const segments = text.match(/[\u4e00-\u9fff]+/g) ?? [] + for (const segment of segments) { + for (let len = 2; len <= Math.min(4, segment.length); len++) { + for (let i = 0; i <= segment.length - len; i++) { + tokens.add(segment.slice(i, i + len)) + } + } + } + return tokens +} + +export function findLocalNamingConflicts( + settingNames: string[], + corpusPlain: string, + ignoreWords: string[] +): NamingConflict[] { + const ignore = new Set(ignoreWords) + const tokens = extractBodyTokens(corpusPlain) + const conflicts: NamingConflict[] = [] + + for (const name of settingNames) { + const trimmed = name.trim() + if (!trimmed || ignore.has(trimmed)) continue + + for (const token of tokens) { + if (token === trimmed || ignore.has(token)) continue + if (trimmed.length !== token.length || trimmed[0] !== token[0]) continue + + const diffCount = [...trimmed].filter((ch, index) => ch !== token[index]).length + if (diffCount === 0 || diffCount > 2) continue + + conflicts.push({ + termA: trimmed, + termB: token, + confidence: 0.88, + reason: '设定名与正文用词仅一字之差', + suggestedCanonical: trimmed + }) + } + } + + const seen = new Set() + return conflicts.filter((row) => { + const key = [row.termA, row.termB].sort().join('|') + if (seen.has(key)) return false + seen.add(key) + return true + }) +} + +function mergeNamingConflicts(...groups: NamingConflict[][]): NamingConflict[] { + const seen = new Set() + const merged: NamingConflict[] = [] + for (const group of groups) { + for (const row of group) { + const key = [row.termA, row.termB].sort().join('|') + if (seen.has(key)) continue + seen.add(key) + merged.push(row) + } + } + return merged.sort((a, b) => b.confidence - a.confidence) +} + +export function collectChaptersPlain( + chapters: { title: string; content: string }[] +): string { + return chapters + .map((ch) => `${ch.title}\n${stripHtml(ch.content)}`) + .join('\n\n') +} + +export { mergeNamingConflicts } diff --git a/src/main/services/network-monitor.service.ts b/src/main/services/network-monitor.service.ts new file mode 100644 index 0000000..ec6b8ac --- /dev/null +++ b/src/main/services/network-monitor.service.ts @@ -0,0 +1,31 @@ +import { net, type BrowserWindow } from 'electron' +import { IPC } from '../../shared/ipc-channels' + +export class NetworkMonitorService { + private timer: ReturnType | null = null + private online = net.isOnline() + + start(getWindow: () => BrowserWindow | null): void { + this.stop() + this.broadcast(getWindow) + this.timer = setInterval(() => { + const now = net.isOnline() + if (now !== this.online) { + this.online = now + this.broadcast(getWindow) + } + }, 30_000) + } + + private broadcast(getWindow: () => BrowserWindow | null): void { + const win = getWindow() + win?.webContents.send(IPC.AI_NETWORK_STATUS, { online: this.online }) + } + + stop(): void { + if (this.timer) { + clearInterval(this.timer) + this.timer = null + } + } +} diff --git a/src/main/services/search.service.ts b/src/main/services/search.service.ts index 668675a..30e4ace 100644 --- a/src/main/services/search.service.ts +++ b/src/main/services/search.service.ts @@ -1,5 +1,6 @@ import type { SearchResult, SearchSourceKind } from '../../shared/types' import type { SqliteDb } from '../db/types' +import { ftsSync } from './fts-sync.service' export interface SearchQueryParams { text: string @@ -137,8 +138,8 @@ export class SearchService { for (const hit of results) { if (hit.kind === 'chapter') { - const row = db.prepare('SELECT content FROM chapters WHERE id = ?').get(hit.id) as - | { content: string } + const row = db.prepare('SELECT title, content FROM chapters WHERE id = ?').get(hit.id) as + | { title: string; content: string } | undefined if (!row) continue const next = row.content.replace(re, params.replacement) @@ -147,6 +148,7 @@ export class SearchService { next, hit.id ) + ftsSync.upsert(db, 'chapter', hit.id, row.title, next) count++ } } diff --git a/src/preload/index.ts b/src/preload/index.ts index bebc990..dc6a3dd 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -9,6 +9,14 @@ import type { CreateBookParams, GlobalSettings, InspirationEntry, + AiContextBinding, + AiContextBuildResult, + AiConfig, + AiMessage, + AiSession, + NamingConflict, + AiStreamChunkEvent, + AiNetworkStatusEvent, IpcResult, LandmarkType, OutlineItem, @@ -67,7 +75,16 @@ const electronAPI = { update: (params: UpdateChapterParams): Promise> => ipcRenderer.invoke(IPC.CHAPTER_UPDATE, params), delete: (bookId: string, chapterId: string): Promise> => - ipcRenderer.invoke(IPC.CHAPTER_DELETE, { bookId, chapterId }) + ipcRenderer.invoke(IPC.CHAPTER_DELETE, { bookId, chapterId }), + reorder: (bookId: string, volumeId: string, orderedIds: string[]): Promise> => + ipcRenderer.invoke(IPC.CHAPTER_REORDER, { bookId, volumeId, orderedIds }), + moveToVolume: ( + bookId: string, + chapterId: string, + targetVolumeId: string, + targetIndex: number + ): Promise> => + ipcRenderer.invoke(IPC.CHAPTER_MOVE_TO_VOLUME, { bookId, chapterId, targetVolumeId, targetIndex }) }, outline: { list: (bookId: string): Promise> => @@ -213,6 +230,40 @@ const electronAPI = { saveHistory: (query: string): Promise> => ipcRenderer.invoke(IPC.SEARCH_SAVE_HISTORY, { query }) }, + ai: { + sessionList: (bookId: string, includeArchived?: boolean): Promise> => + ipcRenderer.invoke(IPC.AI_SESSION_LIST, { bookId, includeArchived }), + sessionCreate: (bookId: string, title?: string, model?: string): Promise> => + ipcRenderer.invoke(IPC.AI_SESSION_CREATE, { bookId, title, model }), + sessionUpdate: ( + bookId: string, + sessionId: string, + patch: Partial<{ title: string; model: string; archived: boolean; context: AiContextBinding }> + ): Promise> => + ipcRenderer.invoke(IPC.AI_SESSION_UPDATE, { bookId, sessionId, patch }), + sessionDelete: (bookId: string, sessionId: string): Promise> => + ipcRenderer.invoke(IPC.AI_SESSION_DELETE, { bookId, sessionId }), + messageList: (bookId: string, sessionId: string): Promise> => + ipcRenderer.invoke(IPC.AI_MESSAGE_LIST, { bookId, sessionId }), + chat: ( + bookId: string, + sessionId: string, + content: string, + prompt?: string + ): Promise> => + ipcRenderer.invoke(IPC.AI_CHAT, { bookId, sessionId, content, prompt }), + abort: (messageId: string): Promise> => + ipcRenderer.invoke(IPC.AI_ABORT, { messageId }), + buildContext: ( + bookId: string, + binding: AiContextBinding + ): Promise> => + ipcRenderer.invoke(IPC.AI_BUILD_CONTEXT, { bookId, binding }), + testConnection: (config?: AiConfig): Promise> => + ipcRenderer.invoke(IPC.AI_TEST_CONNECTION, { config }), + namingCheck: (bookId: string): Promise> => + ipcRenderer.invoke(IPC.AI_NAMING_CHECK, { bookId }) + }, wordfreq: { analyze: ( bookId: string, @@ -232,6 +283,20 @@ const electronAPI = { } ipcRenderer.on(IPC.SHORTCUT_TRIGGERED, handler) return () => ipcRenderer.removeListener(IPC.SHORTCUT_TRIGGERED, handler) + }, + onAiStreamChunk: (callback: (payload: AiStreamChunkEvent) => void): (() => void) => { + const handler = (_event: IpcRendererEvent, payload: AiStreamChunkEvent) => { + callback(payload) + } + ipcRenderer.on(IPC.AI_STREAM_CHUNK, handler) + return () => ipcRenderer.removeListener(IPC.AI_STREAM_CHUNK, handler) + }, + onAiNetworkStatus: (callback: (payload: AiNetworkStatusEvent) => void): (() => void) => { + const handler = (_event: IpcRendererEvent, payload: AiNetworkStatusEvent) => { + callback(payload) + } + ipcRenderer.on(IPC.AI_NETWORK_STATUS, handler) + return () => ipcRenderer.removeListener(IPC.AI_NETWORK_STATUS, handler) } } diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 6582171..506ec5a 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -16,10 +16,12 @@ import { useTabStore } from '@renderer/stores/useTabStore' import { useBookStore } from '@renderer/stores/useBookStore' import { useEditStore } from '@renderer/stores/useEditStore' import { SearchModal } from '@renderer/components/search/SearchModal' +import { InspirationModal } from '@renderer/components/inspiration/InspirationModal' import { useSearchStore } from '@renderer/stores/useSearchStore' import { useReferenceStore } from '@renderer/stores/useReferenceStore' import { flushEditorSave } from '@renderer/components/editor/TipTapEditor' import { insertLandmarkInEditor } from '@renderer/lib/editor-commands' +import { useAiStore } from '@renderer/stores/useAiStore' import { ipcCall } from '@renderer/lib/ipc-client' function AppInner(): React.JSX.Element { @@ -48,6 +50,11 @@ function AppInner(): React.JSX.Element { useEffect(() => { const openSearch = (): void => useSearchStore.getState().setOpen(true) const openLandmarks = (): void => useAppStore.getState().setLandmarksModalOpen(true) + const openInspiration = (): void => { + if (useAppStore.getState().view !== 'editor') return + if (!useBookStore.getState().currentBookId) return + useAppStore.getState().setInspirationModalOpen(true) + } const onInsertLandmark = (e: Event): void => { const label = (e as CustomEvent<{ label: string }>).detail?.label if (!label) return @@ -59,13 +66,26 @@ function AppInner(): React.JSX.Element { window.electronAPI.bookmark.create(currentBookId, target.id, 0, label, 'todo') ) } + const onSetAiOnline = (e: Event): void => { + const online = (e as CustomEvent<{ online: boolean }>).detail?.online + if (typeof online === 'boolean') useAiStore.setState({ online }) + } + const onFlushEditor = (): void => { + void flushEditorSave() + } window.addEventListener('bilin:e2e-open-search', openSearch) window.addEventListener('bilin:e2e-open-landmarks', openLandmarks) + window.addEventListener('bilin:e2e-capture-inspiration', openInspiration) window.addEventListener('bilin:e2e-insert-landmark', onInsertLandmark) + window.addEventListener('bilin:e2e-set-ai-online', onSetAiOnline) + window.addEventListener('bilin:e2e-flush-editor', onFlushEditor) return () => { window.removeEventListener('bilin:e2e-open-search', openSearch) window.removeEventListener('bilin:e2e-open-landmarks', openLandmarks) + window.removeEventListener('bilin:e2e-capture-inspiration', openInspiration) window.removeEventListener('bilin:e2e-insert-landmark', onInsertLandmark) + window.removeEventListener('bilin:e2e-set-ai-online', onSetAiOnline) + window.removeEventListener('bilin:e2e-flush-editor', onFlushEditor) } }, []) @@ -123,16 +143,8 @@ function AppInner(): React.JSX.Element { } if (action === 'captureInspiration') { if (view !== 'editor') return - const { currentBookId, addInspirationLocal } = useBookStore.getState() - if (!currentBookId) return - void (async () => { - const item = await ipcCall(() => - window.electronAPI.inspiration.create(currentBookId, t('inspiration.newItem')) - ) - addInspirationLocal(item) - useAppStore.getState().setSidebarPanel('inspiration') - await useEditStore.getState().switchTarget({ kind: 'inspiration', id: item.id }) - })() + if (!useBookStore.getState().currentBookId) return + useAppStore.getState().setInspirationModalOpen(true) return } showToast(t('feature.comingSoon')) @@ -154,6 +166,7 @@ function AppInner(): React.JSX.Element { {showOnboarding && } + {toast &&
{toast}
} ) diff --git a/src/renderer/components/ai/AiPanel.tsx b/src/renderer/components/ai/AiPanel.tsx new file mode 100644 index 0000000..4a9574d --- /dev/null +++ b/src/renderer/components/ai/AiPanel.tsx @@ -0,0 +1,229 @@ +import { useEffect, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import type { AiWritingMode } from '@shared/types' +import { useBookStore } from '@renderer/stores/useBookStore' +import { useAppStore } from '@renderer/stores/useAppStore' +import { useAiStore } from '@renderer/stores/useAiStore' +import { useSettingsStore } from '@renderer/stores/useSettingsStore' +import { applySlashCommand } from '@renderer/lib/ai-slash-commands' +import { insertToEditor } from '@renderer/lib/editor-commands' +import { ipcCall } from '@renderer/lib/ipc-client' +import { ContextEditorModal } from '@renderer/components/ai/ContextEditorModal' + +const MODES: { id: AiWritingMode; labelKey: string }[] = [ + { id: 'chat', labelKey: 'ai.mode.chat' }, + { id: 'interactive', labelKey: 'ai.mode.interactive' }, + { id: 'auto', labelKey: 'ai.mode.auto' }, + { id: 'wizard', labelKey: 'ai.mode.wizard' } +] + +const QUICK_CMDS = ['/续写', '/扩写', '/润色', '/总结', '/纠错'] + +export function AiPanel(): React.JSX.Element { + const { t } = useTranslation() + const showToast = useAppStore((s) => s.showToast) + const bookId = useBookStore((s) => s.currentBookId) + const sessions = useAiStore((s) => s.sessions) + const activeSessionId = useAiStore((s) => s.activeSessionId) + const messages = useAiStore((s) => s.messages) + const streamingBuffer = useAiStore((s) => s.streamingBuffer) + const streamingMessageId = useAiStore((s) => s.streamingMessageId) + const sending = useAiStore((s) => s.sending) + const online = useAiStore((s) => s.online) + const backend = useSettingsStore((s) => s.aiConfig.backend) + const disabled = backend !== 'lmstudio' && !online + const writingMode = useAiStore((s) => s.writingMode) + const contextSummary = useAiStore((s) => s.contextSummary) + const loadSessions = useAiStore((s) => s.loadSessions) + const selectSession = useAiStore((s) => s.selectSession) + const createSession = useAiStore((s) => s.createSession) + const sendMessage = useAiStore((s) => s.sendMessage) + const setWritingMode = useAiStore((s) => s.setWritingMode) + const [input, setInput] = useState('') + const [contextOpen, setContextOpen] = useState(false) + const messagesEndRef = useRef(null) + const prevOnlineRef = useRef(online) + + useEffect(() => { + if (!prevOnlineRef.current && online && backend !== 'lmstudio') { + showToast(t('ai.networkRestored')) + } + prevOnlineRef.current = online + }, [online, backend, showToast, t]) + + useEffect(() => { + useAiStore.getState().mount() + return () => useAiStore.getState().unmount() + }, []) + + useEffect(() => { + if (bookId) void loadSessions(bookId) + }, [bookId, loadSessions]) + + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) + }, [messages, streamingBuffer]) + + const handleSend = (): void => { + const text = input.trim() + if (!text || sending || disabled) return + const { display, prompt } = applySlashCommand(text, t) + setInput('') + void sendMessage(display, prompt !== display ? prompt : undefined) + } + + const handleInsert = async (content: string): Promise => { + try { + await insertToEditor(content, t('snapshot.ai'), async (bookId, chapterId, html, label) => { + await ipcCall(() => + window.electronAPI.snapshot.create(bookId, chapterId, 'ai', label, html) + ) + }) + showToast(t('ai.inserted')) + } catch (err) { + showToast(err instanceof Error ? err.message : String(err)) + } + } + + const handleModeClick = (mode: AiWritingMode): void => { + if (mode !== 'chat') { + showToast(t('feature.comingSoon')) + return + } + setWritingMode(mode) + } + + if (!bookId) { + return ( +
+ {t('ai.noBook')} +
+ ) + } + + return ( + <> +
+
+ + +
+ +
+ {MODES.map((mode) => ( + + ))} +
+ + + + {disabled && ( +
+ {t('ai.offlineBanner')} +
+ )} + +
+ {messages.map((msg) => ( +
+
{msg.content}
+ {msg.role === 'assistant' && ( + + )} +
+ ))} + {streamingMessageId && streamingBuffer && ( +
+ {streamingBuffer} +
+ )} +
+
+ +
+ {QUICK_CMDS.map((cmd) => ( + + ))} +
+ +
+ setInput(e.target.value)} + placeholder={t('ai.inputPlaceholder')} + disabled={sending || disabled} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault() + handleSend() + } + }} + /> + +
+
+ setContextOpen(false)} /> + + ) +} diff --git a/src/renderer/components/ai/ContextEditorModal.tsx b/src/renderer/components/ai/ContextEditorModal.tsx new file mode 100644 index 0000000..6d6ab7b --- /dev/null +++ b/src/renderer/components/ai/ContextEditorModal.tsx @@ -0,0 +1,150 @@ +import { useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import type { AiContextBinding } from '@shared/types' +import { useBookStore } from '@renderer/stores/useBookStore' +import { useAiStore } from '@renderer/stores/useAiStore' +import { ipcCall } from '@renderer/lib/ipc-client' +import { formatContextSummary } from '@renderer/lib/ai-context-summary' +import i18n from '@renderer/i18n' + +type ContextTab = 'chapter' | 'outline' | 'setting' | 'inspiration' + +interface ContextEditorModalProps { + open: boolean + onClose: () => void +} + +const EMPTY_BINDING: AiContextBinding = { + chapterIds: [], + outlineIds: [], + settingIds: [], + inspirationIds: [] +} + +export function ContextEditorModal({ open, onClose }: ContextEditorModalProps): React.JSX.Element | null { + const { t } = useTranslation() + const bookId = useBookStore((s) => s.currentBookId) + const chapters = useBookStore((s) => s.chapters) + const outlines = useBookStore((s) => s.outlines) + const settings = useBookStore((s) => s.settings) + const inspirations = useBookStore((s) => s.inspirations) + const activeSessionId = useAiStore((s) => s.activeSessionId) + const sessions = useAiStore((s) => s.sessions) + const [tab, setTab] = useState('setting') + const [binding, setBinding] = useState(EMPTY_BINDING) + + useEffect(() => { + if (!open) return + const session = sessions.find((s) => s.id === activeSessionId) + setBinding(session?.context ?? EMPTY_BINDING) + }, [open, activeSessionId, sessions]) + + const toggle = (kind: ContextTab, id: string): void => { + const key = + kind === 'chapter' + ? 'chapterIds' + : kind === 'outline' + ? 'outlineIds' + : kind === 'setting' + ? 'settingIds' + : 'inspirationIds' + setBinding((prev) => { + const list = prev[key] + return { + ...prev, + [key]: list.includes(id) ? list.filter((x) => x !== id) : [...list, id] + } + }) + } + + const handleSave = async (): Promise => { + if (!bookId || !activeSessionId) return + const updated = await ipcCall(() => + window.electronAPI.ai.sessionUpdate(bookId, activeSessionId, { context: binding }) + ) + useAiStore.setState((s) => ({ + sessions: s.sessions.map((x) => (x.id === updated.id ? updated : x)) + })) + await useAiStore.getState().refreshContextPreview() + onClose() + } + + const handleRefresh = async (): Promise => { + if (!bookId) return + const result = await ipcCall(() => window.electronAPI.ai.buildContext(bookId, binding)) + useAiStore.setState({ + contextPreview: result.preview, + contextSummary: formatContextSummary(result.preview, i18n.t) + }) + } + + if (!open) return null + + const items = + tab === 'chapter' + ? chapters.map((c) => ({ id: c.id, title: c.title })) + : tab === 'outline' + ? outlines.map((o) => ({ id: o.id, title: o.title })) + : tab === 'setting' + ? settings.map((s) => ({ id: s.id, title: s.name })) + : inspirations.map((i) => ({ id: i.id, title: i.title || t('inspiration.untitled') })) + + const selectedIds = + tab === 'chapter' + ? binding.chapterIds + : tab === 'outline' + ? binding.outlineIds + : tab === 'setting' + ? binding.settingIds + : binding.inspirationIds + + return ( +
+
+
+

{t('ai.contextEditorTitle')}

+ +
+
+ {(['chapter', 'outline', 'setting', 'inspiration'] as const).map((id) => ( + + ))} +
+
+ {items.length === 0 &&
{t('ai.contextEmptyList')}
} + {items.map((item) => ( + + ))} +
+
+ + + +
+
+
+ ) +} diff --git a/src/renderer/components/ai/NamingCheckModal.tsx b/src/renderer/components/ai/NamingCheckModal.tsx new file mode 100644 index 0000000..12e8e6a --- /dev/null +++ b/src/renderer/components/ai/NamingCheckModal.tsx @@ -0,0 +1,124 @@ +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import type { NamingConflict } from '@shared/types' +import { useBookStore } from '@renderer/stores/useBookStore' +import { useSettingsStore } from '@renderer/stores/useSettingsStore' +import { useAppStore } from '@renderer/stores/useAppStore' +import { useEditStore } from '@renderer/stores/useEditStore' +import { ipcCall } from '@renderer/lib/ipc-client' + +interface NamingCheckModalProps { + open: boolean + onClose: () => void +} + +export function NamingCheckModal({ open, onClose }: NamingCheckModalProps): React.JSX.Element | null { + const { t } = useTranslation() + const bookId = useBookStore((s) => s.currentBookId) + const openBook = useBookStore((s) => s.openBook) + const namingIgnoreWords = useSettingsStore((s) => s.namingIgnoreWords) + const updateSettings = useSettingsStore((s) => s.update) + const showToast = useAppStore((s) => s.showToast) + const reloadTarget = useEditStore((s) => s.reloadTarget) + const [loading, setLoading] = useState(false) + const [conflicts, setConflicts] = useState([]) + + if (!open) return null + + const runCheck = async (): Promise => { + const activeBookId = useBookStore.getState().currentBookId + if (!activeBookId) return + setLoading(true) + try { + const rows = await ipcCall(() => window.electronAPI.ai.namingCheck(activeBookId)) + const ignore = new Set(namingIgnoreWords) + setConflicts( + rows.filter((row) => !ignore.has(row.termA) && !ignore.has(row.termB)) + ) + if (rows.length === 0) { + showToast(t('naming.noConflicts')) + } + } catch (err) { + showToast(err instanceof Error ? err.message : String(err)) + setConflicts([]) + } finally { + setLoading(false) + } + } + + const handleIgnore = (row: NamingConflict): void => { + const next = Array.from(new Set([...namingIgnoreWords, row.termA, row.termB])) + void updateSettings({ namingIgnoreWords: next }) + setConflicts((list) => list.filter((item) => item !== row)) + showToast(t('naming.ignored')) + } + + const handleReplace = async (row: NamingConflict): Promise => { + if (!bookId) return + const canonical = row.suggestedCanonical || row.termA + const from = row.termB + if (!from || from === canonical) return + try { + const result = await ipcCall(() => + window.electronAPI.search.replace(bookId, from, canonical, false, { caseSensitive: true }) + ) + await openBook(bookId) + reloadTarget() + setConflicts((list) => list.filter((item) => item !== row)) + showToast(t('naming.replaced', { count: result.count })) + } catch (err) { + showToast(err instanceof Error ? err.message : String(err)) + } + } + + return ( +
+
+
+

{t('naming.title')}

+ +
+
+ {conflicts.length === 0 && !loading && ( +

{t('naming.emptyHint')}

+ )} + {conflicts.map((row, index) => ( +
+
+ {row.termA}{row.termB} +
+
+ {t('naming.confidence', { value: Math.round(row.confidence * 100) })} + {row.reason ? ` · ${row.reason}` : ''} +
+
+ + +
+
+ ))} +
+
+ + +
+
+
+ ) +} diff --git a/src/renderer/components/editor/TipTapEditor.tsx b/src/renderer/components/editor/TipTapEditor.tsx index f491f41..b3c07fd 100644 --- a/src/renderer/components/editor/TipTapEditor.tsx +++ b/src/renderer/components/editor/TipTapEditor.tsx @@ -8,11 +8,11 @@ import { useSetAtom } from 'jotai' import type { EditTarget } from '@shared/types' import { editorDirtyAtom, editorSavingAtom, lastSavedAtAtom, wordCountAtom } from '@renderer/atoms/editorAtoms' import { useBookStore } from '@renderer/stores/useBookStore' -import { flushEditSession, registerEditorFlush } from '@renderer/stores/useEditStore' +import { flushEditSession, registerEditorFlush, useEditStore } from '@renderer/stores/useEditStore' import { ipcCall } from '@renderer/lib/ipc-client' import { LandmarkMark } from '@renderer/extensions/landmark-mark' import { SettingMention } from '@renderer/extensions/mention' -import { registerHighlightToken, registerInsertLandmark } from '@renderer/lib/editor-commands' +import { registerHighlightToken, registerInsertLandmark, registerEditorInsert } from '@renderer/lib/editor-commands' function countPlain(text: string): number { const stripped = text.replace(/<[^>]+>/g, '').replace(/\s+/g, '') const cjk = stripped.match(/[\u4e00-\u9fff\u3400-\u4dbf]/g) @@ -149,6 +149,13 @@ export function TipTapEditor({ target, bookId }: TipTapEditorProps): React.JSX.E } }, editorProps: { + handleClick: (_view, _pos, event) => { + const el = (event.target as HTMLElement).closest('[data-setting-mention]') + if (!el) return false + const id = el.getAttribute('data-id') + if (id) void useEditStore.getState().switchTarget({ kind: 'setting', id }) + return true + }, handleDOMEvents: { dragover: (_view, event) => { event.preventDefault() @@ -282,7 +289,29 @@ export function TipTapEditor({ target, bookId }: TipTapEditorProps): React.JSX.E }, [editor]) useEffect(() => { - if (!editor || !target) return + if (!editor || !bookId || !target || target.kind !== 'chapter') { + registerEditorInsert(null, () => {}) + return + } + registerEditorInsert( + { + bookId, + chapterId: target.id, + getHtml: () => editor.getHTML() + }, + (text) => { + editor.chain().focus().insertContent(text).run() + } + ) + return () => registerEditorInsert(null, () => {}) + }, [editor, bookId, target]) + + useEffect(() => { + if (!editor) return + if (!target) { + targetKeyRef.current = null + return + } const key = `${target.kind}:${target.id}` if (targetKeyRef.current === key) return targetKeyRef.current = key diff --git a/src/renderer/components/inspiration/InspirationModal.tsx b/src/renderer/components/inspiration/InspirationModal.tsx new file mode 100644 index 0000000..9a54774 --- /dev/null +++ b/src/renderer/components/inspiration/InspirationModal.tsx @@ -0,0 +1,144 @@ +import { useEffect, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { useAppStore } from '@renderer/stores/useAppStore' +import { useBookStore } from '@renderer/stores/useBookStore' +import { useEditStore } from '@renderer/stores/useEditStore' +import { ipcCall } from '@renderer/lib/ipc-client' + +type SpeechRecognitionCtor = new () => SpeechRecognition + +function getSpeechRecognition(): SpeechRecognitionCtor | null { + const w = window as Window & { + SpeechRecognition?: SpeechRecognitionCtor + webkitSpeechRecognition?: SpeechRecognitionCtor + } + return w.SpeechRecognition ?? w.webkitSpeechRecognition ?? null +} + +export function InspirationModal(): React.JSX.Element | null { + const { t } = useTranslation() + const open = useAppStore((s) => s.inspirationModalOpen) + const setOpen = useAppStore((s) => s.setInspirationModalOpen) + const setSidebarPanel = useAppStore((s) => s.setSidebarPanel) + const bookId = useBookStore((s) => s.currentBookId) + const addInspirationLocal = useBookStore((s) => s.addInspirationLocal) + const switchTarget = useEditStore((s) => s.switchTarget) + const [title, setTitle] = useState('') + const [content, setContent] = useState('') + const [tags, setTags] = useState('') + const contentRef = useRef(null) + const speechAvailable = getSpeechRecognition() !== null + + useEffect(() => { + if (!open) return + setTitle('') + setContent('') + setTags('') + const timer = setTimeout(() => contentRef.current?.focus(), 100) + return () => clearTimeout(timer) + }, [open]) + + const handleVoice = (): void => { + const SpeechRecognition = getSpeechRecognition() + if (!SpeechRecognition) return + const rec = new SpeechRecognition() + rec.lang = 'zh-CN' + rec.onresult = (e) => { + const transcript = e.results[0]?.[0]?.transcript ?? '' + if (transcript) setContent((c) => c + transcript) + } + rec.start() + } + + const handleSave = async (): Promise => { + if (!bookId || !content.trim()) return + const tagList = tags + .split(/[,,]/) + .map((x) => x.trim()) + .filter(Boolean) + const item = await ipcCall(() => + window.electronAPI.inspiration.create( + bookId, + title.trim() || t('inspiration.newItem'), + content.trim(), + tagList + ) + ) + addInspirationLocal(item) + setSidebarPanel('inspiration') + await switchTarget({ kind: 'inspiration', id: item.id }) + setOpen(false) + } + + if (!open) return null + + return ( +