0b5aa146bd
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 用例)
|
||
|
||
- [ ] **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'])
|
||
})
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 2: 运行确认 FAIL**
|
||
|
||
```bash
|
||
npm run test -- tests/main/chapter-move.test.ts
|
||
```
|
||
|
||
Expected: FAIL `chapters.move is not a function`
|
||
|
||
- [ ] **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))
|
||
}
|
||
```
|
||
|
||
- [ ] **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)`。
|
||
|
||
- [ ] **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)`。
|
||
|
||
- [ ] **Step 6: E2E E2E-P21-DRAG-01**
|
||
|
||
`e2e/p21-features.spec.ts`:两章 → drag 交换顺序 → 重启 → 顺序保持。
|
||
|
||
- [ ] **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`
|
||
|
||
- [ ] **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。
|
||
|
||
- [ ] **Step 2: i18n 键**
|
||
|
||
`search.replace`, `search.replacePreview`, `search.replaceExecute`, `search.replaceConfirm`, `search.replaceDone`
|
||
|
||
- [ ] **Step 3: E2E E2E-P21-REP-01**
|
||
|
||
写入章节唯一词 → 搜索 → 预览 → 执行 → `editor` 含新词。
|
||
|
||
- [ ] **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`
|
||
|
||
- [ ] **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')}`。
|
||
|
||
- [ ] **Step 2: App.tsx 改 captureInspiration**
|
||
|
||
`captureInspiration` 快捷键 → `useAppStore.setInspirationModalOpen(true)`,不再直接 create。
|
||
|
||
- [ ] **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`。
|
||
|
||
- [ ] **Step 4: 引用钉住**
|
||
|
||
`useReferenceStore`:`pinned: boolean`, `setPinned`。
|
||
|
||
`ReferencePanel`:`pinned` 时关闭按钮改为取消钉住;`setOpen(false)` 若 `pinned` 则忽略。
|
||
|
||
- [ ] **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`
|
||
|
||
- [ ] **Step 1: schema-v3.sql**
|
||
|
||
内容同 spec §3.1 `ai_sessions` / `ai_messages` / index。
|
||
|
||
- [ ] **Step 2: migrate.ts**
|
||
|
||
`CURRENT_VERSION = 3`;`current < 3` 时 `db.exec(schemaV3)` + insert version 3。
|
||
|
||
- [ ] **Step 3: migrate-v3.test.ts**
|
||
|
||
v2 库 migrate 后 `SELECT name FROM sqlite_master` 含 `ai_sessions`。
|
||
|
||
- [ ] **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[]`。
|
||
|
||
- [ ] **Step 5: global-settings defaults()**
|
||
|
||
合并 `aiConfig: DEFAULT_AI_CONFIG`, `namingIgnoreWords: []`。
|
||
|
||
- [ ] **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`
|
||
|
||
- [ ] **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)
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 2: 运行确认 FAIL**
|
||
|
||
```bash
|
||
npm run test -- tests/main/ai-client.test.ts
|
||
```
|
||
|
||
- [ ] **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
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **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`
|
||
|
||
- [ ] **Step 1: ai-session.repo 测试 + 实现**
|
||
|
||
CRUD:`list`, `create`, `update`, `delete`, `listMessages`, `addMessage`。
|
||
|
||
`context_json` ↔ `AiContextBinding` JSON 序列化。
|
||
|
||
- [ ] **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 }
|
||
})
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 3: book-registry getAiSessionRepo**
|
||
|
||
- [ ] **Step 4: preload 暴露 `window.electronAPI.ai.*`**
|
||
|
||
- [ ] **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`
|
||
|
||
- [ ] **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>`。
|
||
|
||
- [ ] **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)
|
||
}
|
||
```
|
||
|
||
- [ ] **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`
|
||
|
||
- [ ] **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`。
|
||
|
||
- [ ] **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 占位**
|
||
|
||
`panel-content` AI Tab 渲染 `<AiPanel />`。
|
||
|
||
- [ ] **Step 4: E2E E2E-P3-CHAT-01**
|
||
|
||
`e2e/ai-chat.spec.ts`:打开书 → 新建会话 → 输入「你好」→ Enter → `chat-message-assistant` 非空(timeout 90s)。
|
||
|
||
- [ ] **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`
|
||
|
||
- [ ] **Step 1: AiContextBuilder 测试**
|
||
|
||
截断:超长 chapter content → 块 ≤ 2000 字;总量 ≤ 16KB。
|
||
|
||
- [ ] **Step 2: 实现 build(binding, bookId)**
|
||
|
||
返回 `{ systemPrompt, preview: ContextPreviewItem[] }`。
|
||
|
||
- [ ] **Step 3: ContextEditorModal**
|
||
|
||
四 Tab 勾选列表(chapters/outlines/settings/inspirations);保存 → `ai:sessionUpdate` patch `context_json`;「更新上下文」→ `ai:buildContext`。
|
||
|
||
- [ ] **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。
|
||
|
||
- [ ] **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`
|
||
|
||
- [ ] **Step 1: AiSettingsPage**
|
||
|
||
字段:backend select、baseUrl、model、apiKey(非 lmstudio 显示)、timeout。
|
||
|
||
云端 backend 显示 §6.9 许可链接。
|
||
|
||
「测试连接」→ `ai:testConnection` → 最小 chat → toast。
|
||
|
||
- [ ] **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`
|
||
|
||
- [ ] **Step 1: 离线规则**
|
||
|
||
`useAiStore`:`disabled = aiConfig.backend !== 'lmstudio' && !networkOnline`。
|
||
|
||
`AiPanel` 显示 `#offlineBanner`;网络恢复 toast。
|
||
|
||
- [ ] **Step 2: naming-check.service**
|
||
|
||
`buildPrompt(settings, chaptersPlain)` → 调 `AiClientService.chat` → `parseNamingJson(response)`。
|
||
|
||
解析器单测(不 mock HTTP):`tests/main/naming-parse.test.ts`。
|
||
|
||
- [ ] **Step 3: NamingCheckModal**
|
||
|
||
列表 + 忽略/替换;替换调 `search.replace`。
|
||
|
||
- [ ] **Step 4: AI 快照**
|
||
|
||
`insertToEditor(text)`:
|
||
|
||
```typescript
|
||
await ipcCall(() => window.electronAPI.snapshot.create(bookId, chapterId, 'ai', t('snapshot.ai'), currentHtml))
|
||
// then insert text at cursor
|
||
```
|
||
|
||
- [ ] **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`
|
||
|
||
- [ ] **Step 1: i18n 全量键**
|
||
|
||
`ai.*`, `naming.*`, `inspiration.voice*`, `search.replace*`, `snapshot.ai`
|
||
|
||
- [ ] **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: 全量测试**
|
||
|
||
```bash
|
||
npm run test
|
||
npm run build
|
||
npm run test:e2e
|
||
```
|
||
|
||
Expected: 全部 PASS(**LM Studio 必须在 127.0.0.1:1234 运行**)
|
||
|
||
- [ ] **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 顺序直接实现,批次间设检查点
|
||
|
||
你选哪种?
|