8ce35a1e8e
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>
813 lines
24 KiB
Markdown
813 lines
24 KiB
Markdown
# 笔临 P2.1 + P3 Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** 交付 v0.3.0:P2 遗留(章节拖拽、搜索替换、灵感模态+语音、@跳转、引用钉住)+ P3 严格(LM Studio 对话、上下文注入、快捷命令、离线降级、命名检查、AI 快照)。
|
||
|
||
**Architecture:** 延续 electron-vite 单体;主进程 schema v3 + `AiClientService`(OpenAI 兼容 `fetch` 至 LM Studio)+ 流式 IPC 事件;渲染进程 `AiPanel` 替换占位;测试 **禁止 Mock**,集成/E2E 必须打真实 `http://127.0.0.1:1234`。
|
||
|
||
**Tech Stack:** Electron 36、electron-vite、React 18、TypeScript、TipTap、node:sqlite、Zustand、Jotai、i18next、Vitest、Playwright
|
||
|
||
**Spec:** `docs/superpowers/specs/2026-07-07-bilin-p21-p3-design.md`
|
||
|
||
**LM Studio 默认:** `baseUrl=http://127.0.0.1:1234`,`model=gemma-4-e4b-it`(可用 `BILIN_AI_BASE_URL` / `BILIN_AI_MODEL` 覆盖)
|
||
|
||
---
|
||
|
||
## File Map
|
||
|
||
| 文件 | 职责 |
|
||
|------|------|
|
||
| `src/main/db/schema-v3.sql` | `ai_sessions` / `ai_messages` |
|
||
| `src/main/db/migrate.ts` | v2→v3 迁移 |
|
||
| `src/main/db/repositories/chapter.repo.ts` | `move()` |
|
||
| `src/main/db/repositories/ai-session.repo.ts` | 会话/消息 CRUD |
|
||
| `src/main/services/ai-client.service.ts` | LM Studio HTTP + SSE 流 |
|
||
| `src/main/services/ai-context-builder.service.ts` | 上下文拼接 |
|
||
| `src/main/services/network-monitor.service.ts` | online/offline 推送 |
|
||
| `src/main/ipc/handlers/ai.handler.ts` | AI IPC + 流式 |
|
||
| `src/main/ipc/handlers/chapter.handler.ts` | `chapter:move` |
|
||
| `src/shared/types.ts` | AiConfig / AiSession / NamingConflict |
|
||
| `src/shared/ipc-channels.ts` | P2.1/P3 IPC |
|
||
| `src/preload/index.ts` | AI API + 事件订阅 |
|
||
| `src/renderer/stores/useAiStore.ts` | 会话/流式/disabled |
|
||
| `src/renderer/components/ai/AiPanel.tsx` | 右栏 AI 主 UI |
|
||
| `src/renderer/components/ai/ContextEditorModal.tsx` | 上下文勾选 |
|
||
| `src/renderer/components/ai/NamingCheckModal.tsx` | 命名检查 |
|
||
| `src/renderer/components/inspiration/InspirationModal.tsx` | 灵感捕获 |
|
||
| `src/renderer/components/settings/AiSettingsPage.tsx` | AI 配置 Tab |
|
||
| `src/renderer/components/search/SearchModal.tsx` | 替换区 |
|
||
| `src/renderer/components/layout/EditorLayout.tsx` | 章节 drag |
|
||
| `e2e/p21-features.spec.ts` | P2.1 E2E |
|
||
| `e2e/ai-chat.spec.ts` | P3 对话 E2E |
|
||
|
||
---
|
||
|
||
## Task 1: 章节拖拽(P2.1a)
|
||
|
||
**Files:**
|
||
- Modify: `src/main/db/repositories/chapter.repo.ts`
|
||
- Modify: `src/main/ipc/handlers/chapter.handler.ts`
|
||
- Modify: `src/shared/ipc-channels.ts`
|
||
- Modify: `src/preload/index.ts`, `src/shared/electron-api.d.ts`
|
||
- Modify: `src/renderer/components/layout/EditorLayout.tsx`
|
||
- Modify: `src/renderer/styles/layout.css`
|
||
- Create: `tests/main/chapter-move.test.ts`
|
||
- Create: `e2e/p21-features.spec.ts`(DRAG 用例)
|
||
|
||
- [x] **Step 1: 写 chapter.move 失败测试**
|
||
|
||
`tests/main/chapter-move.test.ts`:
|
||
|
||
```typescript
|
||
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 idC = list.find((x) => x.title === 'C')!.id
|
||
chapters.move(idC, volId, 0)
|
||
chapters.reorderVolume(volId, [idC, list.find((x) => x.title === 'A')!.id, list.find((x) => x.title === 'B')!.id])
|
||
const titles = chapters.listByVolume(volId).map((x) => x.title)
|
||
expect(titles).toEqual(['C', 'A', 'B'])
|
||
})
|
||
})
|
||
```
|
||
|
||
- [x] **Step 2: 运行确认 FAIL**
|
||
|
||
```bash
|
||
npm run test -- tests/main/chapter-move.test.ts
|
||
```
|
||
|
||
Expected: FAIL `chapters.move is not a function`
|
||
|
||
- [x] **Step 3: 实现 `ChapterRepository.move`**
|
||
|
||
```typescript
|
||
move(chapterId: string, volumeId: string, sortOrder: number): Chapter {
|
||
this.db
|
||
.prepare(
|
||
`UPDATE chapters SET volume_id = ?, sort_order = ?, updated_at = datetime('now') WHERE id = ?`
|
||
)
|
||
.run(volumeId, sortOrder, chapterId)
|
||
return this.get(chapterId)!
|
||
}
|
||
|
||
reorderVolume(volumeId: string, orderedIds: string[]): void {
|
||
const stmt = this.db.prepare(
|
||
`UPDATE chapters SET sort_order = ? WHERE id = ? AND volume_id = ?`
|
||
)
|
||
orderedIds.forEach((id, idx) => stmt.run(idx, id, volumeId))
|
||
}
|
||
```
|
||
|
||
- [x] **Step 4: IPC `CHAPTER_MOVE`**
|
||
|
||
`ipc-channels.ts` 追加 `CHAPTER_MOVE: 'chapter:move'`。
|
||
|
||
`chapter.handler.ts`:
|
||
|
||
```typescript
|
||
ipcMain.handle(
|
||
IPC.CHAPTER_MOVE,
|
||
(_e, { bookId, chapterId, volumeId, sortOrder }: { bookId: string; chapterId: string; volumeId: string; sortOrder: number }) =>
|
||
wrap(() => registry.getChapterRepo(bookId).move(chapterId, volumeId, sortOrder))
|
||
)
|
||
```
|
||
|
||
preload / electron-api.d.ts 暴露 `chapter.move(bookId, chapterId, volumeId, sortOrder)`。
|
||
|
||
- [x] **Step 5: EditorLayout 拖拽 UI**
|
||
|
||
章节 `.chapter-item` 增加 `draggable`;`onDragStart` 设 `chapterId`;`onDragOver` preventDefault;`onDrop` 计算新 `sortOrder` 调 IPC。
|
||
|
||
同卷:根据 drop 目标 index 调用 `reorderVolume`(新增 IPC `chapter:reorder` 或在 move 后批量 reorder)。
|
||
|
||
跨卷:drop 到另一卷 header → `move(chapterId, targetVolId, targetLen)`。
|
||
|
||
- [x] **Step 6: E2E E2E-P21-DRAG-01**
|
||
|
||
`e2e/p21-features.spec.ts`:两章 → drag 交换顺序 → 重启 → 顺序保持。
|
||
|
||
- [x] **Step 7: 验证**
|
||
|
||
```bash
|
||
npm run test -- tests/main/chapter-move.test.ts
|
||
npm run build
|
||
npm run test:e2e -- e2e/p21-features.spec.ts
|
||
```
|
||
|
||
- [ ] **Step 8: Commit**
|
||
|
||
```bash
|
||
git commit -am "feat: add chapter drag reorder within and across volumes"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 2: 搜索替换 UI(P2.1b)
|
||
|
||
**Files:**
|
||
- Modify: `src/renderer/components/search/SearchModal.tsx`
|
||
- Modify: `src/renderer/styles/layout.css`
|
||
- Modify: `public/locales/zh-CN/translation.json`, `public/locales/en/translation.json`
|
||
- Extend: `e2e/p21-features.spec.ts`
|
||
|
||
- [x] **Step 1: SearchModal 替换折叠区**
|
||
|
||
在 `search-results` 下方增加:
|
||
|
||
```tsx
|
||
<details className="search-replace-panel" data-testid="search-replace-panel">
|
||
<summary>{t('search.replace')}</summary>
|
||
<input data-testid="search-replace-input" value={replacement} onChange={...} />
|
||
<button data-testid="search-replace-preview" onClick={() => void previewReplace()}>
|
||
{t('search.replacePreview')}
|
||
</button>
|
||
<button data-testid="search-replace-execute" onClick={() => void executeReplace()}>
|
||
{t('search.replaceExecute')}
|
||
</button>
|
||
</details>
|
||
```
|
||
|
||
`previewReplace`:
|
||
|
||
```typescript
|
||
const result = await ipcCall(() =>
|
||
window.electronAPI.search.replace(bookId, query, replacement, true, { regex, caseSensitive })
|
||
)
|
||
setReplacePreviews(result.previews)
|
||
```
|
||
|
||
`executeReplace`:`dryRun: false` → `refreshChapters()` + toast。
|
||
|
||
- [x] **Step 2: i18n 键**
|
||
|
||
`search.replace`, `search.replacePreview`, `search.replaceExecute`, `search.replaceConfirm`, `search.replaceDone`
|
||
|
||
- [x] **Step 3: E2E E2E-P21-REP-01**
|
||
|
||
写入章节唯一词 → 搜索 → 预览 → 执行 → `editor` 含新词。
|
||
|
||
- [x] **Step 4: 验证 + Commit**
|
||
|
||
```bash
|
||
npm run test:e2e -- e2e/p21-features.spec.ts
|
||
git commit -am "feat: add global search replace preview and execute UI"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 3: 灵感模态 + @跳转 + 引用钉住(P2.1c)
|
||
|
||
**Files:**
|
||
- Create: `src/renderer/components/inspiration/InspirationModal.tsx`
|
||
- Modify: `src/renderer/App.tsx`
|
||
- Modify: `src/renderer/components/editor/TipTapEditor.tsx`
|
||
- Modify: `src/renderer/extensions/mention.ts`
|
||
- Modify: `src/renderer/stores/useReferenceStore.ts`
|
||
- Modify: `src/renderer/components/reference/ReferencePanel.tsx`
|
||
- Modify: `src/renderer/styles/layout.css`
|
||
- Extend: `e2e/p21-features.spec.ts`
|
||
|
||
- [x] **Step 1: InspirationModal**
|
||
|
||
对齐 `design/ui_pc.html` `#inspirationModal`:title、content、tags;保存调 `inspiration.create`;`data-testid="inspiration-modal"`。
|
||
|
||
语音按钮:
|
||
|
||
```typescript
|
||
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition
|
||
if (SpeechRecognition) {
|
||
const rec = new SpeechRecognition()
|
||
rec.lang = 'zh-CN'
|
||
rec.onresult = (e) => setContent((c) => c + e.results[0][0].transcript)
|
||
rec.start()
|
||
}
|
||
```
|
||
|
||
不可用时 `disabled` + `title={t('inspiration.voiceUnavailable')}`。
|
||
|
||
- [x] **Step 2: App.tsx 改 captureInspiration**
|
||
|
||
`captureInspiration` 快捷键 → `useAppStore.setInspirationModalOpen(true)`,不再直接 create。
|
||
|
||
- [x] **Step 3: Mention 点击跳转**
|
||
|
||
`TipTapEditor` `editorProps`:
|
||
|
||
```typescript
|
||
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
|
||
}
|
||
```
|
||
|
||
确保 `applyMention` 插入 HTML 含 `data-id`。
|
||
|
||
- [x] **Step 4: 引用钉住**
|
||
|
||
`useReferenceStore`:`pinned: boolean`, `setPinned`。
|
||
|
||
`ReferencePanel`:`pinned` 时关闭按钮改为取消钉住;`setOpen(false)` 若 `pinned` 则忽略。
|
||
|
||
- [x] **Step 5: E2E E2E-P21-INSP-01**
|
||
|
||
`Control+Shift+I` → 填内容 → 保存 → `inspiration-list` 可见。
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git commit -am "feat: add inspiration modal, mention navigation, reference pin"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 4: Schema v3 + 共享类型 + AI 配置(P3.0)
|
||
|
||
**Files:**
|
||
- Create: `src/main/db/schema-v3.sql`
|
||
- Modify: `src/main/db/migrate.ts`
|
||
- Create: `tests/main/migrate-v3.test.ts`
|
||
- Modify: `src/shared/types.ts`
|
||
- Modify: `src/shared/ipc-channels.ts`
|
||
- Modify: `src/main/services/global-settings.ts`
|
||
|
||
- [x] **Step 1: schema-v3.sql**
|
||
|
||
内容同 spec §3.1 `ai_sessions` / `ai_messages` / index。
|
||
|
||
- [x] **Step 2: migrate.ts**
|
||
|
||
`CURRENT_VERSION = 3`;`current < 3` 时 `db.exec(schemaV3)` + insert version 3。
|
||
|
||
- [x] **Step 3: migrate-v3.test.ts**
|
||
|
||
v2 库 migrate 后 `SELECT name FROM sqlite_master` 含 `ai_sessions`。
|
||
|
||
- [x] **Step 4: types.ts 扩展**
|
||
|
||
```typescript
|
||
export interface AiConfig {
|
||
backend: AiBackend
|
||
baseUrl: string
|
||
model: string
|
||
apiKey?: string
|
||
timeoutMs: number
|
||
maxTokens: number
|
||
}
|
||
|
||
export const DEFAULT_AI_CONFIG: AiConfig = {
|
||
backend: 'lmstudio',
|
||
baseUrl: process.env.BILIN_AI_BASE_URL ?? 'http://127.0.0.1:1234',
|
||
model: process.env.BILIN_AI_MODEL ?? 'gemma-4-e4b-it',
|
||
timeoutMs: 60_000,
|
||
maxTokens: 2048
|
||
}
|
||
```
|
||
|
||
`GlobalSettings` 追加 `aiConfig`, `namingIgnoreWords: string[]`。
|
||
|
||
- [x] **Step 5: global-settings defaults()**
|
||
|
||
合并 `aiConfig: DEFAULT_AI_CONFIG`, `namingIgnoreWords: []`。
|
||
|
||
- [x] **Step 6: 验证 + Commit**
|
||
|
||
```bash
|
||
npm run test -- tests/main/migrate-v3.test.ts
|
||
git commit -am "feat: add schema v3 and AI config types"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 5: AiClientService(P3.0)
|
||
|
||
**Files:**
|
||
- Create: `src/main/services/ai-client.service.ts`
|
||
- Create: `tests/main/ai-client.test.ts`
|
||
|
||
- [x] **Step 1: 写真实 LM Studio 集成测试(禁止 skip)**
|
||
|
||
```typescript
|
||
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)
|
||
})
|
||
```
|
||
|
||
- [x] **Step 2: 运行确认 FAIL**
|
||
|
||
```bash
|
||
npm run test -- tests/main/ai-client.test.ts
|
||
```
|
||
|
||
- [x] **Step 3: 实现 AiClientService**
|
||
|
||
```typescript
|
||
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`
|
||
}
|
||
|
||
async chat(messages: ChatMessage[], onChunk?: (delta: string) => void): Promise<string> {
|
||
const config = this.getConfig()
|
||
const controller = new AbortController()
|
||
const timer = setTimeout(() => controller.abort(), config.timeoutMs)
|
||
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: controller.signal
|
||
})
|
||
clearTimeout(timer)
|
||
if (!res.ok) throw new Error(`AI ${res.status}: ${await res.text()}`)
|
||
if (!onChunk) {
|
||
const json = await res.json()
|
||
return json.choices[0].message.content as string
|
||
}
|
||
// SSE parse: data: {...}\n\n
|
||
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)
|
||
const delta = parsed.choices?.[0]?.delta?.content ?? ''
|
||
if (delta) {
|
||
full += delta
|
||
onChunk(delta)
|
||
}
|
||
}
|
||
}
|
||
return full
|
||
}
|
||
}
|
||
```
|
||
|
||
- [x] **Step 4: 验证 PASS(需 LM Studio 运行)**
|
||
|
||
```bash
|
||
npm run test -- tests/main/ai-client.test.ts
|
||
```
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git commit -am "feat: add AiClientService with LM Studio OpenAI compatibility"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 6: AiSessionRepository + IPC(P3.1)
|
||
|
||
**Files:**
|
||
- Create: `src/main/db/repositories/ai-session.repo.ts`
|
||
- Create: `tests/main/ai-session.repo.test.ts`
|
||
- Create: `src/main/ipc/handlers/ai.handler.ts`
|
||
- Modify: `src/main/ipc/register.ts`
|
||
- Modify: `src/main/services/book-registry.ts`
|
||
- Modify: `src/preload/index.ts`, `src/shared/electron-api.d.ts`
|
||
|
||
- [x] **Step 1: ai-session.repo 测试 + 实现**
|
||
|
||
CRUD:`list`, `create`, `update`, `delete`, `listMessages`, `addMessage`。
|
||
|
||
`context_json` ↔ `AiContextBinding` JSON 序列化。
|
||
|
||
- [x] **Step 2: ai.handler.ts**
|
||
|
||
注册 spec §4.1 全部 `ai:session*` / `ai:messageList`。
|
||
|
||
`ai:chat` 骨架(暂同步,Task 7 接流式):
|
||
|
||
```typescript
|
||
ipcMain.handle(IPC.AI_CHAT, async (event, { bookId, sessionId, content }) => {
|
||
return wrap(async () => {
|
||
const repo = registry.getAiSessionRepo(bookId)
|
||
const settings = globalSettings.get()
|
||
repo.addMessage(sessionId, 'user', content)
|
||
const history = repo.listMessages(sessionId)
|
||
const assistantId = randomUUID()
|
||
const reply = await aiClient.chat(history.map(...))
|
||
repo.addMessage(sessionId, 'assistant', reply, assistantId)
|
||
return { messageId: assistantId }
|
||
})
|
||
})
|
||
```
|
||
|
||
- [x] **Step 3: book-registry getAiSessionRepo**
|
||
|
||
- [x] **Step 4: preload 暴露 `window.electronAPI.ai.*`**
|
||
|
||
- [x] **Step 5: 验证**
|
||
|
||
```bash
|
||
npm run test -- tests/main/ai-session.repo.test.ts
|
||
```
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git commit -am "feat: add AI session repository and IPC handlers"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 7: 流式 IPC + NetworkMonitor(P3.1)
|
||
|
||
**Files:**
|
||
- Create: `src/main/services/network-monitor.service.ts`
|
||
- Modify: `src/main/ipc/handlers/ai.handler.ts`
|
||
- Modify: `src/main/index.ts`
|
||
- Modify: `src/preload/index.ts`
|
||
|
||
- [x] **Step 1: 改造 ai:chat 为流式**
|
||
|
||
`ai:chat` 内:
|
||
|
||
```typescript
|
||
const win = BrowserWindow.fromWebContents(event.sender)
|
||
let full = ''
|
||
await aiClient.chat(messages, (delta) => {
|
||
full += delta
|
||
win?.webContents.send(IPC.AI_STREAM_CHUNK, { messageId: assistantId, delta, done: false })
|
||
})
|
||
win?.webContents.send(IPC.AI_STREAM_CHUNK, { messageId: assistantId, delta: '', done: true })
|
||
repo.addMessage(sessionId, 'assistant', full, assistantId)
|
||
```
|
||
|
||
`ai:abort`:维护 `Map<messageId, AbortController>`。
|
||
|
||
- [x] **Step 2: preload 订阅**
|
||
|
||
```typescript
|
||
onAiStreamChunk: (cb) => {
|
||
const handler = (_e, payload) => cb(payload)
|
||
ipcRenderer.on(IPC.AI_STREAM_CHUNK, handler)
|
||
return () => ipcRenderer.removeListener(IPC.AI_STREAM_CHUNK, handler)
|
||
}
|
||
```
|
||
|
||
- [x] **Step 3: NetworkMonitor**
|
||
|
||
`net.isOnline()` + 每 30s 检测;变化时 `webContents.send(IPC.AI_NETWORK_STATUS, { online })`。
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git commit -am "feat: add AI streaming IPC and network monitor"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 8: AiPanel UI + useAiStore(P3.1)
|
||
|
||
**Files:**
|
||
- Create: `src/renderer/stores/useAiStore.ts`
|
||
- Create: `src/renderer/components/ai/AiPanel.tsx`
|
||
- Modify: `src/renderer/components/layout/RightPanel.tsx`
|
||
- Modify: `src/renderer/styles/layout.css`
|
||
|
||
- [x] **Step 1: useAiStore**
|
||
|
||
```typescript
|
||
interface AiStore {
|
||
sessions: AiSession[]
|
||
activeSessionId: string | null
|
||
messages: AiMessage[]
|
||
streamingMessageId: string | null
|
||
streamingBuffer: string
|
||
disabled: boolean
|
||
writingMode: AiWritingMode // default 'chat'
|
||
loadSessions(bookId: string): Promise<void>
|
||
sendMessage(content: string): Promise<void>
|
||
appendStreamChunk(payload: StreamChunk): void
|
||
}
|
||
```
|
||
|
||
挂载时订阅 `onAiStreamChunk` / `onAiNetworkStatus`。
|
||
|
||
- [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`.
|
||
|
||
- [x] **Step 3: RightPanel 替换 AI 占位**
|
||
|
||
`panel-content` AI Tab 渲染 `<AiPanel />`。
|
||
|
||
- [x] **Step 4: E2E E2E-P3-CHAT-01**
|
||
|
||
`e2e/ai-chat.spec.ts`:打开书 → 新建会话 → 输入「你好」→ Enter → `chat-message-assistant` 非空(timeout 90s)。
|
||
|
||
- [x] **Step 5: 验证 + Commit**
|
||
|
||
```bash
|
||
npm run test:e2e -- e2e/ai-chat.spec.ts
|
||
git commit -am "feat: add AiPanel with streaming chat UI"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 9: 上下文编辑器 + 快捷命令(P3.2)
|
||
|
||
**Files:**
|
||
- Create: `src/main/services/ai-context-builder.service.ts`
|
||
- Create: `tests/main/ai-context-builder.test.ts`
|
||
- Create: `src/renderer/components/ai/ContextEditorModal.tsx`
|
||
- Modify: `src/main/ipc/handlers/ai.handler.ts`
|
||
- Modify: `src/renderer/components/ai/AiPanel.tsx`
|
||
|
||
- [x] **Step 1: AiContextBuilder 测试**
|
||
|
||
截断:超长 chapter content → 块 ≤ 2000 字;总量 ≤ 16KB。
|
||
|
||
- [x] **Step 2: 实现 build(binding, bookId)**
|
||
|
||
返回 `{ systemPrompt, preview: ContextPreviewItem[] }`。
|
||
|
||
- [x] **Step 3: ContextEditorModal**
|
||
|
||
四 Tab 勾选列表(chapters/outlines/settings/inspirations);保存 → `ai:sessionUpdate` patch `context_json`;「更新上下文」→ `ai:buildContext`。
|
||
|
||
- [x] **Step 4: 快捷命令**
|
||
|
||
`src/renderer/lib/ai-slash-commands.ts`:
|
||
|
||
```typescript
|
||
export function resolveSlashCommand(cmd: string, t: TFunction): string | null {
|
||
const map: Record<string, string> = {
|
||
'/续写': t('ai.slash.continue'),
|
||
'/扩写': t('ai.slash.expand'),
|
||
// ...
|
||
}
|
||
return map[cmd.split(/\s/)[0]] ?? null
|
||
}
|
||
```
|
||
|
||
发送前:若匹配 slash,用模板替换实际 prompt。
|
||
|
||
- [x] **Step 5: E2E E2E-P3-CTX-01 / E2E-P3-CMD-01**
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git commit -am "feat: add AI context editor and slash commands"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 10: AI 设置页(P3.0 续)
|
||
|
||
**Files:**
|
||
- Create: `src/renderer/components/settings/AiSettingsPage.tsx`
|
||
- Modify: `src/renderer/components/settings/SettingsPage.tsx`
|
||
- Modify: `src/main/ipc/handlers/ai.handler.ts`(`ai:testConnection`)
|
||
- Modify: `public/locales/*/translation.json`
|
||
|
||
- [x] **Step 1: AiSettingsPage**
|
||
|
||
字段:backend select、baseUrl、model、apiKey(非 lmstudio 显示)、timeout。
|
||
|
||
云端 backend 显示 §6.9 许可链接。
|
||
|
||
「测试连接」→ `ai:testConnection` → 最小 chat → toast。
|
||
|
||
- [x] **Step 2: Settings 导航增加 AI Tab**
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git commit -am "feat: add AI settings page with connection test"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 11: 离线降级 + 命名检查 + AI 快照(P3.3)
|
||
|
||
**Files:**
|
||
- Create: `src/renderer/components/ai/NamingCheckModal.tsx`
|
||
- Create: `src/main/services/naming-check.service.ts`
|
||
- Create: `tests/main/naming-parse.test.ts`
|
||
- Modify: `src/renderer/components/layout/RightPanel.tsx`(知识库 Tab 按钮)
|
||
- Modify: `src/renderer/lib/editor-commands.ts`
|
||
- Modify: `src/renderer/components/ai/AiPanel.tsx`
|
||
|
||
- [x] **Step 1: 离线规则**
|
||
|
||
`useAiStore`:`disabled = aiConfig.backend !== 'lmstudio' && !networkOnline`。
|
||
|
||
`AiPanel` 显示 `#offlineBanner`;网络恢复 toast。
|
||
|
||
- [x] **Step 2: naming-check.service**
|
||
|
||
`buildPrompt(settings, chaptersPlain)` → 调 `AiClientService.chat` → `parseNamingJson(response)`。
|
||
|
||
解析器单测(不 mock HTTP):`tests/main/naming-parse.test.ts`。
|
||
|
||
- [x] **Step 3: NamingCheckModal**
|
||
|
||
列表 + 忽略/替换;替换调 `search.replace`。
|
||
|
||
- [x] **Step 4: AI 快照**
|
||
|
||
`insertToEditor(text)`:
|
||
|
||
```typescript
|
||
await ipcCall(() => window.electronAPI.snapshot.create(bookId, chapterId, 'ai', t('snapshot.ai'), currentHtml))
|
||
// then insert text at cursor
|
||
```
|
||
|
||
- [x] **Step 5: E2E E2E-P3-NAME-01**
|
||
|
||
种子:设定「林远」+ 正文「林悦」→ 命名检查 → 至少一行结果。
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git commit -am "feat: add offline fallback, naming check, and AI snapshots"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 12: i18n + CSS + 全量测试 + v0.3.0(P3.4)
|
||
|
||
**Files:**
|
||
- Modify: `public/locales/zh-CN/translation.json`, `public/locales/en/translation.json`
|
||
- Modify: `src/renderer/styles/layout.css`
|
||
- Modify: `package.json`, `README.md`
|
||
- Create: `e2e/ai-chat.spec.ts`(补全 CTX/CMD/OFF)
|
||
- Extend: `e2e/p21-features.spec.ts`
|
||
|
||
- [x] **Step 1: i18n 全量键**
|
||
|
||
`ai.*`, `naming.*`, `inspiration.voice*`, `search.replace*`, `snapshot.ai`
|
||
|
||
- [x] **Step 2: layout.css**
|
||
|
||
`.ai-session-bar`, `.ai-mode-tabs`, `.chat-msg.user/.ai`, `.quick-cmd`, `.offline-banner`, `.naming-modal`, `.search-replace-panel`
|
||
|
||
- [x] **Step 3: 全量测试**
|
||
|
||
```bash
|
||
npm run test
|
||
npm run build
|
||
npm run test:e2e
|
||
```
|
||
|
||
Expected: 全部 PASS(**LM Studio 必须在 127.0.0.1:1234 运行**)
|
||
|
||
- [x] **Step 4: package.json version 0.3.0 + README**
|
||
|
||
- [ ] **Step 5: Commit + tag**
|
||
|
||
```bash
|
||
git commit -am "chore: prepare v0.3.0 release"
|
||
git tag v0.3.0
|
||
```
|
||
|
||
---
|
||
|
||
## Plan Self-Review
|
||
|
||
| Spec 章节 | 对应用 Task |
|
||
|-----------|-------------|
|
||
| §5.1 章节拖拽 | Task 1 |
|
||
| §5.2 搜索替换 | Task 2 |
|
||
| §5.3 灵感模态+语音 | Task 3 |
|
||
| §5.4 @跳转 | Task 3 |
|
||
| §5.5 引用钉住 | Task 3 |
|
||
| §3 Schema v3 | Task 4 |
|
||
| §2.2 AiClientService | Task 5 |
|
||
| §4 AI IPC | Task 6–7 |
|
||
| §6.1 AiPanel | Task 8 |
|
||
| §6.2 上下文+快捷命令 | Task 9 |
|
||
| §6.7 AI 设置 | Task 10 |
|
||
| §6.4 离线 | Task 11 |
|
||
| §6.5 命名检查 | Task 11 |
|
||
| §6.6 AI 快照 | Task 11 |
|
||
| §7 测试 | Task 1–12 |
|
||
| §10 DoD | Task 12 |
|
||
|
||
| 检查项 | 结果 |
|
||
|--------|------|
|
||
| Placeholder 扫描 | 无 TBD;写作模式 Tab 在 Task 8 明确 comingSoon |
|
||
| 测试 Mock | 全文禁止 skip;ai-client.test 真实 HTTP |
|
||
| 类型一致 | `AiContextBinding` / `context_json` / IPC 命名一致 |
|
||
|
||
---
|
||
|
||
## 执行方式
|
||
|
||
Plan 已保存至 `docs/superpowers/plans/2026-07-07-bilin-p21-p3.md`。
|
||
|
||
**两种执行选项:**
|
||
|
||
1. **Subagent-Driven(推荐)** — 每个 Task 派发独立 subagent,任务间 review,迭代快
|
||
2. **Inline Execution** — 在本会话按 Task 顺序直接实现,批次间设检查点
|
||
|
||
你选哪种?
|