feat: ship v1.2.0 Wave 1 foundation with book settings, chapter management, and focus mode

Deliver schema v9, book/chapter IPC extensions, BookSettingsTab, chapter meta/tags,
AI session menu, focus mode and TTS, template context enrich, and Wave 1 E2E coverage.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-08 15:22:58 +08:00
parent 64da95f7e1
commit fe127ec3ed
57 changed files with 1963 additions and 139 deletions
+44 -1
View File
@@ -1,5 +1,6 @@
import { randomUUID } from 'crypto'
import { BrowserWindow, ipcMain } from 'electron'
import { writeFileSync } from 'fs'
import { BrowserWindow, dialog, ipcMain } from 'electron'
import { IPC } from '../../../shared/ipc-channels'
import type { AiConfig, AiContextBinding, NamingConflict } from '../../../shared/types'
import { BookRegistryService } from '../../services/book-registry'
@@ -19,6 +20,15 @@ import { wrap } from '../result'
const activeChats = new Map<string, AbortController>()
function formatSessionMarkdown(title: string, messages: { role: string; content: string; createdAt: string }[]): string {
const lines = [`# ${title || 'AI 会话'}`, '']
for (const msg of messages) {
const label = msg.role === 'user' ? '用户' : msg.role === 'assistant' ? '助手' : msg.role
lines.push(`## ${label}`, '', msg.content, '', `_${msg.createdAt}_`, '')
}
return lines.join('\n')
}
export function registerAiHandlers(
registry: BookRegistryService,
settings: GlobalSettingsService,
@@ -67,6 +77,39 @@ export function registerAiHandlers(
wrap(() => registry.getAiSessionRepo(bookId).listMessages(sessionId))
)
ipcMain.handle(
IPC.AI_SESSION_EXPORT,
async (
_e,
{
bookId,
sessionId,
format = 'markdown'
}: { bookId: string; sessionId: string; format?: 'markdown' | 'txt' }
) =>
wrap(async () => {
const repo = registry.getAiSessionRepo(bookId)
const session = repo.get(sessionId)
if (!session) throw new Error('AI session not found')
const messages = repo.listMessages(sessionId)
const text =
format === 'markdown'
? formatSessionMarkdown(session.title, messages)
: messages
.map((m) => `[${m.role}] ${m.createdAt}\n${m.content}`)
.join('\n\n---\n\n')
const ext = format === 'markdown' ? 'md' : 'txt'
const { canceled, filePath } = await dialog.showSaveDialog({
defaultPath: `${session.title || 'ai-session'}.${ext}`,
filters: [{ name: format === 'markdown' ? 'Markdown' : 'Text', extensions: [ext] }]
})
if (canceled || !filePath) return null
writeFileSync(filePath, text, 'utf8')
return filePath
})
)
ipcMain.handle(
IPC.AI_BUILD_CONTEXT,
(_e, { bookId, binding }: { bookId: string; binding: AiContextBinding }) =>
@@ -0,0 +1,21 @@
import { ipcMain } from 'electron'
import { IPC } from '../../../shared/ipc-channels'
import { BookPrefsRepository } from '../../db/repositories/book-prefs.repo'
import { BookRegistryService } from '../../services/book-registry'
import { wrap } from '../result'
export function registerBookPrefsHandlers(registry: BookRegistryService): void {
ipcMain.handle(
IPC.BOOK_PREFS_GET,
(_event, { bookId, key }: { bookId: string; key: string }) =>
wrap(() => new BookPrefsRepository(registry.getDb(bookId)).get(key))
)
ipcMain.handle(
IPC.BOOK_PREFS_SET,
(_event, { bookId, key, value }: { bookId: string; key: string; value: string }) =>
wrap(() => {
new BookPrefsRepository(registry.getDb(bookId)).set(key, value)
})
)
}
+23 -14
View File
@@ -1,6 +1,8 @@
import { ipcMain } from 'electron'
import { ipcMain, dialog, app } from 'electron'
import { copyFileSync, mkdirSync } from 'fs'
import { extname, join } from 'path'
import { IPC } from '../../../shared/ipc-channels'
import type { BookMeta, CreateBookParams } from '../../../shared/types'
import type { CreateBookParams, UpdateBookMetaParams } from '../../../shared/types'
import { BookRegistryService } from '../../services/book-registry'
import type { WritingSessionService } from '../../services/writing-session.service'
import { wrap } from '../result'
@@ -35,17 +37,24 @@ export function registerBookHandlers(
ipcMain.handle(
IPC.BOOK_UPDATE_META,
(
_event,
{
bookId,
...patch
}: {
bookId: string
lastChapterId?: string | null
lastOpenedAt?: string
status?: BookMeta['status']
}
) => wrap(() => registry.updateMeta(bookId, patch))
(_event, { bookId, ...patch }: { bookId: string } & UpdateBookMetaParams) =>
wrap(() => registry.updateMeta(bookId, patch))
)
ipcMain.handle(IPC.BOOK_PICK_COVER, (_event, { bookId }: { bookId: string }) =>
wrap(async () => {
const { canceled, filePaths } = await dialog.showOpenDialog({
filters: [{ name: 'Images', extensions: ['jpg', 'jpeg', 'png', 'webp'] }],
properties: ['openFile']
})
if (canceled || !filePaths[0]) return null
const src = filePaths[0]
const ext = extname(src).toLowerCase() || '.jpg'
const coversDir = join(app.getPath('userData'), 'covers')
mkdirSync(coversDir, { recursive: true })
const dest = join(coversDir, `${bookId}${ext}`)
copyFileSync(src, dest)
return registry.updateMeta(bookId, { coverPath: dest })
})
)
}
+47 -2
View File
@@ -75,7 +75,12 @@ export function registerChapterHandlers(
content,
status,
cursorOffset,
povCharacterId
povCharacterId,
summary,
storyTime,
wordCountThreshold,
publishStatus,
publishedAt
}: {
bookId: string
chapterId: string
@@ -84,6 +89,11 @@ export function registerChapterHandlers(
status?: ChapterStatus
cursorOffset?: number
povCharacterId?: string | null
summary?: string
storyTime?: string | null
wordCountThreshold?: number | null
publishStatus?: PublishStatus
publishedAt?: string | null
}
) =>
wrap(() => {
@@ -92,7 +102,12 @@ export function registerChapterHandlers(
content,
status,
cursorOffset,
povCharacterId
povCharacterId,
summary,
storyTime,
wordCountThreshold,
publishStatus,
publishedAt
})
ftsSync.upsert(
registry.getDb(bookId),
@@ -146,4 +161,34 @@ export function registerChapterHandlers(
{ bookId, chapterId, publishStatus }: { bookId: string; chapterId: string; publishStatus: PublishStatus }
) => wrap(() => registry.getChapterRepo(bookId).setPublishStatus(chapterId, publishStatus))
)
ipcMain.handle(
IPC.CHAPTER_TAG_LIST,
(_event, { bookId, chapterId }: { bookId: string; chapterId: string }) =>
wrap(() => registry.getChapterTagRepo(bookId).list(chapterId))
)
ipcMain.handle(
IPC.CHAPTER_TAG_SET,
(
_event,
{
bookId,
chapterId,
tag,
color
}: { bookId: string; chapterId: string; tag: string; color?: string }
) =>
wrap(() => {
registry.getChapterTagRepo(bookId).set(chapterId, tag, color ?? '')
})
)
ipcMain.handle(
IPC.CHAPTER_TAG_REMOVE,
(_event, { bookId, chapterId, tag }: { bookId: string; chapterId: string; tag: string }) =>
wrap(() => {
registry.getChapterTagRepo(bookId).remove(chapterId, tag)
})
)
}
+2
View File
@@ -14,6 +14,7 @@ import { WritingSessionRepository } from '../db/repositories/writing-session.rep
import { WritingSessionService } from '../services/writing-session.service'
import { registerSettingsHandlers } from './handlers/settings.handler'
import { registerBookHandlers } from './handlers/book.handler'
import { registerBookPrefsHandlers } from './handlers/book-prefs.handler'
import { registerChapterHandlers } from './handlers/chapter.handler'
import { registerShortcutHandlers } from './handlers/shortcut.handler'
import { registerOutlineHandlers } from './handlers/outline.handler'
@@ -69,6 +70,7 @@ export function registerIpc(): { settings: GlobalSettingsService; books: BookReg
registerSettingsHandlers(settings)
registerBookHandlers(books, writingSessions)
registerBookPrefsHandlers(books)
registerChapterHandlers(books, writingLogs, writingSessions)
registerShortcutHandlers(shortcutManager)
registerOutlineHandlers(books)