feat: ship v0.3.0 with P2.1 editor polish and P3 AI collaboration

Add chapter reorder, search replace, inspiration capture, and AI chat with context editing, settings, offline handling, and naming checks. Fix dev black screen by keeping process.env reads in the main process only.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-06 16:33:56 +08:00
parent 0b5aa146bd
commit 8ce35a1e8e
59 changed files with 3808 additions and 100 deletions
+12 -4
View File
@@ -1,6 +1,12 @@
# 笔临 (Bilin)
长篇创作智能协作平台 — Electron 桌面客户端 v0.2.0P2
长篇创作智能协作平台 — Electron 桌面客户端 v0.3.0P2.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`
@@ -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<messageId, AbortController>`
- [ ] **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 渲染 `<AiPanel />`
- [ ] **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**
+204
View File
@@ -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<void> {
await page.waitForLoadState('domcontentloaded')
if (await page.getByText('欢迎使用笔临').isVisible()) {
await page.getByRole('button', { name: '跳过' }).click()
}
}
async function createAndOpenBook(page: Page, name = 'AI测试书'): Promise<void> {
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<void> {
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<void> {
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: '<p>正文中出现了林悦这一名字。</p>'
})
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()
})
})
+157
View File
@@ -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<void> {
await page.waitForLoadState('domcontentloaded')
if (await page.getByText('欢迎使用笔临').isVisible()) {
await page.getByRole('button', { name: '跳过' }).click()
}
}
async function createAndOpenBook(page: Page): Promise<void> {
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<void> {
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<void> {
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<string[]> {
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()
})
})
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "bilin",
"version": "0.2.0",
"version": "0.3.0",
"description": "笔临 - 长篇创作智能协作平台",
"main": "./out/main/index.js",
"type": "module",
+69
View File
@@ -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>OpenAI Terms</openai> / <anthropic>Anthropic Terms</anthropic> 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",
+69
View File
@@ -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>OpenAI 服务条款</openai> / <anthropic>Anthropic 服务条款</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": "创建快照",
+8 -1
View File
@@ -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')
}
}
+125
View File
@@ -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<AiContextBinding>
return {
chapterIds: parsed.chapterIds ?? [],
outlineIds: parsed.outlineIds ?? [],
settingIds: parsed.settingIds ?? [],
inspirationIds: parsed.inspirationIds ?? []
}
} catch {
return { ...EMPTY_CONTEXT }
}
}
function mapSession(row: Record<string, unknown>): 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<string, unknown>): 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<string, unknown>[])
: (this.db
.prepare('SELECT * FROM ai_sessions WHERE archived = 0 ORDER BY updated_at DESC')
.all() as Record<string, unknown>[])
return rows.map(mapSession)
}
get(id: string): AiSession | null {
const row = this.db.prepare('SELECT * FROM ai_sessions WHERE id = ?').get(id) as
| Record<string, unknown>
| 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<string, unknown>[]
).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)
}
}
+41
View File
@@ -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)!
}
}
+20
View File
@@ -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);
+8 -1
View File
@@ -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()
})
+220
View File
@@ -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<string, AbortController>()
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<never>((_, reject) =>
setTimeout(() => reject(new Error('ai-timeout')), 8_000)
)
])
return mergeNamingConflicts(local, parseNamingJson(response))
} catch {
return local
}
}
const fetchAi = async (): Promise<NamingConflict[]> => {
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 }
})
)
}
+20
View File
@@ -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))
)
}
+10
View File
@@ -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
}
+11 -2
View File
@@ -8,9 +8,18 @@ export function fail(code: ErrorCode, message: string, recoverable: boolean): Ip
return { ok: false, error: { code, message, recoverable } }
}
export function wrap<T>(fn: () => T, recoverable = true): IpcResult<T> {
export function wrap<T>(fn: () => T | Promise<T>, recoverable = true): IpcResult<T> | Promise<IpcResult<T>> {
try {
return ok(fn())
const out = fn()
if (out && typeof (out as Promise<T>).then === 'function') {
return (out as Promise<T>)
.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)
+105
View File
@@ -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<string> {
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()
}
}
}
@@ -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()
+5
View File
@@ -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))
}
+16 -4
View File
@@ -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
+151
View File
@@ -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<string, unknown>
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<string> {
const tokens = new Set<string>()
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<string>()
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<string>()
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 }
@@ -0,0 +1,31 @@
import { net, type BrowserWindow } from 'electron'
import { IPC } from '../../shared/ipc-channels'
export class NetworkMonitorService {
private timer: ReturnType<typeof setInterval> | 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
}
}
}
+4 -2
View File
@@ -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++
}
}
+66 -1
View File
@@ -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<IpcResult<Chapter>> =>
ipcRenderer.invoke(IPC.CHAPTER_UPDATE, params),
delete: (bookId: string, chapterId: string): Promise<IpcResult<void>> =>
ipcRenderer.invoke(IPC.CHAPTER_DELETE, { bookId, chapterId })
ipcRenderer.invoke(IPC.CHAPTER_DELETE, { bookId, chapterId }),
reorder: (bookId: string, volumeId: string, orderedIds: string[]): Promise<IpcResult<Chapter[]>> =>
ipcRenderer.invoke(IPC.CHAPTER_REORDER, { bookId, volumeId, orderedIds }),
moveToVolume: (
bookId: string,
chapterId: string,
targetVolumeId: string,
targetIndex: number
): Promise<IpcResult<Chapter>> =>
ipcRenderer.invoke(IPC.CHAPTER_MOVE_TO_VOLUME, { bookId, chapterId, targetVolumeId, targetIndex })
},
outline: {
list: (bookId: string): Promise<IpcResult<OutlineItem[]>> =>
@@ -213,6 +230,40 @@ const electronAPI = {
saveHistory: (query: string): Promise<IpcResult<string[]>> =>
ipcRenderer.invoke(IPC.SEARCH_SAVE_HISTORY, { query })
},
ai: {
sessionList: (bookId: string, includeArchived?: boolean): Promise<IpcResult<AiSession[]>> =>
ipcRenderer.invoke(IPC.AI_SESSION_LIST, { bookId, includeArchived }),
sessionCreate: (bookId: string, title?: string, model?: string): Promise<IpcResult<AiSession>> =>
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<IpcResult<AiSession>> =>
ipcRenderer.invoke(IPC.AI_SESSION_UPDATE, { bookId, sessionId, patch }),
sessionDelete: (bookId: string, sessionId: string): Promise<IpcResult<void>> =>
ipcRenderer.invoke(IPC.AI_SESSION_DELETE, { bookId, sessionId }),
messageList: (bookId: string, sessionId: string): Promise<IpcResult<AiMessage[]>> =>
ipcRenderer.invoke(IPC.AI_MESSAGE_LIST, { bookId, sessionId }),
chat: (
bookId: string,
sessionId: string,
content: string,
prompt?: string
): Promise<IpcResult<{ messageId: string }>> =>
ipcRenderer.invoke(IPC.AI_CHAT, { bookId, sessionId, content, prompt }),
abort: (messageId: string): Promise<IpcResult<void>> =>
ipcRenderer.invoke(IPC.AI_ABORT, { messageId }),
buildContext: (
bookId: string,
binding: AiContextBinding
): Promise<IpcResult<AiContextBuildResult>> =>
ipcRenderer.invoke(IPC.AI_BUILD_CONTEXT, { bookId, binding }),
testConnection: (config?: AiConfig): Promise<IpcResult<{ ok: true }>> =>
ipcRenderer.invoke(IPC.AI_TEST_CONNECTION, { config }),
namingCheck: (bookId: string): Promise<IpcResult<NamingConflict[]>> =>
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)
}
}
+23 -10
View File
@@ -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 {
</div>
{showOnboarding && <OnboardingWizard onComplete={handleOnboardingComplete} />}
<SearchModal />
<InspirationModal />
{toast && <div className="toast">{toast}</div>}
</div>
)
+229
View File
@@ -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<HTMLDivElement>(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<void> => {
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 (
<div className="ai-panel-empty" data-testid="ai-panel">
{t('ai.noBook')}
</div>
)
}
return (
<>
<div id="ai-panel" className="ai-panel" data-testid="ai-panel">
<div className="ai-session-bar">
<select
data-testid="ai-session-select"
value={activeSessionId ?? ''}
onChange={(e) => void selectSession(e.target.value)}
>
{sessions.length === 0 && <option value="">{t('ai.noSession')}</option>}
{sessions.map((s) => (
<option key={s.id} value={s.id}>
{s.title || t('ai.untitledSession')}
</option>
))}
</select>
<button
type="button"
className="btn"
data-testid="ai-session-new"
onClick={() => void createSession(bookId)}
>
+
</button>
</div>
<div className="ai-mode-tabs" role="tablist">
{MODES.map((mode) => (
<button
key={mode.id}
type="button"
className={`ai-mode-tab ${writingMode === mode.id ? 'active' : ''}`}
onClick={() => handleModeClick(mode.id)}
>
{t(mode.labelKey)}
</button>
))}
</div>
<button
type="button"
className="context-preview"
data-testid="ai-context-preview"
onClick={() => setContextOpen(true)}
>
{contextSummary}
</button>
{disabled && (
<div className="offline-banner show" data-testid="ai-offline-banner">
{t('ai.offlineBanner')}
</div>
)}
<div className="chat-messages" data-testid="ai-chat-messages">
{messages.map((msg) => (
<div
key={msg.id}
className={`chat-msg ${msg.role === 'user' ? 'user' : 'ai'}`}
data-testid={msg.role === 'assistant' ? 'chat-message-assistant' : 'chat-message-user'}
>
<div className="chat-msg-body">{msg.content}</div>
{msg.role === 'assistant' && (
<button
type="button"
className="chat-insert-btn"
data-testid="ai-insert-to-editor"
onClick={() => void handleInsert(msg.content)}
>
{t('ai.insertToEditor')}
</button>
)}
</div>
))}
{streamingMessageId && streamingBuffer && (
<div className="chat-msg ai" data-testid="chat-message-assistant">
{streamingBuffer}
</div>
)}
<div ref={messagesEndRef} />
</div>
<div className="quick-cmds">
{QUICK_CMDS.map((cmd) => (
<button
key={cmd}
type="button"
className="quick-cmd"
data-testid={`ai-quick-cmd-${cmd.slice(1)}`}
disabled={disabled || sending}
onClick={() => !disabled && setInput(cmd + ' ')}
>
{cmd}
</button>
))}
</div>
<div className="chat-input-area">
<input
type="text"
data-testid="ai-chat-input"
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder={t('ai.inputPlaceholder')}
disabled={sending || disabled}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
handleSend()
}
}}
/>
<button
type="button"
data-testid="ai-send"
disabled={sending || disabled || !input.trim()}
onClick={handleSend}
>
{t('ai.send')}
</button>
</div>
</div>
<ContextEditorModal open={contextOpen} onClose={() => setContextOpen(false)} />
</>
)
}
@@ -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<ContextTab>('setting')
const [binding, setBinding] = useState<AiContextBinding>(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<void> => {
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<void> => {
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 (
<div className="modal-overlay" data-testid="context-editor-modal">
<div className="modal-content context-editor-modal">
<div className="modal-header">
<h3>{t('ai.contextEditorTitle')}</h3>
<button type="button" className="modal-close" onClick={onClose}>
</button>
</div>
<div className="context-editor-tabs">
{(['chapter', 'outline', 'setting', 'inspiration'] as const).map((id) => (
<button
key={id}
type="button"
className={`context-editor-tab ${tab === id ? 'active' : ''}`}
data-testid={`context-tab-${id}`}
onClick={() => setTab(id)}
>
{t(`ai.contextTab.${id}`)}
</button>
))}
</div>
<div className="context-editor-list">
{items.length === 0 && <div className="sidebar-empty">{t('ai.contextEmptyList')}</div>}
{items.map((item) => (
<label key={item.id} className="context-editor-item" data-testid={`context-item-${item.id}`}>
<input
type="checkbox"
checked={selectedIds.includes(item.id)}
onChange={() => toggle(tab, item.id)}
/>
<span>{item.title}</span>
</label>
))}
</div>
<div className="modal-footer">
<button type="button" className="btn" data-testid="context-refresh" onClick={() => void handleRefresh()}>
{t('ai.contextRefresh')}
</button>
<button type="button" className="btn" onClick={onClose}>
{t('dialog.cancel')}
</button>
<button type="button" className="btn btn-primary" data-testid="context-save" onClick={() => void handleSave()}>
{t('ai.contextSave')}
</button>
</div>
</div>
</div>
)
}
@@ -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<NamingConflict[]>([])
if (!open) return null
const runCheck = async (): Promise<void> => {
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<void> => {
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 (
<div className="modal-overlay naming-modal" data-testid="naming-check-modal">
<div className="modal-content">
<div className="modal-header">
<h3>{t('naming.title')}</h3>
<button type="button" className="modal-close" onClick={onClose}>
</button>
</div>
<div className="naming-modal-body">
{conflicts.length === 0 && !loading && (
<p className="naming-empty">{t('naming.emptyHint')}</p>
)}
{conflicts.map((row, index) => (
<div key={`${row.termA}-${row.termB}-${index}`} className="naming-row" data-testid="naming-row">
<div className="naming-terms">
<strong>{row.termA}</strong> <strong>{row.termB}</strong>
</div>
<div className="naming-meta">
{t('naming.confidence', { value: Math.round(row.confidence * 100) })}
{row.reason ? ` · ${row.reason}` : ''}
</div>
<div className="naming-actions">
<button type="button" className="btn" onClick={() => handleIgnore(row)}>
{t('naming.ignore')}
</button>
<button type="button" className="btn btn-primary" onClick={() => void handleReplace(row)}>
{t('naming.replace', { term: row.suggestedCanonical || row.termA })}
</button>
</div>
</div>
))}
</div>
<div className="modal-footer">
<button type="button" className="btn" onClick={onClose}>
{t('dialog.cancel')}
</button>
<button
type="button"
className="btn btn-primary"
data-testid="naming-run-check"
disabled={loading || !bookId}
onClick={() => void runCheck()}
>
{loading ? t('naming.running') : t('naming.run')}
</button>
</div>
</div>
</div>
)
}
@@ -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
@@ -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<HTMLTextAreaElement>(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<void> => {
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 (
<div
className="modal-overlay"
id="inspirationModal"
data-testid="inspiration-modal"
role="dialog"
aria-modal="true"
aria-label={t('inspiration.captureTitle')}
>
<div className="modal-content inspiration-modal">
<div className="modal-header">
<h3>{t('inspiration.captureTitle')}</h3>
<button type="button" className="modal-close" onClick={() => setOpen(false)}>
</button>
</div>
<div className="modal-body inspiration-capture">
<input
className="form-control form-control--wide"
data-testid="inspiration-title-input"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder={t('inspiration.titlePlaceholder')}
/>
<textarea
ref={contentRef}
className="inspiration-textarea"
data-testid="inspiration-content-input"
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder={t('inspiration.contentPlaceholder')}
rows={6}
/>
<label className="form-label">{t('inspiration.tagsLabel')}</label>
<input
className="form-control form-control--wide"
data-testid="inspiration-tags-input"
value={tags}
onChange={(e) => setTags(e.target.value)}
placeholder={t('inspiration.tagsPlaceholder')}
/>
</div>
<div className="modal-footer">
<button
type="button"
className="btn"
data-testid="inspiration-voice-btn"
disabled={!speechAvailable}
title={speechAvailable ? t('inspiration.voice') : t('inspiration.voiceUnavailable')}
onClick={handleVoice}
>
🎤 {t('inspiration.voice')}
</button>
<button type="button" className="btn" onClick={() => setOpen(false)}>
{t('dialog.cancel')}
</button>
<button
type="button"
className="btn btn-primary"
data-testid="inspiration-save-btn"
disabled={!content.trim()}
onClick={() => void handleSave()}
>
{t('inspiration.save')}
</button>
</div>
</div>
</div>
)
}
@@ -0,0 +1,27 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { NamingCheckModal } from '@renderer/components/ai/NamingCheckModal'
export function KnowledgePanel(): React.JSX.Element {
const { t } = useTranslation()
const [namingOpen, setNamingOpen] = useState(false)
return (
<>
<div className="knowledge-panel" data-testid="knowledge-panel">
<div className="knowledge-toolbar">
<button
type="button"
className="btn"
data-testid="naming-check-open"
onClick={() => setNamingOpen(true)}
>
{t('naming.open')}
</button>
</div>
<div className="placeholder-box">{t('knowledge.placeholder')}</div>
</div>
<NamingCheckModal open={namingOpen} onClose={() => setNamingOpen(false)} />
</>
)
}
@@ -1,4 +1,4 @@
import { useEffect, useMemo } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useAtomValue } from 'jotai'
import { useAppStore } from '@renderer/stores/useAppStore'
@@ -122,6 +122,55 @@ export function EditorLayout(): React.JSX.Element {
setActiveVolume(vol.id)
}
const [dragOverChapterId, setDragOverChapterId] = useState<string | null>(null)
const handleChapterDropOnItem = async (
draggedId: string,
targetVolumeId: string,
targetChapterId: string
): Promise<void> => {
if (!currentBookId || draggedId === targetChapterId) return
await flushEditorSave()
const dragged = chapters.find((c) => c.id === draggedId)
if (!dragged) return
const volChapters = chapters
.filter((c) => c.volumeId === targetVolumeId)
.sort((a, b) => a.sortOrder - b.sortOrder)
const targetIdx = volChapters.findIndex((c) => c.id === targetChapterId)
if (dragged.volumeId === targetVolumeId) {
const ids = volChapters.map((c) => c.id)
const from = ids.indexOf(draggedId)
if (from < 0) return
ids.splice(from, 1)
const insertAt = ids.indexOf(targetChapterId)
ids.splice(insertAt, 0, draggedId)
await ipcCall(() => window.electronAPI.chapter.reorder(currentBookId, targetVolumeId, ids))
} else {
await ipcCall(() =>
window.electronAPI.chapter.moveToVolume(
currentBookId,
draggedId,
targetVolumeId,
Math.max(0, targetIdx)
)
)
}
await refreshChapters()
}
const handleChapterDropOnVolume = async (draggedId: string, targetVolumeId: string): Promise<void> => {
if (!currentBookId) return
await flushEditorSave()
const dragged = chapters.find((c) => c.id === draggedId)
if (!dragged) return
const targetLen = chapters.filter((c) => c.volumeId === targetVolumeId).length
if (dragged.volumeId === targetVolumeId) return
await ipcCall(() =>
window.electronAPI.chapter.moveToVolume(currentBookId, draggedId, targetVolumeId, targetLen)
)
await refreshChapters()
}
const handleInsertLandmark = async (): Promise<void> => {
if (!currentBookId || target?.kind !== 'chapter') {
showToast(t('landmark.chapterOnly'))
@@ -163,7 +212,14 @@ export function EditorLayout(): React.JSX.Element {
<div key={vol.id}>
<div
className="vol-header"
data-volume-id={vol.id}
onClick={() => setActiveVolume(vol.id)}
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => {
e.preventDefault()
const draggedId = e.dataTransfer.getData('application/x-bilin-chapter')
if (draggedId) void handleChapterDropOnVolume(draggedId, vol.id)
}}
style={{ color: activeVolumeId === vol.id ? 'var(--accent-light)' : undefined }}
>
{vol.name}
@@ -173,7 +229,24 @@ export function EditorLayout(): React.JSX.Element {
.map((ch, idx) => (
<div
key={ch.id}
className={`chapter-item ${target?.kind === 'chapter' && target.id === ch.id ? 'active' : ''}`}
className={`chapter-item ${target?.kind === 'chapter' && target.id === ch.id ? 'active' : ''} ${dragOverChapterId === ch.id ? 'drag-over' : ''}`}
draggable
data-testid={`chapter-item-${ch.id}`}
onDragStart={(e) => {
e.dataTransfer.setData('application/x-bilin-chapter', ch.id)
e.dataTransfer.effectAllowed = 'move'
}}
onDragOver={(e) => {
e.preventDefault()
setDragOverChapterId(ch.id)
}}
onDragLeave={() => setDragOverChapterId(null)}
onDrop={(e) => {
e.preventDefault()
setDragOverChapterId(null)
const draggedId = e.dataTransfer.getData('application/x-bilin-chapter')
if (draggedId) void handleChapterDropOnItem(draggedId, vol.id, ch.id)
}}
onClick={() => void handleSelectChapter(ch.id)}
onKeyDown={(e) => e.key === 'Enter' && void handleSelectChapter(ch.id)}
role="button"
@@ -1,6 +1,8 @@
import { useTranslation } from 'react-i18next'
import { useAppStore } from '@renderer/stores/useAppStore'
import { WordFreqPanel } from '@renderer/components/wordfreq/WordFreqPanel'
import { AiPanel } from '@renderer/components/ai/AiPanel'
import { KnowledgePanel } from '@renderer/components/knowledge/KnowledgePanel'
export function RightPanel(): React.JSX.Element {
const { t } = useTranslation()
@@ -28,12 +30,11 @@ export function RightPanel(): React.JSX.Element {
</button>
))}
</div>
<div className={`panel-content ${rightPanel === 'ai' ? 'active' : ''}`}>
<p style={{ fontSize: 11, color: 'var(--text-muted)', marginBottom: 8 }}>{t('panel.aiPlaceholder')}</p>
<input className="ai-input-disabled" disabled placeholder={t('feature.comingSoon')} />
<div className={`panel-content ai-panel-wrap ${rightPanel === 'ai' ? 'active' : ''}`}>
<AiPanel />
</div>
<div className={`panel-content ${rightPanel === 'knowledge' ? 'active' : ''}`}>
<div className="placeholder-box">{t('feature.comingSoon')}</div>
<KnowledgePanel />
</div>
<div className={`panel-content ${rightPanel === 'wordfreq' ? 'active' : ''}`}>
<WordFreqPanel />
@@ -7,11 +7,13 @@ import { useEditStore } from '@renderer/stores/useEditStore'
export function ReferencePanel(): React.JSX.Element {
const { t } = useTranslation()
const open = useReferenceStore((s) => s.open)
const pinned = useReferenceStore((s) => s.pinned)
const tab = useReferenceStore((s) => s.tab)
const query = useReferenceStore((s) => s.query)
const setTab = useReferenceStore((s) => s.setTab)
const setQuery = useReferenceStore((s) => s.setQuery)
const setOpen = useReferenceStore((s) => s.setOpen)
const setPinned = useReferenceStore((s) => s.setPinned)
const switchTarget = useEditStore((s) => s.switchTarget)
const outlines = useBookStore((s) => s.outlines)
const settings = useBookStore((s) => s.settings)
@@ -43,9 +45,28 @@ export function ReferencePanel(): React.JSX.Element {
<div id="reference-panel" className="open" data-testid="reference-panel">
<div className="ref-header">
<span>{t('reference.title')}</span>
<button type="button" className="btn" onClick={() => setOpen(false)}>
</button>
<div className="ref-header-actions">
<button
type="button"
className={`btn ref-pin-btn ${pinned ? 'active' : ''}`}
data-testid="reference-pin"
title={pinned ? t('reference.unpin') : t('reference.pin')}
onClick={() => setPinned(!pinned)}
>
📌
</button>
<button
type="button"
className="btn"
data-testid="reference-close"
onClick={() => {
if (pinned) setPinned(false)
else setOpen(false)
}}
>
{pinned ? t('reference.unpin') : '✕'}
</button>
</div>
</div>
<input
className="form-control form-control--wide"
@@ -4,6 +4,7 @@ import type { SearchResult } from '@shared/types'
import { useSearchStore } from '@renderer/stores/useSearchStore'
import { useBookStore } from '@renderer/stores/useBookStore'
import { useEditStore } from '@renderer/stores/useEditStore'
import { useAppStore } from '@renderer/stores/useAppStore'
import { ipcCall } from '@renderer/lib/ipc-client'
export function SearchModal(): React.JSX.Element | null {
@@ -17,11 +18,16 @@ export function SearchModal(): React.JSX.Element | null {
const setRegex = useSearchStore((s) => s.setRegex)
const setCaseSensitive = useSearchStore((s) => s.setCaseSensitive)
const bookId = useBookStore((s) => s.currentBookId)
const refreshChapters = useBookStore((s) => s.refreshChapters)
const setSelectedChapter = useBookStore((s) => s.setSelectedChapter)
const switchTarget = useEditStore((s) => s.switchTarget)
const reloadTarget = useEditStore((s) => s.reloadTarget)
const showToast = useAppStore((s) => s.showToast)
const [results, setResults] = useState<SearchResult[]>([])
const [history, setHistory] = useState<string[]>([])
const [selectedIdx, setSelectedIdx] = useState(0)
const [replacement, setReplacement] = useState('')
const [replacePreviews, setReplacePreviews] = useState<SearchResult[]>([])
useEffect(() => {
if (!open) return
@@ -58,6 +64,26 @@ export function SearchModal(): React.JSX.Element | null {
}
}
const previewReplace = async (): Promise<void> => {
if (!bookId || !query.trim()) return
const result = await ipcCall(() =>
window.electronAPI.search.replace(bookId, query, replacement, true, { regex, caseSensitive })
)
setReplacePreviews(result.previews)
}
const executeReplace = async (): Promise<void> => {
if (!bookId || !query.trim()) return
if (!window.confirm(t('search.replaceConfirm'))) return
const result = await ipcCall(() =>
window.electronAPI.search.replace(bookId, query, replacement, false, { regex, caseSensitive })
)
await refreshChapters()
reloadTarget()
showToast(t('search.replaceDone', { count: result.count }))
setReplacePreviews([])
}
if (!open) return null
return (
@@ -121,6 +147,46 @@ export function SearchModal(): React.JSX.Element | null {
</div>
))}
</div>
<details className="search-replace-panel" data-testid="search-replace-panel">
<summary>{t('search.replace')}</summary>
<input
className="form-control form-control--wide"
data-testid="search-replace-input"
value={replacement}
onChange={(e) => setReplacement(e.target.value)}
placeholder={t('search.replace')}
/>
<div className="search-replace-actions">
<button
type="button"
className="btn"
data-testid="search-replace-preview"
onClick={() => void previewReplace()}
>
{t('search.replacePreview')}
</button>
<button
type="button"
className="btn btn-primary"
data-testid="search-replace-execute"
onClick={() => void executeReplace()}
>
{t('search.replaceExecute')}
</button>
</div>
{replacePreviews.length > 0 && (
<div className="search-replace-previews" data-testid="search-replace-previews">
{replacePreviews.map((hit) => (
<div key={`${hit.kind}-${hit.id}`} className="search-result">
<div className="sr-type">
{hit.kind} · {hit.title}
</div>
<div>{hit.snippet}</div>
</div>
))}
</div>
)}
</details>
<div className="modal-footer">
<button type="button" className="btn" onClick={() => setOpen(false)}>
{t('dialog.cancel')}
@@ -0,0 +1,142 @@
import { useState } from 'react'
import { useTranslation, Trans } from 'react-i18next'
import type { AiBackend, AiConfig } from '@shared/types'
import { useSettingsStore } from '@renderer/stores/useSettingsStore'
import { useAppStore } from '@renderer/stores/useAppStore'
import { ipcCall } from '@renderer/lib/ipc-client'
const BACKENDS: { id: AiBackend; labelKey: string }[] = [
{ id: 'lmstudio', labelKey: 'ai.settings.backend.lmstudio' },
{ id: 'openai', labelKey: 'ai.settings.backend.openai' },
{ id: 'anthropic', labelKey: 'ai.settings.backend.anthropic' }
]
export function AiSettingsPage(): React.JSX.Element {
const { t } = useTranslation()
const showToast = useAppStore((s) => s.showToast)
const settings = useSettingsStore()
const aiConfig = settings.aiConfig
const [testing, setTesting] = useState(false)
const patchAiConfig = (patch: Partial<AiConfig>): void => {
void settings.update({ aiConfig: { ...aiConfig, ...patch } })
}
const handleTest = async (): Promise<void> => {
setTesting(true)
try {
await ipcCall(() => window.electronAPI.ai.testConnection(aiConfig))
showToast(t('ai.settings.testSuccess'))
} catch (err) {
showToast(err instanceof Error ? err.message : String(err))
} finally {
setTesting(false)
}
}
const cloudBackend = aiConfig.backend !== 'lmstudio'
return (
<div data-testid="ai-settings-page">
<div className="setting-item">
<span>{t('ai.settings.backend')}</span>
<select
data-testid="ai-settings-backend"
className="form-control"
value={aiConfig.backend}
onChange={(e) => patchAiConfig({ backend: e.target.value as AiBackend })}
>
{BACKENDS.map((b) => (
<option key={b.id} value={b.id}>
{t(b.labelKey)}
</option>
))}
</select>
</div>
<div className="setting-item">
<span>{t('ai.settings.baseUrl')}</span>
<input
data-testid="ai-settings-base-url"
className="form-control"
value={aiConfig.baseUrl}
onChange={(e) => patchAiConfig({ baseUrl: e.target.value })}
/>
</div>
<div className="setting-item">
<span>{t('ai.settings.model')}</span>
<input
data-testid="ai-settings-model"
className="form-control"
value={aiConfig.model}
onChange={(e) => patchAiConfig({ model: e.target.value })}
/>
</div>
{cloudBackend && (
<div className="setting-item">
<span>{t('ai.settings.apiKey')}</span>
<input
data-testid="ai-settings-api-key"
type="password"
className="form-control"
value={aiConfig.apiKey ?? ''}
onChange={(e) => patchAiConfig({ apiKey: e.target.value })}
autoComplete="off"
/>
</div>
)}
<div className="setting-item">
<span>{t('ai.settings.timeout')}</span>
<input
data-testid="ai-settings-timeout"
type="number"
min={5}
max={600}
className="form-control form-control--narrow"
value={Math.round(aiConfig.timeoutMs / 1000)}
onChange={(e) => {
const seconds = Number(e.target.value)
if (!Number.isFinite(seconds)) return
patchAiConfig({ timeoutMs: Math.max(5, seconds) * 1000 })
}}
/>
</div>
{cloudBackend && (
<p className="ai-license-notice" data-testid="ai-settings-license">
<Trans
i18nKey="ai.settings.licenseNotice"
components={{
openai: (
<a
href="https://openai.com/policies/terms-of-use"
target="_blank"
rel="noreferrer"
/>
),
anthropic: (
<a href="https://www.anthropic.com/legal/terms" target="_blank" rel="noreferrer" />
)
}}
/>
</p>
)}
<div className="setting-item">
<span />
<button
type="button"
className="btn"
data-testid="ai-settings-test"
disabled={testing}
onClick={() => void handleTest()}
>
{testing ? t('ai.settings.testing') : t('ai.settings.testConnection')}
</button>
</div>
</div>
)
}
@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next'
import type { ThemeId, Language } from '@shared/types'
import { useSettingsStore } from '@renderer/stores/useSettingsStore'
import { ShortcutEditor } from '@renderer/components/settings/ShortcutEditor'
import { AiSettingsPage } from '@renderer/components/settings/AiSettingsPage'
const THEMES: { id: ThemeId; key: string }[] = [
{ id: 'default', key: 'theme.default' },
@@ -17,7 +18,7 @@ const THEMES: { id: ThemeId; key: string }[] = [
export function SettingsPage(): React.JSX.Element {
const { t } = useTranslation()
const settings = useSettingsStore()
const [section, setSection] = useState<'general' | 'shortcuts' | 'about'>('general')
const [section, setSection] = useState<'general' | 'ai' | 'shortcuts' | 'about'>('general')
return (
<div id="settings-page">
@@ -30,6 +31,15 @@ export function SettingsPage(): React.JSX.Element {
>
{t('settings.general')}
</div>
<div
className={`settings-nav-item ${section === 'ai' ? 'active' : ''}`}
onClick={() => setSection('ai')}
role="button"
tabIndex={0}
data-testid="settings-nav-ai"
>
{t('settings.ai')}
</div>
<div
className={`settings-nav-item ${section === 'shortcuts' ? 'active' : ''}`}
onClick={() => setSection('shortcuts')}
@@ -89,10 +99,11 @@ export function SettingsPage(): React.JSX.Element {
</div>
</>
)}
{section === 'ai' && <AiSettingsPage />}
{section === 'shortcuts' && <ShortcutEditor />}
{section === 'about' && (
<p style={{ color: 'var(--text-secondary)', lineHeight: 1.8 }}>
{t('app.name')} v0.1.0
{t('app.name')} v0.3.0
<br />
{t('app.tagline')}
</p>
+15 -1
View File
@@ -10,15 +10,29 @@ export const SettingMention = Mark.create({
}
},
parseHTML() {
return [{ tag: 'span[data-setting-mention]' }]
return [
{
tag: 'span[data-setting-mention]',
getAttrs: (el) => {
if (!(el instanceof HTMLElement)) return false
return {
id: el.getAttribute('data-id'),
label: el.getAttribute('data-label') ?? el.textContent?.replace(/^@/, '') ?? ''
}
}
}
]
},
renderHTML({ HTMLAttributes }) {
const label = HTMLAttributes.label ?? ''
const id = HTMLAttributes.id ?? ''
return [
'span',
mergeAttributes(HTMLAttributes, {
'data-setting-mention': 'true',
class: 'setting-mention',
'data-id': id,
'data-label': label,
'data-testid': 'setting-mention'
}),
`@${label}`
+16
View File
@@ -0,0 +1,16 @@
import type { TFunction } from 'i18next'
import type { ContextPreviewItem } from '@shared/types'
export function formatContextSummary(preview: ContextPreviewItem[], t: TFunction): string {
if (preview.length === 0) return t('ai.contextEmpty')
const counts = { chapter: 0, outline: 0, setting: 0, inspiration: 0 }
for (const item of preview) counts[item.kind]++
return t('ai.contextSummary', {
chapters: counts.chapter,
outlines: counts.outline,
settings: counts.setting,
inspirations: counts.inspiration
})
}
+22
View File
@@ -0,0 +1,22 @@
import type { TFunction } from 'i18next'
const SLASH_KEYS: Record<string, string> = {
'/续写': 'ai.slash.continue',
'/扩写': 'ai.slash.expand',
'/润色': 'ai.slash.polish',
'/总结': 'ai.slash.summarize',
'/纠错': 'ai.slash.proofread'
}
export function resolveSlashCommand(input: string, t: TFunction): string | null {
const token = input.trim().split(/\s+/)[0]
const key = SLASH_KEYS[token]
if (!key) return null
return t(key)
}
export function applySlashCommand(input: string, t: TFunction): { display: string; prompt: string } {
const display = input.trim()
const resolved = resolveSlashCommand(display, t)
return { display, prompt: resolved ?? display }
}
+34
View File
@@ -3,8 +3,16 @@ export interface LandmarkInsert {
landmarkType?: string
}
type EditorInsertContext = {
bookId: string
chapterId: string
getHtml: () => string
}
let insertLandmarkFn: ((payload: LandmarkInsert) => void) | null = null
let highlightTokenFn: ((token: string) => void) | null = null
let editorInsertContext: EditorInsertContext | null = null
let insertTextFn: ((text: string) => void) | null = null
export function registerInsertLandmark(fn: (payload: LandmarkInsert) => void): void {
insertLandmarkFn = fn
@@ -14,6 +22,14 @@ export function registerHighlightToken(fn: (token: string) => void): void {
highlightTokenFn = fn
}
export function registerEditorInsert(
context: EditorInsertContext | null,
insert: (text: string) => void
): void {
editorInsertContext = context
insertTextFn = insert
}
export function insertLandmarkInEditor(payload: LandmarkInsert): void {
insertLandmarkFn?.(payload)
}
@@ -21,3 +37,21 @@ export function insertLandmarkInEditor(payload: LandmarkInsert): void {
export function highlightTokenInEditor(token: string): void {
highlightTokenFn?.(token)
}
export async function insertToEditor(
text: string,
snapshotLabel: string,
createSnapshot: (
bookId: string,
chapterId: string,
html: string,
label: string
) => Promise<void>
): Promise<void> {
if (!editorInsertContext || !insertTextFn) {
throw new Error('No chapter editor active')
}
const { bookId, chapterId, getHtml } = editorInsertContext
await createSnapshot(bookId, chapterId, getHtml(), snapshotLabel)
insertTextFn(text)
}
+178
View File
@@ -0,0 +1,178 @@
import { create } from 'zustand'
import type { AiContextBinding, AiMessage, AiSession, AiStreamChunkEvent, AiWritingMode, ContextPreviewItem } from '@shared/types'
import { ipcCall } from '@renderer/lib/ipc-client'
import { useBookStore } from '@renderer/stores/useBookStore'
import { formatContextSummary } from '@renderer/lib/ai-context-summary'
import i18n from '@renderer/i18n'
interface AiStore {
sessions: AiSession[]
activeSessionId: string | null
messages: AiMessage[]
streamingMessageId: string | null
streamingBuffer: string
sending: boolean
online: boolean
writingMode: AiWritingMode
contextPreview: ContextPreviewItem[]
contextSummary: string
listenersAttached: boolean
mount: () => void
unmount: () => void
loadSessions: (bookId: string) => Promise<void>
selectSession: (sessionId: string) => Promise<void>
createSession: (bookId: string) => Promise<void>
sendMessage: (content: string, prompt?: string) => Promise<void>
refreshContextPreview: () => Promise<void>
appendStreamChunk: (payload: AiStreamChunkEvent) => void
setWritingMode: (mode: AiWritingMode) => void
}
let unsubStream: (() => void) | null = null
let unsubNetwork: (() => void) | null = null
export const useAiStore = create<AiStore>((set, get) => ({
sessions: [],
activeSessionId: null,
messages: [],
streamingMessageId: null,
streamingBuffer: '',
sending: false,
online: true,
writingMode: 'chat',
contextPreview: [],
contextSummary: i18n.t('ai.contextEmpty'),
listenersAttached: false,
mount: () => {
if (get().listenersAttached) return
unsubStream = window.electronAPI.onAiStreamChunk((payload) => {
get().appendStreamChunk(payload)
})
unsubNetwork = window.electronAPI.onAiNetworkStatus((payload) => {
set({ online: payload.online })
})
set({ listenersAttached: true })
},
unmount: () => {
unsubStream?.()
unsubNetwork?.()
unsubStream = null
unsubNetwork = null
set({ listenersAttached: false })
},
loadSessions: async (bookId) => {
const sessions = await ipcCall(() => window.electronAPI.ai.sessionList(bookId))
const activeSessionId = get().activeSessionId
const stillActive = activeSessionId && sessions.some((s) => s.id === activeSessionId)
set({ sessions })
if (stillActive && activeSessionId) {
await get().selectSession(activeSessionId)
} else if (sessions.length > 0) {
await get().selectSession(sessions[0].id)
} else {
set({
activeSessionId: null,
messages: [],
contextPreview: [],
contextSummary: i18n.t('ai.contextEmpty')
})
}
},
selectSession: async (sessionId) => {
const bookId = useBookStore.getState().currentBookId
if (!bookId) return
const messages = await ipcCall(() => window.electronAPI.ai.messageList(bookId, sessionId))
set({
activeSessionId: sessionId,
messages,
streamingMessageId: null,
streamingBuffer: ''
})
await get().refreshContextPreview()
},
createSession: async (bookId) => {
const session = await ipcCall(() => window.electronAPI.ai.sessionCreate(bookId))
set((s) => ({
sessions: [session, ...s.sessions],
activeSessionId: session.id,
messages: [],
streamingMessageId: null,
streamingBuffer: '',
contextPreview: [],
contextSummary: i18n.t('ai.contextEmpty')
}))
},
refreshContextPreview: async () => {
const bookId = useBookStore.getState().currentBookId
const sessionId = get().activeSessionId
if (!bookId || !sessionId) {
set({ contextPreview: [], contextSummary: i18n.t('ai.contextEmpty') })
return
}
const session = get().sessions.find((s) => s.id === sessionId)
if (!session) return
const result = await ipcCall(() =>
window.electronAPI.ai.buildContext(bookId, session.context)
)
set({
contextPreview: result.preview,
contextSummary: formatContextSummary(result.preview, i18n.t)
})
},
sendMessage: async (content, prompt) => {
const trimmed = content.trim()
if (!trimmed || get().sending) return
const bookId = useBookStore.getState().currentBookId
if (!bookId) return
let sessionId = get().activeSessionId
if (!sessionId) {
await get().createSession(bookId)
sessionId = get().activeSessionId
}
if (!sessionId) return
const optimisticUser: AiMessage = {
id: `optimistic-${Date.now()}`,
sessionId,
role: 'user',
content: trimmed,
createdAt: new Date().toISOString()
}
set({
sending: true,
streamingMessageId: null,
streamingBuffer: '',
messages: [...get().messages, optimisticUser]
})
try {
await ipcCall(() => window.electronAPI.ai.chat(bookId, sessionId, trimmed, prompt))
const messages = await ipcCall(() => window.electronAPI.ai.messageList(bookId, sessionId))
const sessions = await ipcCall(() => window.electronAPI.ai.sessionList(bookId))
set({ messages, sessions, streamingMessageId: null, streamingBuffer: '' })
} finally {
set({ sending: false })
}
},
appendStreamChunk: (payload) => {
const { messageId, delta, done } = payload
if (done) return
set((s) => ({
streamingMessageId: messageId,
streamingBuffer:
s.streamingMessageId === messageId ? s.streamingBuffer + delta : s.streamingBuffer + delta
}))
},
setWritingMode: (mode) => set({ writingMode: mode })
}))
+4
View File
@@ -8,12 +8,14 @@ interface AppState {
rightPanel: 'ai' | 'knowledge' | 'wordfreq' | 'book-settings'
versionModalOpen: boolean
landmarksModalOpen: boolean
inspirationModalOpen: boolean
toast: string | null
setView: (view: AppView) => void
setSidebarPanel: (panel: AppState['sidebarPanel']) => void
setRightPanel: (panel: AppState['rightPanel']) => void
setVersionModalOpen: (open: boolean) => void
setLandmarksModalOpen: (open: boolean) => void
setInspirationModalOpen: (open: boolean) => void
showToast: (message: string) => void
clearToast: () => void
}
@@ -24,12 +26,14 @@ export const useAppStore = create<AppState>((set) => ({
rightPanel: 'ai',
versionModalOpen: false,
landmarksModalOpen: false,
inspirationModalOpen: false,
toast: null,
setView: (view) => set({ view }),
setSidebarPanel: (sidebarPanel) => set({ sidebarPanel }),
setRightPanel: (rightPanel) => set({ rightPanel }),
setVersionModalOpen: (versionModalOpen) => set({ versionModalOpen }),
setLandmarksModalOpen: (landmarksModalOpen) => set({ landmarksModalOpen }),
setInspirationModalOpen: (inspirationModalOpen) => set({ inspirationModalOpen }),
showToast: (toast) => {
set({ toast })
setTimeout(() => set({ toast: null }), 2500)
+7
View File
@@ -4,6 +4,7 @@ import type { EditTarget } from '@shared/types'
interface EditState {
target: EditTarget | null
switchTarget: (next: EditTarget) => Promise<void>
reloadTarget: () => void
setTarget: (next: EditTarget | null) => void
}
@@ -23,5 +24,11 @@ export const useEditStore = create<EditState>((set) => ({
await flushEditSession()
set({ target: next })
},
reloadTarget: () => {
const next = useEditStore.getState().target
if (!next) return
set({ target: null })
queueMicrotask(() => set({ target: next }))
},
setTarget: (next) => set({ target: next })
}))
+8 -1
View File
@@ -4,10 +4,12 @@ type ReferenceTab = 'setting' | 'outline' | 'inspiration'
interface ReferenceState {
open: boolean
pinned: boolean
tab: ReferenceTab
query: string
pinnedIds: string[]
setOpen: (open: boolean) => void
setPinned: (pinned: boolean) => void
setTab: (tab: ReferenceTab) => void
setQuery: (query: string) => void
togglePin: (id: string) => void
@@ -15,10 +17,15 @@ interface ReferenceState {
export const useReferenceStore = create<ReferenceState>((set, get) => ({
open: false,
pinned: false,
tab: 'setting',
query: '',
pinnedIds: [],
setOpen: (open) => set({ open }),
setOpen: (open) => {
if (!open && get().pinned) return
set({ open })
},
setPinned: (pinned) => set({ pinned }),
setTab: (tab) => set({ tab }),
setQuery: (query) => set({ query }),
togglePin: (id) => {
+3
View File
@@ -1,5 +1,6 @@
import { create } from 'zustand'
import type { ThemeId, Language, GlobalSettings, ActionId } from '@shared/types'
import { DEFAULT_AI_CONFIG } from '@shared/types'
import { ipcCall } from '@renderer/lib/ipc-client'
import { applyTheme } from '@renderer/lib/theme'
import i18n from '@renderer/i18n'
@@ -17,6 +18,8 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
language: 'zh-CN',
dailyWordGoal: 0,
shortcuts: {} as Record<ActionId, string>,
aiConfig: { ...DEFAULT_AI_CONFIG },
namingIgnoreWords: [] as string[],
loaded: false,
load: async () => {
const data = await ipcCall(() => window.electronAPI.settings.get())
+390
View File
@@ -354,6 +354,10 @@
color: var(--text-primary);
}
.chapter-item.drag-over {
box-shadow: inset 0 2px 0 var(--accent);
}
.ch-badge {
margin-left: auto;
font-size: 10px;
@@ -500,6 +504,314 @@
flex-direction: column;
}
.panel-content.active {
display: flex;
flex-direction: column;
}
.panel-content.ai-panel-wrap {
padding: 0;
overflow: hidden;
}
.ai-panel {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.ai-panel-empty {
padding: 12px;
font-size: 12px;
color: var(--text-muted);
}
.ai-session-bar {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 10px;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.ai-session-bar select {
flex: 1;
font-size: 11px;
padding: 4px 8px;
background: var(--bg-tertiary);
color: var(--text-primary);
border: 1px solid var(--border);
border-radius: 4px;
}
.ai-mode-tabs {
display: flex;
gap: 2px;
padding: 6px 8px;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.ai-mode-tab {
flex: 1;
padding: 5px 4px;
font-size: 10px;
text-align: center;
border-radius: 4px;
cursor: pointer;
color: var(--text-muted);
background: transparent;
border: none;
}
.ai-mode-tab.active {
background: var(--accent);
color: #fff;
font-weight: 600;
}
.context-preview {
padding: 6px 10px;
font-size: 10px;
color: var(--text-muted);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
text-align: left;
background: transparent;
border-left: none;
border-right: none;
border-top: none;
cursor: pointer;
width: 100%;
}
.context-preview:hover {
color: var(--accent-light);
}
.context-editor-modal {
max-width: 520px;
width: 90vw;
}
.context-editor-tabs {
display: flex;
gap: 4px;
margin-bottom: 10px;
}
.context-editor-tab {
flex: 1;
font-size: 11px;
padding: 6px 4px;
border: 1px solid var(--border);
background: var(--bg-tertiary);
color: var(--text-secondary);
border-radius: 4px;
cursor: pointer;
}
.context-editor-tab.active {
background: var(--accent);
color: #fff;
border-color: var(--accent);
}
.context-editor-list {
max-height: 280px;
overflow-y: auto;
margin-bottom: 12px;
}
.context-editor-item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 4px;
font-size: 12px;
cursor: pointer;
}
.offline-banner {
display: none;
padding: 6px 12px;
background: var(--orange, #f59e0b);
color: #000;
font-size: 11px;
text-align: center;
font-weight: 600;
flex-shrink: 0;
}
.offline-banner.show {
display: block;
}
.chat-messages {
flex: 1;
overflow-y: auto;
padding: 12px;
display: flex;
flex-direction: column;
gap: 10px;
min-height: 0;
}
.chat-msg {
padding: 10px 12px;
border-radius: var(--radius-sm);
font-size: 12px;
line-height: 1.6;
max-width: 92%;
white-space: pre-wrap;
word-break: break-word;
}
.chat-msg.user {
align-self: flex-end;
background: var(--accent-dark, var(--accent));
color: #fff;
}
.chat-msg.ai {
align-self: flex-start;
background: var(--bg-tertiary);
color: var(--text-primary);
border: 1px solid var(--border);
}
.quick-cmds {
display: flex;
flex-wrap: wrap;
gap: 4px;
padding: 6px 8px;
border-top: 1px solid var(--border);
flex-shrink: 0;
}
.quick-cmd {
font-size: 10px;
padding: 3px 8px;
border-radius: 4px;
background: var(--bg-tertiary);
border: 1px solid var(--border);
color: var(--text-secondary);
cursor: pointer;
}
.quick-cmd:hover:not(:disabled) {
background: var(--bg-hover);
color: var(--text-primary);
}
.quick-cmd:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.chat-msg {
position: relative;
}
.chat-insert-btn {
margin-top: 6px;
font-size: 10px;
padding: 2px 8px;
border-radius: 4px;
border: 1px solid var(--border);
background: var(--bg-surface);
color: var(--text-secondary);
cursor: pointer;
}
.chat-insert-btn:hover {
color: var(--text-primary);
border-color: var(--accent);
}
.knowledge-toolbar {
padding: 10px;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.knowledge-panel {
display: flex;
flex-direction: column;
height: 100%;
}
.naming-modal-body {
max-height: 360px;
overflow-y: auto;
padding: 8px 12px;
}
.naming-row {
padding: 10px;
margin-bottom: 8px;
border-radius: var(--radius-sm);
background: var(--bg-tertiary);
border: 1px solid var(--border);
}
.naming-terms {
font-size: 13px;
margin-bottom: 4px;
}
.naming-meta {
font-size: 11px;
color: var(--text-secondary);
margin-bottom: 8px;
}
.naming-actions {
display: flex;
gap: 6px;
}
.naming-empty {
font-size: 12px;
color: var(--text-secondary);
padding: 12px 4px;
}
.chat-input-area {
display: flex;
gap: 6px;
padding: 8px;
border-top: 1px solid var(--border);
flex-shrink: 0;
}
.chat-input-area input {
flex: 1;
padding: 8px 10px;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: var(--bg-tertiary);
color: var(--text-primary);
font-size: 12px;
}
.chat-input-area button {
padding: 8px 14px;
border-radius: var(--radius-sm);
border: none;
background: var(--accent);
color: #fff;
font-size: 12px;
cursor: pointer;
}
.chat-input-area button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.placeholder-box {
color: var(--text-muted);
font-size: 12px;
@@ -556,6 +868,21 @@
font-size: 13px;
}
.ai-license-notice {
margin: 4px 0 12px;
padding: 10px 14px;
font-size: 12px;
line-height: 1.6;
color: var(--text-secondary);
background: color-mix(in srgb, var(--accent) 8%, var(--bg-tertiary));
border-radius: var(--radius-sm);
border-left: 3px solid var(--accent);
}
.ai-license-notice a {
color: var(--accent);
}
.shortcut-key {
min-width: 140px;
padding: 6px 12px;
@@ -734,6 +1061,44 @@
font-weight: 600;
}
.ref-header-actions {
display: flex;
gap: 4px;
align-items: center;
}
.ref-pin-btn.active {
color: var(--accent-light);
}
.inspiration-modal {
max-width: 480px;
}
.inspiration-capture {
display: flex;
flex-direction: column;
gap: 8px;
}
.inspiration-textarea {
width: 100%;
min-height: 120px;
padding: 10px;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: var(--bg-tertiary);
color: var(--text-primary);
font-size: 13px;
line-height: 1.6;
resize: vertical;
}
.form-label {
font-size: 11px;
color: var(--text-secondary);
}
.ref-tabs {
display: flex;
gap: 4px;
@@ -862,6 +1227,31 @@
background: var(--bg-hover);
}
.search-replace-panel {
margin-top: 8px;
font-size: 12px;
}
.search-replace-panel summary {
cursor: pointer;
color: var(--text-secondary);
margin-bottom: 8px;
}
.search-replace-actions {
display: flex;
gap: 8px;
margin-top: 8px;
}
.search-replace-previews {
margin-top: 8px;
max-height: 160px;
overflow-y: auto;
border: 1px solid var(--border);
border-radius: 4px;
}
.sr-type {
font-size: 10px;
color: var(--accent-light);
+38
View File
@@ -7,6 +7,14 @@ import type {
CreateBookParams,
GlobalSettings,
InspirationEntry,
AiContextBinding,
AiContextBuildResult,
AiConfig,
AiMessage,
AiSession,
NamingConflict,
AiStreamChunkEvent,
AiNetworkStatusEvent,
IpcResult,
LandmarkType,
OutlineItem,
@@ -55,6 +63,13 @@ export interface ElectronAPI {
get: (bookId: string, chapterId: string) => Promise<IpcResult<Chapter>>
update: (params: UpdateChapterParams) => Promise<IpcResult<Chapter>>
delete: (bookId: string, chapterId: string) => Promise<IpcResult<void>>
reorder: (bookId: string, volumeId: string, orderedIds: string[]) => Promise<IpcResult<Chapter[]>>
moveToVolume: (
bookId: string,
chapterId: string,
targetVolumeId: string,
targetIndex: number
) => Promise<IpcResult<Chapter>>
}
outline: {
list: (bookId: string) => Promise<IpcResult<OutlineItem[]>>
@@ -163,6 +178,27 @@ export interface ElectronAPI {
getHistory: () => Promise<IpcResult<string[]>>
saveHistory: (query: string) => Promise<IpcResult<string[]>>
}
ai: {
sessionList: (bookId: string, includeArchived?: boolean) => Promise<IpcResult<AiSession[]>>
sessionCreate: (bookId: string, title?: string, model?: string) => Promise<IpcResult<AiSession>>
sessionUpdate: (
bookId: string,
sessionId: string,
patch: Partial<{ title: string; model: string; archived: boolean; context: AiContextBinding }>
) => Promise<IpcResult<AiSession>>
sessionDelete: (bookId: string, sessionId: string) => Promise<IpcResult<void>>
messageList: (bookId: string, sessionId: string) => Promise<IpcResult<AiMessage[]>>
chat: (
bookId: string,
sessionId: string,
content: string,
prompt?: string
) => Promise<IpcResult<{ messageId: string }>>
abort: (messageId: string) => Promise<IpcResult<void>>
buildContext: (bookId: string, binding: AiContextBinding) => Promise<IpcResult<AiContextBuildResult>>
testConnection: (config?: AiConfig) => Promise<IpcResult<{ ok: true }>>
namingCheck: (bookId: string) => Promise<IpcResult<NamingConflict[]>>
}
wordfreq: {
analyze: (
bookId: string,
@@ -174,6 +210,8 @@ export interface ElectronAPI {
register: (action: ActionId, accelerator: string) => Promise<IpcResult<void>>
}
onShortcutTriggered: (callback: (action: ActionId) => void) => () => void
onAiStreamChunk: (callback: (payload: AiStreamChunkEvent) => void) => () => void
onAiNetworkStatus: (callback: (payload: AiNetworkStatusEvent) => void) => () => void
}
declare global {
+15 -1
View File
@@ -13,6 +13,8 @@ export const IPC = {
CHAPTER_GET: 'chapter:get',
CHAPTER_UPDATE: 'chapter:update',
CHAPTER_DELETE: 'chapter:delete',
CHAPTER_REORDER: 'chapter:reorder',
CHAPTER_MOVE_TO_VOLUME: 'chapter:moveToVolume',
SHORTCUT_GET_ALL: 'shortcut:getAll',
SHORTCUT_REGISTER: 'shortcut:register',
SHORTCUT_TRIGGERED: 'shortcut:triggered',
@@ -49,5 +51,17 @@ export const IPC = {
SEARCH_REPLACE: 'search:replace',
SEARCH_GET_HISTORY: 'search:getHistory',
SEARCH_SAVE_HISTORY: 'search:saveHistory',
WORDFREQ_ANALYZE: 'wordfreq:analyze'
WORDFREQ_ANALYZE: 'wordfreq:analyze',
AI_SESSION_LIST: 'ai:sessionList',
AI_SESSION_CREATE: 'ai:sessionCreate',
AI_SESSION_UPDATE: 'ai:sessionUpdate',
AI_SESSION_DELETE: 'ai:sessionDelete',
AI_MESSAGE_LIST: 'ai:messageList',
AI_CHAT: 'ai:chat',
AI_ABORT: 'ai:abort',
AI_BUILD_CONTEXT: 'ai:buildContext',
AI_TEST_CONNECTION: 'ai:testConnection',
AI_NAMING_CHECK: 'ai:namingCheck',
AI_STREAM_CHUNK: 'ai:streamChunk',
AI_NETWORK_STATUS: 'ai:networkStatus'
} as const
+81
View File
@@ -60,6 +60,8 @@ export interface GlobalSettings {
snapshotMaxAuto: number
snapshotMaxPersist: number
searchHistory: string[]
aiConfig: AiConfig
namingIgnoreWords: string[]
}
export interface BookMeta {
@@ -192,3 +194,82 @@ export interface UpdateChapterParams {
status?: ChapterStatus
cursorOffset?: number
}
export type AiBackend = 'lmstudio' | 'openai' | 'anthropic'
export type AiMessageRole = 'user' | 'assistant' | 'system'
export type AiWritingMode = 'chat' | 'interactive' | 'auto' | 'wizard'
export interface AiConfig {
backend: AiBackend
baseUrl: string
model: string
apiKey?: string
timeoutMs: number
maxTokens: number
}
export const DEFAULT_AI_CONFIG: AiConfig = {
backend: 'lmstudio',
baseUrl: 'http://127.0.0.1:1234',
model: 'gemma-4-e4b-it',
timeoutMs: 60_000,
maxTokens: 2048
}
export interface AiContextBinding {
chapterIds: string[]
outlineIds: string[]
settingIds: string[]
inspirationIds: string[]
}
export interface AiSession {
id: string
title: string
model: string
archived: boolean
context: AiContextBinding
createdAt: string
updatedAt: string
}
export interface AiMessage {
id: string
sessionId: string
role: AiMessageRole
content: string
createdAt: string
}
export interface NamingConflict {
termA: string
termB: string
confidence: number
reason: string
suggestedCanonical?: string
}
export interface AiStreamChunkEvent {
messageId: string
delta: string
done: boolean
}
export interface AiNetworkStatusEvent {
online: boolean
}
export type ContextPreviewKind = 'chapter' | 'outline' | 'setting' | 'inspiration'
export interface ContextPreviewItem {
kind: ContextPreviewKind
id: string
title: string
excerpt: string
charCount: number
}
export interface AiContextBuildResult {
systemPrompt: string
preview: ContextPreviewItem[]
}
+12
View File
@@ -0,0 +1,12 @@
import { describe, it, expect } from 'vitest'
import { AiClientService } from '../../src/main/services/ai-client.service'
import { DEFAULT_AI_CONFIG } from '../../src/shared/types'
describe('AiClientService', () => {
const client = new AiClientService(() => DEFAULT_AI_CONFIG)
it('IT-AI-01: chat returns non-empty content from LM Studio', async () => {
const content = await client.chat([{ role: 'user', content: '回复一个字:好' }])
expect(content.trim().length).toBeGreaterThan(0)
}, 90_000)
})
+86
View File
@@ -0,0 +1,86 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { DatabaseSync } from 'node:sqlite'
import { mkdtempSync, rmSync } from 'fs'
import { join } from 'path'
import { tmpdir } from 'os'
import { migrate } from '../../src/main/db/migrate'
import { ChapterRepository } from '../../src/main/db/repositories/chapter.repo'
import { VolumeRepository } from '../../src/main/db/repositories/volume.repo'
import { SettingRepository } from '../../src/main/db/repositories/setting.repo'
import { BookRegistryService } from '../../src/main/services/book-registry'
import { aiContextBuilder } from '../../src/main/services/ai-context-builder.service'
import type { SqliteDb } from '../../src/main/db/types'
describe('AiContextBuilderService', () => {
let userDir: string
let registry: BookRegistryService
let bookId: string
let volId: string
let db: SqliteDb
beforeEach(() => {
userDir = mkdtempSync(join(tmpdir(), 'bilin-ctx-'))
registry = new BookRegistryService(userDir)
const meta = registry.create({ name: '测试书', category: '玄幻' })
bookId = meta.id
db = registry.getDb(bookId)
volId = new VolumeRepository(db).create('卷一', 0).id
})
afterEach(() => {
db?.close()
rmSync(userDir, { recursive: true, force: true })
})
it('truncates long chapter content to 2000 chars per block', () => {
const chapters = new ChapterRepository(db)
const longHtml = `<p>${'章'.repeat(3000)}</p>`
const chapter = chapters.create(volId, '长章节', 0, longHtml)
const result = aiContextBuilder.build(
{ chapterIds: [chapter.id], outlineIds: [], settingIds: [], inspirationIds: [] },
registry,
bookId,
'作者'
)
expect(result.preview).toHaveLength(1)
expect(result.preview[0].excerpt.length).toBeLessThanOrEqual(2001)
expect(result.systemPrompt.length).toBeLessThanOrEqual(16 * 1024 + 500)
})
it('keeps total context within 16KB', () => {
const chapters = new ChapterRepository(db)
const ids: string[] = []
for (let i = 0; i < 12; i++) {
const ch = chapters.create(volId, `章节${i}`, i, `<p>${'内容'.repeat(900)}</p>`)
ids.push(ch.id)
}
const result = aiContextBuilder.build(
{ chapterIds: ids, outlineIds: [], settingIds: [], inspirationIds: [] },
registry,
bookId,
'作者'
)
const blocksText = result.preview.map((p) => p.excerpt).join('')
expect(blocksText.length).toBeLessThanOrEqual(16 * 1024)
expect(result.preview.length).toBeLessThan(ids.length)
})
it('includes setting in preview', () => {
const settings = new SettingRepository(db)
const setting = settings.create('character', '主角林远', '少年修士')
const result = aiContextBuilder.build(
{ chapterIds: [], outlineIds: [], settingIds: [setting.id], inspirationIds: [] },
registry,
bookId,
'作者'
)
expect(result.preview[0].title).toBe('主角林远')
expect(result.systemPrompt).toContain('主角林远')
})
})
+69
View File
@@ -0,0 +1,69 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { DatabaseSync } from 'node:sqlite'
import { migrate } from '../../src/main/db/migrate'
import { AiSessionRepository } from '../../src/main/db/repositories/ai-session.repo'
import type { SqliteDb } from '../../src/main/db/types'
describe('AiSessionRepository', () => {
let db: SqliteDb
let repo: AiSessionRepository
beforeEach(() => {
db = new DatabaseSync(':memory:')
migrate(db)
repo = new AiSessionRepository(db)
})
afterEach(() => db.close())
it('creates session with empty context', () => {
const session = repo.create('对话一', 'gemma-4-e4b-it')
expect(session.title).toBe('对话一')
expect(session.model).toBe('gemma-4-e4b-it')
expect(session.context.chapterIds).toEqual([])
expect(session.archived).toBe(false)
})
it('lists sessions excluding archived by default', () => {
const active = repo.create('活跃')
const archived = repo.create('归档')
repo.update(archived.id, { archived: true })
const list = repo.list()
expect(list.map((s) => s.id)).toEqual([active.id])
expect(repo.list(true).map((s) => s.id).sort()).toEqual([active.id, archived.id].sort())
})
it('persists messages in order', () => {
const session = repo.create()
repo.addMessage(session.id, 'user', '你好')
const assistant = repo.addMessage(session.id, 'assistant', '你好,有什么可以帮你?')
const messages = repo.listMessages(session.id)
expect(messages).toHaveLength(2)
expect(messages[0].role).toBe('user')
expect(messages[1].id).toBe(assistant.id)
})
it('updates context binding', () => {
const session = repo.create()
const updated = repo.update(session.id, {
context: {
chapterIds: ['ch-1'],
outlineIds: [],
settingIds: ['st-1'],
inspirationIds: []
}
})
expect(updated.context.chapterIds).toEqual(['ch-1'])
expect(updated.context.settingIds).toEqual(['st-1'])
})
it('deletes session and messages', () => {
const session = repo.create()
repo.addMessage(session.id, 'user', 'test')
repo.delete(session.id)
expect(repo.get(session.id)).toBeNull()
expect(repo.listMessages(session.id)).toEqual([])
})
})
+44
View File
@@ -0,0 +1,44 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { DatabaseSync } from 'node:sqlite'
import { migrate } from '../../src/main/db/migrate'
import { ChapterRepository } from '../../src/main/db/repositories/chapter.repo'
import { VolumeRepository } from '../../src/main/db/repositories/volume.repo'
import type { SqliteDb } from '../../src/main/db/types'
describe('ChapterRepository.move', () => {
let db: SqliteDb
let chapters: ChapterRepository
let volId: string
beforeEach(() => {
db = new DatabaseSync(':memory:')
migrate(db)
const vols = new VolumeRepository(db)
volId = vols.create('卷一', 0).id
chapters = new ChapterRepository(db)
chapters.create(volId, 'A', 0)
chapters.create(volId, 'B', 1)
chapters.create(volId, 'C', 2)
})
afterEach(() => db.close())
it('reorders within volume', () => {
const list = chapters.listByVolume(volId)
const idA = list.find((x) => x.title === 'A')!.id
const idB = list.find((x) => x.title === 'B')!.id
const idC = list.find((x) => x.title === 'C')!.id
chapters.reorderVolume(volId, [idC, idA, idB])
const titles = chapters.listByVolume(volId).map((x) => x.title)
expect(titles).toEqual(['C', 'A', 'B'])
})
it('moves chapter to another volume at index', () => {
const vols = new VolumeRepository(db)
const vol2 = vols.create('卷二', 1).id
const idB = chapters.listByVolume(volId).find((x) => x.title === 'B')!.id
chapters.moveToVolume(idB, vol2, 0)
expect(chapters.listByVolume(volId).map((x) => x.title)).toEqual(['A', 'C'])
expect(chapters.listByVolume(vol2).map((x) => x.title)).toEqual(['B'])
})
})
+2 -2
View File
@@ -30,7 +30,7 @@ describe('migrate v2', () => {
expect(tableExists(db, 'search_fts')).toBe(true)
const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number }
expect(version.v).toBe(2)
expect(version.v).toBe(3)
})
it('migrates existing v1 database to v2', () => {
@@ -47,7 +47,7 @@ describe('migrate v2', () => {
expect(tableExists(db, 'outline_items')).toBe(true)
const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number }
expect(version.v).toBe(2)
expect(version.v).toBe(3)
const cols = db.prepare('PRAGMA table_info(chapters)').all() as { name: string }[]
const colNames = cols.map((c) => c.name)
+54
View File
@@ -0,0 +1,54 @@
import { describe, it, expect, afterEach } from 'vitest'
import { DatabaseSync } from 'node:sqlite'
import { migrate } from '../../src/main/db/migrate'
import schemaV1 from '../../src/main/db/schema-v1.sql?raw'
import schemaV2 from '../../src/main/db/schema-v2.sql?raw'
import type { SqliteDb } from '../../src/main/db/types'
function tableExists(db: SqliteDb, name: string): boolean {
const row = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name=?").get(name)
return row != null
}
describe('migrate v3', () => {
let db: SqliteDb
afterEach(() => {
db?.close()
})
it('applies v3 on fresh database', () => {
db = new DatabaseSync(':memory:')
migrate(db)
expect(tableExists(db, 'ai_sessions')).toBe(true)
expect(tableExists(db, 'ai_messages')).toBe(true)
const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number }
expect(version.v).toBe(3)
})
it('migrates existing v2 database to v3', () => {
db = new DatabaseSync(':memory:')
db.exec(`CREATE TABLE IF NOT EXISTS schema_version (
version INTEGER PRIMARY KEY,
applied_at TEXT NOT NULL DEFAULT (datetime('now')),
description TEXT
)`)
db.exec(schemaV1)
db.exec(schemaV2)
db.prepare('INSERT INTO schema_version (version, description) VALUES (?, ?)').run(1, 'initial schema')
db.prepare('INSERT INTO schema_version (version, description) VALUES (?, ?)').run(2, 'P2 schema')
migrate(db)
const tables = db
.prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
.all() as { name: string }[]
expect(tables.map((t) => t.name)).toContain('ai_sessions')
expect(tables.map((t) => t.name)).toContain('ai_messages')
const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number }
expect(version.v).toBe(3)
})
})
@@ -0,0 +1,37 @@
import { describe, it, expect } from 'vitest'
import { AiClientService } from '../../src/main/services/ai-client.service'
import {
buildNamingPrompt,
collectChaptersPlain,
parseNamingJson
} from '../../src/main/services/naming-check.service'
import { DEFAULT_AI_CONFIG } from '../../src/shared/types'
describe('naming check integration', () => {
const client = new AiClientService(() => DEFAULT_AI_CONFIG)
it('IT-NAME-01: detects 林远 vs 林悦 from LM Studio', async () => {
const prompt = buildNamingPrompt(
['林远'],
collectChaptersPlain([{ title: '第一章', content: '<p>林悦走进院子,环顾四周。</p>' }]),
[]
)
let response = await client.chat([{ role: 'user', content: prompt }])
let rows
try {
rows = parseNamingJson(response)
} catch {
response = await client.chat([
{ role: 'user', content: `${prompt}\n\n仅返回 JSON 数组,不要 markdown 或其它说明。` }
])
rows = parseNamingJson(response)
}
expect(rows.length).toBeGreaterThan(0)
const hit = rows.some(
(r) =>
(r.termA.includes('林远') && r.termB.includes('林悦')) ||
(r.termA.includes('林悦') && r.termB.includes('林远'))
)
expect(hit).toBe(true)
}, 180_000)
})
+50
View File
@@ -0,0 +1,50 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { mkdtempSync, rmSync } from 'fs'
import { join } from 'path'
import { tmpdir } from 'os'
import { BookRegistryService } from '../../src/main/services/book-registry'
import { ChapterRepository } from '../../src/main/db/repositories/chapter.repo'
import { VolumeRepository } from '../../src/main/db/repositories/volume.repo'
import { SettingRepository } from '../../src/main/db/repositories/setting.repo'
import {
collectChaptersPlain,
buildNamingCorpus,
findLocalNamingConflicts
} from '../../src/main/services/naming-check.service'
import type { SqliteDb } from '../../src/main/db/types'
describe('naming check data path', () => {
let userDir: string
let registry: BookRegistryService
let bookId: string
let volId: string
let db: SqliteDb
beforeEach(() => {
userDir = mkdtempSync(join(tmpdir(), 'bilin-name-repo-'))
registry = new BookRegistryService(userDir)
const meta = registry.create({ name: '测试书', category: '玄幻' })
bookId = meta.id
db = registry.getDb(bookId)
volId = new VolumeRepository(db).create('卷一', 0).id
})
afterEach(() => {
db?.close()
rmSync(userDir, { recursive: true, force: true })
})
it('finds 林远 vs 林悦 from setting description in corpus', () => {
new SettingRepository(db).create('character', '林远', '<p>正文中出现了林悦这一名字。</p>')
const settings = registry.getSettingRepo(bookId).list()
const corpus = buildNamingCorpus(settings, registry.getChapterRepo(bookId).list())
const local = findLocalNamingConflicts(
settings.map((s) => s.name),
corpus,
[]
)
expect(local.length).toBeGreaterThan(0)
})
})
+50
View File
@@ -0,0 +1,50 @@
import { describe, it, expect } from 'vitest'
import { collectChaptersPlain, findLocalNamingConflicts, parseNamingJson, buildNamingPrompt } from '../../src/main/services/naming-check.service'
describe('parseNamingJson', () => {
it('parses JSON array from plain response', () => {
const raw = `[{"termA":"林远","termB":"林悦","confidence":0.92,"reason":"同姓近音","suggestedCanonical":"林远"}]`
const rows = parseNamingJson(raw)
expect(rows).toHaveLength(1)
expect(rows[0].termA).toBe('林远')
expect(rows[0].termB).toBe('林悦')
expect(rows[0].confidence).toBeCloseTo(0.92)
})
it('extracts array from markdown-wrapped response', () => {
const raw = '说明如下:\n```json\n[{"termA":"玄火掌","termB":"焰火掌","confidence":0.8,"reason":"技能名不一致"}]\n```'
const rows = parseNamingJson(raw)
expect(rows).toHaveLength(1)
expect(rows[0].termA).toBe('玄火掌')
})
it('throws when no array present', () => {
expect(() => parseNamingJson('no json here')).toThrow()
})
})
describe('findLocalNamingConflicts', () => {
it('detects 林远 vs 林悦 in chapter text', () => {
const rows = findLocalNamingConflicts(['林远'], '林悦走进院子,环顾四周。', [])
expect(rows.length).toBeGreaterThan(0)
expect(rows[0].termA).toBe('林远')
expect(rows[0].termB).toBe('林悦')
})
it('detects conflict from chapter html content', () => {
const plain = collectChaptersPlain([
{ title: '新章', content: '<p>林悦走进院子,环顾四周。</p>' }
])
const rows = findLocalNamingConflicts(['林远'], plain, [])
expect(rows.length).toBeGreaterThan(0)
})
})
describe('buildNamingPrompt', () => {
it('includes setting names and ignore words', () => {
const prompt = buildNamingPrompt(['林远'], '林悦走进院子。', ['青云宗'])
expect(prompt).toContain('林远')
expect(prompt).toContain('林悦走进院子')
expect(prompt).toContain('青云宗')
})
})