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:
@@ -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)
|
||||
})
|
||||
)
|
||||
}
|
||||
@@ -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 })
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user