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
+10 -1
View File
@@ -7,8 +7,9 @@ import schemaV5 from './schema-v5.sql?raw'
import schemaV6 from './schema-v6.sql?raw'
import schemaV7 from './schema-v7.sql?raw'
import schemaV8 from './schema-v8.sql?raw'
import schemaV9 from './schema-v9.sql?raw'
const CURRENT_VERSION = 8
const CURRENT_VERSION = 9
function tryAlter(db: SqliteDb, sql: string): void {
try {
@@ -88,5 +89,13 @@ export function migrate(db: SqliteDb): void {
if (current < 8) {
db.exec(schemaV8)
db.prepare('INSERT INTO schema_version (version, description) VALUES (?, ?)').run(8, 'P5.3 P6.1 goals injection')
current = 8
}
if (current < 9) {
tryAlter(db, 'ALTER TABLE chapters ADD COLUMN published_at TEXT DEFAULT NULL')
tryAlter(db, 'ALTER TABLE chapters ADD COLUMN word_count_threshold INTEGER DEFAULT NULL')
db.exec(schemaV9)
db.prepare('INSERT INTO schema_version (version, description) VALUES (?, ?)').run(9, 'W1 foundation')
}
}
@@ -0,0 +1,44 @@
import type { ChapterTag } from '../../../shared/types'
import type { SqliteDb } from '../types'
function mapRow(row: Record<string, unknown>): ChapterTag {
return {
chapterId: row.chapter_id as string,
tag: row.tag as string,
color: (row.color as string) ?? ''
}
}
export class ChapterTagRepository {
constructor(private db: SqliteDb) {}
list(chapterId: string): ChapterTag[] {
return (
this.db
.prepare('SELECT * FROM chapter_tags WHERE chapter_id = ? ORDER BY tag')
.all(chapterId) as Record<string, unknown>[]
).map(mapRow)
}
listAll(): ChapterTag[] {
return (
this.db.prepare('SELECT * FROM chapter_tags ORDER BY chapter_id, tag').all() as Record<
string,
unknown
>[]
).map(mapRow)
}
set(chapterId: string, tag: string, color = ''): void {
this.db
.prepare(
`INSERT INTO chapter_tags (chapter_id, tag, color) VALUES (?, ?, ?)
ON CONFLICT(chapter_id, tag) DO UPDATE SET color = excluded.color`
)
.run(chapterId, tag, color)
}
remove(chapterId: string, tag: string): void {
this.db.prepare('DELETE FROM chapter_tags WHERE chapter_id = ? AND tag = ?').run(chapterId, tag)
}
}
+23 -3
View File
@@ -14,7 +14,11 @@ function mapRow(row: Record<string, unknown>): Chapter {
cursorOffset: (row.cursor_offset as number) ?? 0,
origin: ((row.origin as ChapterOrigin) ?? 'manual') as ChapterOrigin,
publishStatus: ((row.publish_status as PublishStatus) ?? 'draft') as PublishStatus,
povCharacterId: (row.pov_character_id as string) || null
povCharacterId: (row.pov_character_id as string) || null,
summary: (row.summary as string) ?? '',
storyTime: (row.story_time as string) ?? null,
publishedAt: (row.published_at as string) ?? null,
wordCountThreshold: (row.word_count_threshold as number) ?? null
}
}
@@ -68,6 +72,10 @@ export class ChapterRepository {
cursorOffset: number
publishStatus: PublishStatus
povCharacterId: string | null
summary: string
storyTime: string | null
publishedAt: string | null
wordCountThreshold: number | null
}>
): Chapter {
const existing = this.get(id)
@@ -82,7 +90,9 @@ export class ChapterRepository {
.prepare(
`UPDATE chapters SET
title = ?, content = ?, status = ?, cursor_offset = ?,
publish_status = ?, pov_character_id = ?, word_count = ?, updated_at = datetime('now')
publish_status = ?, pov_character_id = ?, word_count = ?,
summary = ?, story_time = ?, published_at = ?, word_count_threshold = ?,
updated_at = datetime('now')
WHERE id = ?`
)
.run(
@@ -93,13 +103,23 @@ export class ChapterRepository {
patch.publishStatus ?? existing.publishStatus ?? 'draft',
povCharacterId,
wordCount,
patch.summary ?? existing.summary ?? '',
patch.storyTime !== undefined ? patch.storyTime : (existing.storyTime ?? null),
patch.publishedAt !== undefined ? patch.publishedAt : (existing.publishedAt ?? null),
patch.wordCountThreshold !== undefined
? patch.wordCountThreshold
: (existing.wordCountThreshold ?? null),
id
)
return this.get(id)!
}
setPublishStatus(id: string, publishStatus: PublishStatus): Chapter {
return this.update(id, { publishStatus })
const patch: Parameters<ChapterRepository['update']>[1] = { publishStatus }
if (publishStatus === 'published') {
patch.publishedAt = new Date().toISOString()
}
return this.update(id, patch)
}
countByPublishStatus(status: PublishStatus): number {
+7
View File
@@ -0,0 +1,7 @@
CREATE TABLE IF NOT EXISTS chapter_tags (
chapter_id TEXT NOT NULL,
tag TEXT NOT NULL,
color TEXT DEFAULT '',
PRIMARY KEY (chapter_id, tag),
FOREIGN KEY (chapter_id) REFERENCES chapters(id) ON DELETE CASCADE
);
+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)
+7 -2
View File
@@ -11,7 +11,8 @@ import { AiSessionRepository } from '../db/repositories/ai-session.repo'
import { InteractiveRepository } from '../db/repositories/interactive.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'
import { ChapterTagRepository } from '../db/repositories/chapter-tag.repo'
import type { BookMeta, BookOpenResult, CreateBookParams, Chapter, Volume, UpdateBookMetaParams } from '../../shared/types'
interface RegistryFile {
books: BookMeta[]
@@ -90,7 +91,7 @@ export class BookRegistryService {
this.writeRegistry(registry)
}
updateMeta(bookId: string, patch: Partial<Pick<BookMeta, 'lastOpenedAt' | 'lastChapterId' | 'status'>>): BookMeta {
updateMeta(bookId: string, patch: UpdateBookMetaParams): BookMeta {
const registry = this.readRegistry()
const idx = registry.books.findIndex((b) => b.id === bookId)
if (idx === -1) throw new Error('Book not found')
@@ -162,6 +163,10 @@ export class BookRegistryService {
getBookmarkRepo(bookId: string): BookmarkRepository {
return new BookmarkRepository(this.getDb(bookId))
}
getChapterTagRepo(bookId: string): ChapterTagRepository {
return new ChapterTagRepository(this.getDb(bookId))
}
}
export function aggregateWordCounts(chapters: Chapter[], volumes: Volume[]): {
+3 -1
View File
@@ -52,7 +52,9 @@ export class CockpitService {
}
shouldShowOnOpen(): boolean {
return new BookPrefsRepository(this.db).get('cockpitSeen') !== 'true'
const prefs = new BookPrefsRepository(this.db)
if (prefs.get('alwaysShowCockpit') === 'true') return true
return prefs.get('cockpitSeen') !== 'true'
}
markSeen(): void {
+31 -2
View File
@@ -36,6 +36,8 @@ import type {
Snapshot,
SnapshotType,
UpdateChapterParams,
UpdateBookMetaParams,
ChapterTag,
Volume,
WordFreqResult,
KnowledgeEntry,
@@ -80,8 +82,16 @@ const electronAPI = {
ipcRenderer.invoke(IPC.BOOK_OPEN, { bookId }),
updateMeta: (
bookId: string,
patch: { lastChapterId?: string | null; status?: BookMeta['status'] }
): Promise<IpcResult<BookMeta>> => ipcRenderer.invoke(IPC.BOOK_UPDATE_META, { bookId, ...patch })
patch: UpdateBookMetaParams
): Promise<IpcResult<BookMeta>> => ipcRenderer.invoke(IPC.BOOK_UPDATE_META, { bookId, ...patch }),
pickCover: (bookId: string): Promise<IpcResult<BookMeta | null>> =>
ipcRenderer.invoke(IPC.BOOK_PICK_COVER, { bookId })
},
bookPrefs: {
get: (bookId: string, key: string): Promise<IpcResult<string | null>> =>
ipcRenderer.invoke(IPC.BOOK_PREFS_GET, { bookId, key }),
set: (bookId: string, key: string, value: string): Promise<IpcResult<void>> =>
ipcRenderer.invoke(IPC.BOOK_PREFS_SET, { bookId, key, value })
},
volume: {
create: (bookId: string, name: string): Promise<IpcResult<Volume>> =>
@@ -120,6 +130,19 @@ const electronAPI = {
): Promise<IpcResult<Chapter>> =>
ipcRenderer.invoke(IPC.CHAPTER_SET_PUBLISH_STATUS, { bookId, chapterId, publishStatus })
},
chapterTag: {
list: (bookId: string, chapterId: string): Promise<IpcResult<ChapterTag[]>> =>
ipcRenderer.invoke(IPC.CHAPTER_TAG_LIST, { bookId, chapterId }),
set: (
bookId: string,
chapterId: string,
tag: string,
color?: string
): Promise<IpcResult<void>> =>
ipcRenderer.invoke(IPC.CHAPTER_TAG_SET, { bookId, chapterId, tag, color }),
remove: (bookId: string, chapterId: string, tag: string): Promise<IpcResult<void>> =>
ipcRenderer.invoke(IPC.CHAPTER_TAG_REMOVE, { bookId, chapterId, tag })
},
outline: {
list: (bookId: string): Promise<IpcResult<OutlineItem[]>> =>
ipcRenderer.invoke(IPC.OUTLINE_LIST, { bookId }),
@@ -277,6 +300,12 @@ const electronAPI = {
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 }),
sessionExport: (
bookId: string,
sessionId: string,
format?: 'markdown' | 'txt'
): Promise<IpcResult<string | null>> =>
ipcRenderer.invoke(IPC.AI_SESSION_EXPORT, { bookId, sessionId, format }),
messageList: (bookId: string, sessionId: string): Promise<IpcResult<AiMessage[]>> =>
ipcRenderer.invoke(IPC.AI_MESSAGE_LIST, { bookId, sessionId }),
chat: (
+33 -2
View File
@@ -18,10 +18,14 @@ import { useEditStore } from '@renderer/stores/useEditStore'
import { SearchModal } from '@renderer/components/search/SearchModal'
import { InspirationModal } from '@renderer/components/inspiration/InspirationModal'
import { ImportBookModal } from '@renderer/components/import/ImportBookModal'
import { FocusModeOverlay } from '@renderer/components/focus/FocusModeOverlay'
import { NamingCheckModal } from '@renderer/components/ai/NamingCheckModal'
import { useSearchStore } from '@renderer/stores/useSearchStore'
import { useReferenceStore } from '@renderer/stores/useReferenceStore'
import { flushEditorSave } from '@renderer/components/editor/TipTapEditor'
import { getEditorTextForTts } from '@renderer/lib/editor-commands'
import { insertLandmarkInEditor } from '@renderer/lib/editor-commands'
import { isSpeaking, speakText, stopSpeaking } from '@renderer/lib/tts-controller'
import { useAiStore } from '@renderer/stores/useAiStore'
import { useExportStore } from '@renderer/stores/useExportStore'
import { ipcCall } from '@renderer/lib/ipc-client'
@@ -30,6 +34,10 @@ function AppInner(): React.JSX.Element {
const { t } = useTranslation()
const view = useAppStore((s) => s.view)
const toast = useAppStore((s) => s.toast)
const focusMode = useAppStore((s) => s.focusMode)
const namingCheckOpen = useAppStore((s) => s.namingCheckOpen)
const setFocusMode = useAppStore((s) => s.setFocusMode)
const setNamingCheckOpen = useAppStore((s) => s.setNamingCheckOpen)
const showToast = useAppStore((s) => s.showToast)
const setView = useAppStore((s) => s.setView)
const { loaded, onboardingCompleted, load: loadSettings } = useSettingsStore()
@@ -158,27 +166,50 @@ function AppInner(): React.JSX.Element {
useExportStore.getState().openModal()
return
}
if (action === 'focusMode') {
if (view !== 'editor') return
setFocusMode(!useAppStore.getState().focusMode)
return
}
if (action === 'toggleTTS') {
if (view !== 'editor') return
if (isSpeaking()) {
stopSpeaking()
showToast(t('tts.stopped'))
return
}
const text = getEditorTextForTts()
if (!text.trim()) {
showToast(t('tts.noText'))
return
}
speakText(text)
showToast(t('tts.started'))
return
}
showToast(t('feature.comingSoon'))
})
return unsub
}, [view, activeTabId, openTabs, openBook, setView, showToast, t])
}, [view, activeTabId, openTabs, openBook, setView, setFocusMode, showToast, t])
const handleOnboardingComplete = (): void => {
setShowOnboarding(false)
}
return (
<div id="app">
<div id="app" className={focusMode ? 'focus-mode' : undefined} data-testid="app-root">
<TopBar />
<div id="main-area">
{view === 'home' && <HomePage />}
{view === 'editor' && <EditorLayout />}
{view === 'settings' && <SettingsPage />}
</div>
<FocusModeOverlay />
{showOnboarding && <OnboardingWizard onComplete={handleOnboardingComplete} />}
<SearchModal />
<InspirationModal />
<ImportBookModal />
<NamingCheckModal open={namingCheckOpen} onClose={() => setNamingCheckOpen(false)} />
{toast && <div className="toast">{toast}</div>}
</div>
)
+25 -6
View File
@@ -12,6 +12,7 @@ import { ContextEditorModal } from '@renderer/components/ai/ContextEditorModal'
import { InteractivePanel } from '@renderer/components/ai/InteractivePanel'
import { AutoPanel } from '@renderer/components/ai/AutoPanel'
import { WizardPanel } from '@renderer/components/ai/WizardPanel'
import { AiSessionMenu } from '@renderer/components/ai/AiSessionMenu'
import { useInteractiveStore } from '@renderer/stores/useInteractiveStore'
import { useAutoStore } from '@renderer/stores/useAutoStore'
import { useWizardStore } from '@renderer/stores/useWizardStore'
@@ -75,7 +76,7 @@ export function AiPanel(): React.JSX.Element {
useEffect(() => {
if (bookId) {
void loadSessions(bookId)
void loadSessions(bookId, true)
void checkGate(bookId)
void checkAutoGate(bookId)
void checkWizardGate(bookId)
@@ -176,6 +177,10 @@ export function AiPanel(): React.JSX.Element {
)
}
const activeSessions = sessions.filter((s) => !s.archived)
const archivedSessions = sessions.filter((s) => s.archived)
const activeSession = sessions.find((s) => s.id === activeSessionId) ?? null
return (
<>
<div id="ai-panel" className="ai-panel" data-testid="ai-panel">
@@ -186,12 +191,26 @@ export function AiPanel(): React.JSX.Element {
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>
))}
{activeSessions.length > 0 && (
<optgroup label={t('ai.session.active')}>
{activeSessions.map((s) => (
<option key={s.id} value={s.id}>
{s.title || t('ai.untitledSession')}
</option>
))}
</optgroup>
)}
{archivedSessions.length > 0 && (
<optgroup label={t('ai.session.archived')}>
{archivedSessions.map((s) => (
<option key={s.id} value={s.id}>
{s.title || t('ai.untitledSession')}
</option>
))}
</optgroup>
)}
</select>
<AiSessionMenu bookId={bookId} session={activeSession} />
<button
type="button"
className="btn"
@@ -0,0 +1,113 @@
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import type { AiSession } from '@shared/types'
import { ipcCall } from '@renderer/lib/ipc-client'
import { useAiStore } from '@renderer/stores/useAiStore'
interface AiSessionMenuProps {
bookId: string
session: AiSession | null
}
export function AiSessionMenu({ bookId, session }: AiSessionMenuProps): React.JSX.Element | null {
const { t } = useTranslation()
const loadSessions = useAiStore((s) => s.loadSessions)
const selectSession = useAiStore((s) => s.selectSession)
const [open, setOpen] = useState(false)
const [exportOpen, setExportOpen] = useState(false)
useEffect(() => {
if (!open) return
const close = (): void => {
setOpen(false)
setExportOpen(false)
}
window.addEventListener('click', close)
return () => window.removeEventListener('click', close)
}, [open])
if (!session) return null
const handleRename = async (): Promise<void> => {
setOpen(false)
const title = window.prompt(t('ai.session.renamePrompt'), session.title)
if (!title?.trim() || title.trim() === session.title) return
await ipcCall(() =>
window.electronAPI.ai.sessionUpdate(bookId, session.id, { title: title.trim() })
)
await loadSessions(bookId, true)
}
const handleArchiveToggle = async (): Promise<void> => {
setOpen(false)
await ipcCall(() =>
window.electronAPI.ai.sessionUpdate(bookId, session.id, { archived: !session.archived })
)
await loadSessions(bookId, true)
}
const handleExport = async (format: 'markdown' | 'txt'): Promise<void> => {
setOpen(false)
setExportOpen(false)
await ipcCall(() => window.electronAPI.ai.sessionExport(bookId, session.id, format))
}
const handleDelete = async (): Promise<void> => {
setOpen(false)
if (!window.confirm(t('ai.session.deleteConfirm', { title: session.title || t('ai.untitledSession') }))) {
return
}
await ipcCall(() => window.electronAPI.ai.sessionDelete(bookId, session.id))
await loadSessions(bookId, true)
const next = useAiStore.getState().sessions[0]
if (next) await selectSession(next.id)
}
return (
<div className="ai-session-menu-wrap">
<button
type="button"
className="btn ai-session-menu-btn"
data-testid="ai-session-menu-btn"
onClick={(e) => {
e.stopPropagation()
setOpen((v) => !v)
}}
>
</button>
{open && (
<div className="context-menu ai-session-menu" onClick={(e) => e.stopPropagation()}>
<button type="button" onClick={() => void handleRename()}>
{t('ai.session.rename')}
</button>
<button type="button" onClick={() => void handleArchiveToggle()}>
{session.archived ? t('ai.session.unarchive') : t('ai.session.archive')}
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
setExportOpen((v) => !v)
}}
>
{t('ai.session.export')}
</button>
{exportOpen && (
<div className="ai-session-export-submenu">
<button type="button" onClick={() => void handleExport('markdown')}>
{t('ai.session.exportMd')}
</button>
<button type="button" onClick={() => void handleExport('txt')}>
{t('ai.session.exportTxt')}
</button>
</div>
)}
<button type="button" className="danger" onClick={() => void handleDelete()}>
{t('ai.session.delete')}
</button>
</div>
)}
</div>
)
}
@@ -0,0 +1,183 @@
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ipcCall } from '@renderer/lib/ipc-client'
import { useAppStore } from '@renderer/stores/useAppStore'
import { useBookStore } from '@renderer/stores/useBookStore'
import { useBookPrefsStore } from '@renderer/stores/useBookPrefsStore'
export function BookSettingsTab(): React.JSX.Element {
const { t } = useTranslation()
const setView = useAppStore((s) => s.setView)
const { currentBookId, books, loadBooks } = useBookStore()
const { alwaysShowCockpit, load, setAlwaysShowCockpit } = useBookPrefsStore()
const book = useMemo(
() => books.find((b) => b.id === currentBookId) ?? null,
[books, currentBookId]
)
const [name, setName] = useState('')
const [category, setCategory] = useState('玄幻')
const [targetWordCount, setTargetWordCount] = useState('')
const [synopsis, setSynopsis] = useState('')
const [coverPath, setCoverPath] = useState<string | null>(null)
const [saving, setSaving] = useState(false)
useEffect(() => {
if (!book) return
setName(book.name)
setCategory(book.category || '玄幻')
setTargetWordCount(book.targetWordCount != null ? String(book.targetWordCount) : '')
setSynopsis(book.synopsis ?? '')
setCoverPath(book.coverPath ?? null)
}, [book])
useEffect(() => {
if (currentBookId) void load(currentBookId)
}, [currentBookId, load])
const handleSave = async (): Promise<void> => {
if (!currentBookId || !name.trim()) return
setSaving(true)
try {
const updated = await ipcCall(() =>
window.electronAPI.book.updateMeta(currentBookId, {
name: name.trim(),
category,
targetWordCount: targetWordCount ? Number(targetWordCount) : null,
synopsis: synopsis.trim(),
coverPath
})
)
setCoverPath(updated.coverPath ?? null)
await loadBooks()
} finally {
setSaving(false)
}
}
const handlePickCover = async (): Promise<void> => {
if (!currentBookId) return
const updated = await ipcCall(() => window.electronAPI.book.pickCover(currentBookId))
if (updated) {
setCoverPath(updated.coverPath ?? null)
await loadBooks()
}
}
const handleDelete = async (): Promise<void> => {
if (!currentBookId || !book) return
if (!window.confirm(t('bookSettings.deleteConfirm', { name: book.name }))) return
await ipcCall(() => window.electronAPI.book.delete(currentBookId))
await loadBooks()
setView('home')
}
if (!currentBookId || !book) {
return <div className="placeholder-box">{t('ai.noBook')}</div>
}
return (
<div className="book-settings-tab" data-testid="book-settings-tab">
<h3 className="settings-subheading">{t('bookSettings.title')}</h3>
<div className="form-group">
<label className="form-label">{t('dialog.newBook.name')}</label>
<input
className="form-control form-control--wide"
data-testid="book-settings-name"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div className="form-group">
<label className="form-label">{t('dialog.newBook.category')}</label>
<select
className="form-control form-control--wide"
data-testid="book-settings-category"
value={category}
onChange={(e) => setCategory(e.target.value)}
>
<option value="玄幻"></option>
<option value="仙侠"></option>
<option value="科幻"></option>
<option value="言情"></option>
</select>
</div>
<div className="form-group">
<label className="form-label">{t('dialog.newBook.target')}</label>
<input
type="number"
className="form-control form-control--wide"
data-testid="book-settings-target"
value={targetWordCount}
onChange={(e) => setTargetWordCount(e.target.value)}
/>
</div>
<div className="form-group">
<label className="form-label">{t('bookSettings.synopsis')}</label>
<textarea
className="form-control form-control--wide"
data-testid="book-settings-synopsis"
rows={4}
value={synopsis}
onChange={(e) => setSynopsis(e.target.value)}
/>
</div>
<div className="form-group">
<label className="form-label">{t('bookSettings.cover')}</label>
<div className="book-settings-cover-row">
<button
type="button"
className="btn btn-sm"
data-testid="book-settings-pick-cover"
onClick={() => void handlePickCover()}
>
{t('bookSettings.pickCover')}
</button>
<span className="book-settings-cover-path">
{coverPath ? coverPath.replace(/^.*[\\/]/, '') : t('bookSettings.noCover')}
</span>
</div>
</div>
<div className="form-group">
<label className="form-label checkbox-label">
<input
type="checkbox"
data-testid="book-settings-always-cockpit"
checked={alwaysShowCockpit}
onChange={(e) => void setAlwaysShowCockpit(currentBookId, e.target.checked)}
/>
{t('bookSettings.alwaysShowCockpit')}
</label>
</div>
<button
type="button"
className="btn btn-primary"
data-testid="book-settings-save"
disabled={!name.trim() || saving}
onClick={() => void handleSave()}
>
{t('dialog.save')}
</button>
<div className="book-settings-danger" data-testid="book-settings-danger">
<h4>{t('bookSettings.dangerZone')}</h4>
<button
type="button"
className="btn btn-danger"
data-testid="book-settings-delete"
onClick={() => void handleDelete()}
>
{t('bookSettings.deleteBook')}
</button>
</div>
</div>
)
}
@@ -0,0 +1,169 @@
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import type { Chapter, ChapterTag } from '@shared/types'
import { ipcCall } from '@renderer/lib/ipc-client'
import { useBookStore } from '@renderer/stores/useBookStore'
interface ChapterMetaPanelProps {
open: boolean
chapter: Chapter | null
onClose: () => void
}
export function ChapterMetaPanel({
open,
chapter,
onClose
}: ChapterMetaPanelProps): React.JSX.Element | null {
const { t } = useTranslation()
const currentBookId = useBookStore((s) => s.currentBookId)
const updateChapterLocal = useBookStore((s) => s.updateChapterLocal)
const [summary, setSummary] = useState('')
const [storyTime, setStoryTime] = useState('')
const [wordCountThreshold, setWordCountThreshold] = useState('')
const [tags, setTags] = useState<ChapterTag[]>([])
const [newTag, setNewTag] = useState('')
const [saving, setSaving] = useState(false)
useEffect(() => {
if (!open || !chapter || !currentBookId) return
setSummary(chapter.summary ?? '')
setStoryTime(chapter.storyTime ?? '')
setWordCountThreshold(
chapter.wordCountThreshold != null ? String(chapter.wordCountThreshold) : ''
)
void ipcCall(() =>
window.electronAPI.chapterTag.list(currentBookId, chapter.id)
).then(setTags)
}, [open, chapter, currentBookId])
const handleSave = async (): Promise<void> => {
if (!currentBookId || !chapter) return
setSaving(true)
try {
const updated = await ipcCall(() =>
window.electronAPI.chapter.update({
bookId: currentBookId,
chapterId: chapter.id,
summary: summary.trim(),
storyTime: storyTime.trim() || null,
wordCountThreshold: wordCountThreshold ? Number(wordCountThreshold) : null
})
)
updateChapterLocal(updated)
onClose()
} finally {
setSaving(false)
}
}
const handleAddTag = async (): Promise<void> => {
const tag = newTag.trim()
if (!currentBookId || !chapter || !tag) return
await ipcCall(() =>
window.electronAPI.chapterTag.set(currentBookId, chapter.id, tag)
)
setTags((prev) => [...prev.filter((t) => t.tag !== tag), { chapterId: chapter.id, tag, color: '' }])
setNewTag('')
}
const handleRemoveTag = async (tag: string): Promise<void> => {
if (!currentBookId || !chapter) return
await ipcCall(() =>
window.electronAPI.chapterTag.remove(currentBookId, chapter.id, tag)
)
setTags((prev) => prev.filter((t) => t.tag !== tag))
}
if (!open || !chapter) return null
return (
<div className="modal-overlay" role="dialog" aria-modal="true" data-testid="chapter-meta-panel">
<div className="modal-content chapter-meta-modal">
<div className="modal-header">
<h3>{t('chapterMeta.title')}</h3>
<button type="button" className="modal-close" onClick={onClose}>
</button>
</div>
<div className="modal-body">
<label className="form-label">{t('chapterMeta.summary')}</label>
<textarea
className="form-control form-control--wide"
data-testid="chapter-meta-summary"
rows={3}
value={summary}
onChange={(e) => setSummary(e.target.value)}
/>
<label className="form-label">{t('chapterMeta.storyTime')}</label>
<input
className="form-control form-control--wide"
data-testid="chapter-meta-story-time"
value={storyTime}
onChange={(e) => setStoryTime(e.target.value)}
/>
<label className="form-label">{t('chapterMeta.wordThreshold')}</label>
<input
type="number"
className="form-control form-control--wide"
data-testid="chapter-meta-threshold"
value={wordCountThreshold}
onChange={(e) => setWordCountThreshold(e.target.value)}
/>
{chapter.publishStatus === 'published' && chapter.publishedAt && (
<p className="chapter-meta-published" data-testid="chapter-meta-published">
{t('chapterMeta.publishedAt', {
date: new Date(chapter.publishedAt).toLocaleString('zh-CN')
})}
</p>
)}
<label className="form-label">{t('chapterMeta.tags')}</label>
<div className="chapter-meta-tags">
{tags.map((tag) => (
<span key={tag.tag} className="tag-chip" data-testid={`chapter-tag-${tag.tag}`}>
{tag.tag}
<button type="button" onClick={() => void handleRemoveTag(tag.tag)}>
×
</button>
</span>
))}
</div>
<div className="chapter-meta-tag-add">
<input
className="form-control"
data-testid="chapter-meta-tag-input"
value={newTag}
onChange={(e) => setNewTag(e.target.value)}
placeholder={t('chapterMeta.tagPlaceholder')}
onKeyDown={(e) => {
if (e.key === 'Enter') void handleAddTag()
}}
/>
<button type="button" className="btn btn-sm" onClick={() => void handleAddTag()}>
{t('chapterMeta.addTag')}
</button>
</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="chapter-meta-save"
disabled={saving}
onClick={() => void handleSave()}
>
{t('dialog.save')}
</button>
</div>
</div>
</div>
)
}
@@ -3,8 +3,8 @@ import { useTranslation } from 'react-i18next'
import { mergeChapterTemplates } from '@shared/builtin-templates'
import {
bodyPlainToChapterHtml,
replaceChapterTemplate,
type TemplateContext
buildTemplateContext,
replaceChapterTemplate
} from '@shared/chapter-template'
import type { ChapterTemplate } from '@shared/types'
import { useBookStore } from '@renderer/stores/useBookStore'
@@ -30,6 +30,9 @@ export function TemplateQuickCreateModal({
activeVolumeId,
volumes,
chapters,
outlines,
settings,
selectedChapterId,
refreshChapters,
setSelectedChapter
} = useBookStore()
@@ -52,33 +55,22 @@ export function TemplateQuickCreateModal({
const selectedTemplate = templates.find((tpl) => tpl.id === selectedId) ?? templates[0]
const buildContext = (title: string): TemplateContext => {
const volChapters = chapters
.filter((c) => c.volumeId === activeVolumeId)
.sort((a, b) => a.sortOrder - b.sortOrder)
const chapterNumber = volChapters.length + 1
const volumeName = volumes.find((v) => v.id === activeVolumeId)?.name ?? ''
const previousChapterTitle =
volChapters.length > 0 ? (volChapters[volChapters.length - 1]?.title ?? '') : ''
return {
chapterNumber,
chapterTitle: title,
penName,
date: new Date().toLocaleDateString('zh-CN'),
volumeName,
previousChapterTitle,
outlineItem: '',
characterList: ''
}
}
const handleCreate = async (): Promise<void> => {
if (!currentBookId || !activeVolumeId || !selectedTemplate || !chapterTitle.trim()) return
setCreating(true)
try {
await flushEditorSave()
const title = chapterTitle.trim()
const ctx = buildContext(title)
const ctx = buildTemplateContext({
chapterTitle: title,
penName,
volumes,
chapters,
outlines,
settings,
activeVolumeId,
selectedChapterId
})
const bodyPlain = replaceChapterTemplate(selectedTemplate.body, ctx)
const contentHtml = bodyPlainToChapterHtml(bodyPlain)
@@ -13,7 +13,7 @@ import { ipcCall } from '@renderer/lib/ipc-client'
import { SettingRelationshipsEditor } from '@renderer/components/setting/SettingRelationshipsEditor'
import { LandmarkMark } from '@renderer/extensions/landmark-mark'
import { SettingMention } from '@renderer/extensions/mention'
import { registerHighlightToken, registerInsertLandmark, registerEditorInsert } from '@renderer/lib/editor-commands'
import { registerHighlightToken, registerInsertLandmark, registerEditorInsert, registerEditorGetText } 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)
@@ -289,6 +289,15 @@ export function TipTapEditor({ target, bookId }: TipTapEditorProps): React.JSX.E
}
}, [editor])
useEffect(() => {
if (!editor) {
registerEditorGetText(null)
return
}
registerEditorGetText(() => editor.getText())
return () => registerEditorGetText(null)
}, [editor])
useEffect(() => {
if (!editor || !bookId || !target || target.kind !== 'chapter') {
registerEditorInsert(null, () => {})
@@ -0,0 +1,31 @@
import { useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { useAppStore } from '@renderer/stores/useAppStore'
export function FocusModeOverlay(): React.JSX.Element | null {
const { t } = useTranslation()
const focusMode = useAppStore((s) => s.focusMode)
const setFocusMode = useAppStore((s) => s.setFocusMode)
useEffect(() => {
if (!focusMode) return
const onKey = (e: KeyboardEvent): void => {
if (e.key === 'Escape') setFocusMode(false)
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [focusMode, setFocusMode])
if (!focusMode) return null
return (
<button
type="button"
className="focus-mode-exit"
data-testid="focus-mode-exit"
onClick={() => setFocusMode(false)}
>
{t('focus.exit')}
</button>
)
}
+7 -1
View File
@@ -59,7 +59,13 @@ export function HomePage(): React.JSX.Element {
>
{t('home.importBook')}
</button>
<button type="button" className="btn-large" onClick={() => showToast(t('feature.comingSoon'))}>
<button
type="button"
className="btn-large"
data-testid="home-export-all"
disabled
title={t('home.exportAllWave2')}
>
{t('home.exportAll')}
</button>
<button
+118 -2
View File
@@ -26,6 +26,8 @@ import { CockpitModal } from '@renderer/components/cockpit/CockpitModal'
import { CharacterGraphModal } from '@renderer/components/graph/CharacterGraphModal'
import { ChapterBridgeModal } from '@renderer/components/bridge/ChapterBridgeModal'
import { TemplateQuickCreateModal } from '@renderer/components/chapter/TemplateQuickCreateModal'
import { ChapterMetaPanel } from '@renderer/components/chapter/ChapterMetaPanel'
import { VolumeContextMenu } from '@renderer/components/volume/VolumeContextMenu'
import { ExportModal } from '@renderer/components/export/ExportModal'
import { useExportStore } from '@renderer/stores/useExportStore'
import {
@@ -121,6 +123,12 @@ export function EditorLayout(): React.JSX.Element {
const [bridgeOpen, setBridgeOpen] = useState(false)
const [templateModalOpen, setTemplateModalOpen] = useState(false)
const [chapterMetaOpen, setChapterMetaOpen] = useState(false)
const [volumeMenu, setVolumeMenu] = useState<{ volumeId: string; x: number; y: number } | null>(
null
)
const [renamingChapterId, setRenamingChapterId] = useState<string | null>(null)
const [renameDraft, setRenameDraft] = useState('')
const [cockpitSummary, setCockpitSummary] = useState<CockpitSummary | null>(null)
const bookMeta = useBookStore.getState().books.find((b) => b.id === currentBookId)
@@ -237,6 +245,36 @@ export function EditorLayout(): React.JSX.Element {
setActiveVolume(vol.id)
}
const commitChapterRename = async (chapterId: string): Promise<void> => {
if (!currentBookId) return
const ch = chapters.find((c) => c.id === chapterId)
const title = renameDraft.trim()
setRenamingChapterId(null)
if (!ch || !title || title === ch.title) return
const updated = await ipcCall(() =>
window.electronAPI.chapter.update({ bookId: currentBookId, chapterId, title })
)
updateChapterLocal(updated)
}
const handleDeleteChapter = async (chapterId: string, e: React.MouseEvent): Promise<void> => {
e.stopPropagation()
if (!currentBookId) return
const ch = chapters.find((c) => c.id === chapterId)
if (!ch || !window.confirm(t('chapter.deleteConfirm', { title: ch.title }))) return
await flushEditorSave()
await ipcCall(() => window.electronAPI.chapter.delete(currentBookId, chapterId))
await refreshChapters()
if (selectedChapterId === chapterId) {
const remaining = chapters.filter((c) => c.id !== chapterId)
const next = remaining.find((c) => c.volumeId === ch.volumeId) ?? remaining[0]
if (next) {
setSelectedChapter(next.id)
await switchTarget({ kind: 'chapter', id: next.id })
}
}
}
const [dragOverChapterId, setDragOverChapterId] = useState<string | null>(null)
const handleChapterDropOnItem = async (
@@ -309,6 +347,12 @@ export function EditorLayout(): React.JSX.Element {
}
const saveStatus = saving ? t('editor.saving') : dirty ? t('editor.unsaved') : t('editor.saved')
const chapterOverThreshold =
currentChapter?.wordCountThreshold != null &&
wordCount.chapter > currentChapter.wordCountThreshold
const volumeMenuVolume = volumeMenu
? volumes.find((v) => v.id === volumeMenu.volumeId)
: null
return (
<div id="editor-layout" data-book-id={currentBookId ?? ''}>
@@ -337,6 +381,10 @@ export function EditorLayout(): React.JSX.Element {
className="vol-header"
data-volume-id={vol.id}
onClick={() => setActiveVolume(vol.id)}
onContextMenu={(e) => {
e.preventDefault()
setVolumeMenu({ volumeId: vol.id, x: e.clientX, y: e.clientY })
}}
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => {
e.preventDefault()
@@ -371,12 +419,44 @@ export function EditorLayout(): React.JSX.Element {
if (draggedId) void handleChapterDropOnItem(draggedId, vol.id, ch.id)
}}
onClick={() => void handleSelectChapter(ch.id)}
onDoubleClick={(e) => {
e.stopPropagation()
setRenamingChapterId(ch.id)
setRenameDraft(ch.title)
}}
onKeyDown={(e) => e.key === 'Enter' && void handleSelectChapter(ch.id)}
role="button"
tabIndex={0}
>
<span>{idx + 1}.</span>
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis' }}>{ch.title}</span>
{renamingChapterId === ch.id ? (
<input
className="chapter-rename-input"
data-testid={`chapter-rename-${ch.id}`}
value={renameDraft}
autoFocus
onClick={(e) => e.stopPropagation()}
onChange={(e) => setRenameDraft(e.target.value)}
onBlur={() => void commitChapterRename(ch.id)}
onKeyDown={(e) => {
if (e.key === 'Enter') void commitChapterRename(ch.id)
if (e.key === 'Escape') setRenamingChapterId(null)
}}
/>
) : (
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis' }}>
{ch.title}
</span>
)}
<button
type="button"
className="ch-delete-btn"
title={t('chapter.delete')}
data-testid={`chapter-delete-${ch.id}`}
onClick={(e) => void handleDeleteChapter(ch.id, e)}
>
×
</button>
<button
type="button"
className="ch-publish-btn"
@@ -560,7 +640,27 @@ export function EditorLayout(): React.JSX.Element {
))}
</select>
</label>
<span>{t('status.chapter')}: {t('editor.words', { count: wordCount.chapter })}</span>
<button
type="button"
className="btn btn-sm status-meta-btn"
data-testid="chapter-meta-open"
onClick={() => setChapterMetaOpen(true)}
>
{t('chapterMeta.open')}
</button>
{currentChapter?.publishStatus === 'published' && currentChapter.publishedAt && (
<span className="status-published-at" data-testid="status-published-at">
{t('chapterMeta.publishedAt', {
date: new Date(currentChapter.publishedAt).toLocaleString('zh-CN')
})}
</span>
)}
<span
className={chapterOverThreshold ? 'status-word-over-threshold' : undefined}
data-testid="status-chapter-words"
>
{t('status.chapter')}: {t('editor.words', { count: wordCount.chapter })}
</span>
<span>{t('status.volume')}: {t('editor.words', { count: wordCount.volume })}</span>
<span>{t('status.book')}: {t('editor.words', { count: wordCount.book })}</span>
</>
@@ -598,6 +698,22 @@ export function EditorLayout(): React.JSX.Element {
open={templateModalOpen}
onClose={() => setTemplateModalOpen(false)}
/>
<ChapterMetaPanel
open={chapterMetaOpen}
chapter={currentChapter}
onClose={() => setChapterMetaOpen(false)}
/>
{volumeMenu && volumeMenuVolume && currentBookId && (
<VolumeContextMenu
bookId={currentBookId}
volume={volumeMenuVolume}
hasChapters={chapters.some((c) => c.volumeId === volumeMenuVolume.id)}
x={volumeMenu.x}
y={volumeMenu.y}
onClose={() => setVolumeMenu(null)}
onChanged={refreshChapters}
/>
)}
<ExportModal />
</div>
)
@@ -7,6 +7,7 @@ import { useWizardStore } from '@renderer/stores/useWizardStore'
import { WordFreqPanel } from '@renderer/components/wordfreq/WordFreqPanel'
import { AiPanel } from '@renderer/components/ai/AiPanel'
import { KnowledgePanel } from '@renderer/components/knowledge/KnowledgePanel'
import { BookSettingsTab } from '@renderer/components/book/BookSettingsTab'
export function RightPanel(): React.JSX.Element {
const { t } = useTranslation()
@@ -54,7 +55,7 @@ export function RightPanel(): React.JSX.Element {
<WordFreqPanel />
</div>
<div className={`panel-content ${rightPanel === 'book-settings' ? 'active' : ''}`}>
<div className="placeholder-box">{t('feature.comingSoon')}</div>
<BookSettingsTab />
</div>
</div>
)
+38 -2
View File
@@ -1,18 +1,32 @@
import { useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { useAppStore } from '@renderer/stores/useAppStore'
import { useTabStore } from '@renderer/stores/useTabStore'
import { useBookStore } from '@renderer/stores/useBookStore'
import { useCockpitStore } from '@renderer/stores/useCockpitStore'
import { useWritingStatsStore } from '@renderer/stores/useWritingStatsStore'
import { useSettingsStore } from '@renderer/stores/useSettingsStore'
export function TopBar(): React.JSX.Element {
const { t } = useTranslation()
const setView = useAppStore((s) => s.setView)
const showToast = useAppStore((s) => s.showToast)
const focusMode = useAppStore((s) => s.focusMode)
const setFocusMode = useAppStore((s) => s.setFocusMode)
const { openTabs, activeTabId, switchTab, closeTab, openSettingsTab } = useTabStore()
const currentBookId = useBookStore((s) => s.currentBookId)
const openCockpit = useCockpitStore((s) => s.openModal)
const loadCockpitSummary = useCockpitStore((s) => s.loadSummary)
const activeVolumeId = useBookStore((s) => s.activeVolumeId)
const writingStats = useWritingStatsStore((s) => s.stats)
const dailyWordGoal = useSettingsStore((s) => s.dailyWordGoal)
const goalProgress =
dailyWordGoal > 0 && writingStats ? Math.min(writingStats.goalProgress, 1) : 0
const progressClass =
goalProgress >= 1 ? 'goal-met' : goalProgress >= 0.8 ? 'goal-near' : ''
useEffect(() => {
if (currentBookId) void useWritingStatsStore.getState().refresh(currentBookId)
}, [currentBookId])
const handleCloseTab = (tabId: string, e: React.MouseEvent): void => {
e.stopPropagation()
@@ -63,6 +77,21 @@ export function TopBar(): React.JSX.Element {
</button>
))}
</div>
{dailyWordGoal > 0 && currentBookId && (
<div
className={`top-daily-progress ${progressClass}`}
data-testid="top-daily-progress"
title={t('status.dailyGoal', {
current: writingStats?.todayWords ?? 0,
goal: dailyWordGoal
})}
>
<div
className="top-daily-progress-fill"
style={{ width: `${goalProgress * 100}%` }}
/>
</div>
)}
<div className="top-spacer" />
<button
type="button"
@@ -81,7 +110,14 @@ export function TopBar(): React.JSX.Element {
<button type="button" className="top-btn" onClick={() => { openSettingsTab(); setView('settings') }}>
</button>
<button type="button" className="top-btn" onClick={() => showToast(t('feature.comingSoon'))}>
<button
type="button"
className="top-btn"
title={t('focus.toggle')}
data-testid="topbar-focus-mode"
disabled={!currentBookId}
onClick={() => setFocusMode(!focusMode)}
>
👁
</button>
<button type="button" className="top-btn" onClick={() => window.electronAPI.window.minimize()}></button>
@@ -252,7 +252,7 @@ export function SettingsPage(): React.JSX.Element {
{section === 'shortcuts' && <ShortcutEditor />}
{section === 'about' && (
<p style={{ color: 'var(--text-secondary)', lineHeight: 1.8 }}>
{t('app.name')} v1.1.0
{t('app.name')} v1.2.0
<br />
{t('app.tagline')}
</p>
@@ -0,0 +1,82 @@
import { useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import type { Volume } from '@shared/types'
import { ipcCall } from '@renderer/lib/ipc-client'
import { useAppStore } from '@renderer/stores/useAppStore'
interface VolumeContextMenuProps {
bookId: string
volume: Volume
hasChapters: boolean
x: number
y: number
onClose: () => void
onChanged: () => Promise<void>
}
export function VolumeContextMenu({
bookId,
volume,
hasChapters,
x,
y,
onClose,
onChanged
}: VolumeContextMenuProps): React.JSX.Element {
const { t } = useTranslation()
const showToast = useAppStore((s) => s.showToast)
useEffect(() => {
const close = (): void => onClose()
window.addEventListener('click', close)
return () => window.removeEventListener('click', close)
}, [onClose])
const handleRename = async (): Promise<void> => {
const name = window.prompt(t('volume.renamePrompt'), volume.name)
onClose()
if (!name?.trim() || name.trim() === volume.name) return
await ipcCall(() => window.electronAPI.volume.update(bookId, volume.id, { name: name.trim() }))
await onChanged()
}
const handleDescription = async (): Promise<void> => {
const description = window.prompt(t('volume.descriptionPrompt'), volume.description ?? '')
onClose()
if (description === null) return
await ipcCall(() =>
window.electronAPI.volume.update(bookId, volume.id, { description: description.trim() })
)
await onChanged()
}
const handleDelete = async (): Promise<void> => {
onClose()
if (hasChapters) {
showToast(t('volume.deleteHasChapters'))
return
}
if (!window.confirm(t('volume.deleteConfirm', { name: volume.name }))) return
await ipcCall(() => window.electronAPI.volume.delete(bookId, volume.id))
await onChanged()
}
return (
<div
className="context-menu"
data-testid="volume-context-menu"
style={{ left: x, top: y }}
onClick={(e) => e.stopPropagation()}
>
<button type="button" onClick={() => void handleRename()}>
{t('volume.rename')}
</button>
<button type="button" onClick={() => void handleDescription()}>
{t('volume.editDescription')}
</button>
<button type="button" className="danger" onClick={() => void handleDelete()}>
{t('volume.delete')}
</button>
</div>
)
}
@@ -8,7 +8,7 @@ import { highlightTokenInEditor } from '@renderer/lib/editor-commands'
export function WordFreqPanel(): React.JSX.Element {
const { t } = useTranslation()
const showToast = useAppStore((s) => s.showToast)
const setNamingCheckOpen = useAppStore((s) => s.setNamingCheckOpen)
const bookId = useBookStore((s) => s.currentBookId)
const chapters = useBookStore((s) => s.chapters)
const target = useEditStore((s) => s.target)
@@ -54,7 +54,8 @@ export function WordFreqPanel(): React.JSX.Element {
type="button"
className="btn"
style={{ marginTop: 12 }}
onClick={() => showToast(t('feature.comingSoon'))}
data-testid="wordfreq-naming-check"
onClick={() => setNamingCheckOpen(true)}
>
{t('wordfreq.aiNaming')}
</button>
+11
View File
@@ -13,6 +13,7 @@ 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
let getEditorTextFn: (() => string) | null = null
export function registerInsertLandmark(fn: (payload: LandmarkInsert) => void): void {
insertLandmarkFn = fn
@@ -30,6 +31,16 @@ export function registerEditorInsert(
insertTextFn = insert
}
export function registerEditorGetText(fn: (() => string) | null): void {
getEditorTextFn = fn
}
export function getEditorTextForTts(): string {
const selection = window.getSelection()?.toString().trim()
if (selection) return selection
return getEditorTextFn?.() ?? ''
}
export function insertLandmarkInEditor(payload: LandmarkInsert): void {
insertLandmarkFn?.(payload)
}
+16
View File
@@ -0,0 +1,16 @@
export function speakText(text: string, lang = 'zh-CN'): void {
const trimmed = text.trim()
if (!trimmed) return
const u = new SpeechSynthesisUtterance(trimmed)
u.lang = lang
window.speechSynthesis.cancel()
window.speechSynthesis.speak(u)
}
export function stopSpeaking(): void {
window.speechSynthesis.cancel()
}
export function isSpeaking(): boolean {
return window.speechSynthesis.speaking
}
+6 -4
View File
@@ -19,7 +19,7 @@ interface AiStore {
listenersAttached: boolean
mount: () => void
unmount: () => void
loadSessions: (bookId: string) => Promise<void>
loadSessions: (bookId: string, includeArchived?: boolean) => Promise<void>
selectSession: (sessionId: string) => Promise<void>
createSession: (bookId: string) => Promise<void>
sendMessage: (content: string, prompt?: string) => Promise<void>
@@ -63,8 +63,10 @@ export const useAiStore = create<AiStore>((set, get) => ({
set({ listenersAttached: false })
},
loadSessions: async (bookId) => {
const sessions = await ipcCall(() => window.electronAPI.ai.sessionList(bookId))
loadSessions: async (bookId, includeArchived = true) => {
const sessions = await ipcCall(() =>
window.electronAPI.ai.sessionList(bookId, includeArchived)
)
const activeSessionId = get().activeSessionId
const stillActive = activeSessionId && sessions.some((s) => s.id === activeSessionId)
set({ sessions })
@@ -157,7 +159,7 @@ export const useAiStore = create<AiStore>((set, get) => ({
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))
const sessions = await ipcCall(() => window.electronAPI.ai.sessionList(bookId, true))
set({ messages, sessions, streamingMessageId: null, streamingBuffer: '' })
} finally {
set({ sending: false })
+8
View File
@@ -10,6 +10,8 @@ interface AppState {
landmarksModalOpen: boolean
inspirationModalOpen: boolean
importBookModalOpen: boolean
focusMode: boolean
namingCheckOpen: boolean
toast: string | null
setView: (view: AppView) => void
setSidebarPanel: (panel: AppState['sidebarPanel']) => void
@@ -18,6 +20,8 @@ interface AppState {
setLandmarksModalOpen: (open: boolean) => void
setInspirationModalOpen: (open: boolean) => void
setImportBookModalOpen: (open: boolean) => void
setFocusMode: (on: boolean) => void
setNamingCheckOpen: (open: boolean) => void
showToast: (message: string) => void
clearToast: () => void
}
@@ -30,6 +34,8 @@ export const useAppStore = create<AppState>((set) => ({
landmarksModalOpen: false,
inspirationModalOpen: false,
importBookModalOpen: false,
focusMode: false,
namingCheckOpen: false,
toast: null,
setView: (view) => set({ view }),
setSidebarPanel: (sidebarPanel) => set({ sidebarPanel }),
@@ -38,6 +44,8 @@ export const useAppStore = create<AppState>((set) => ({
setLandmarksModalOpen: (landmarksModalOpen) => set({ landmarksModalOpen }),
setInspirationModalOpen: (inspirationModalOpen) => set({ inspirationModalOpen }),
setImportBookModalOpen: (importBookModalOpen) => set({ importBookModalOpen }),
setFocusMode: (focusMode) => set({ focusMode }),
setNamingCheckOpen: (namingCheckOpen) => set({ namingCheckOpen }),
showToast: (toast) => {
set({ toast })
setTimeout(() => set({ toast: null }), 2500)
+28
View File
@@ -0,0 +1,28 @@
import { create } from 'zustand'
import { ipcCall } from '@renderer/lib/ipc-client'
interface BookPrefsState {
alwaysShowCockpit: boolean
loadedBookId: string | null
load: (bookId: string) => Promise<void>
setAlwaysShowCockpit: (bookId: string, value: boolean) => Promise<void>
}
export const useBookPrefsStore = create<BookPrefsState>((set, get) => ({
alwaysShowCockpit: false,
loadedBookId: null,
load: async (bookId) => {
const value = await ipcCall(() =>
window.electronAPI.bookPrefs.get(bookId, 'alwaysShowCockpit')
)
set({ alwaysShowCockpit: value === 'true', loadedBookId: bookId })
},
setAlwaysShowCockpit: async (bookId, value) => {
await ipcCall(() =>
window.electronAPI.bookPrefs.set(bookId, 'alwaysShowCockpit', value ? 'true' : 'false')
)
if (get().loadedBookId === bookId) {
set({ alwaysShowCockpit: value })
}
}
}))
+218
View File
@@ -67,6 +67,59 @@
-webkit-app-region: drag;
}
.top-daily-progress {
width: 72px;
height: 4px;
background: var(--bg-tertiary);
border-radius: 2px;
overflow: hidden;
flex-shrink: 0;
}
.top-daily-progress-fill {
height: 100%;
background: var(--accent);
transition: width 0.3s ease;
}
.top-daily-progress.goal-near .top-daily-progress-fill {
background: #e67e22;
}
.top-daily-progress.goal-met .top-daily-progress-fill {
background: var(--green);
}
#app.focus-mode #left-sidebar,
#app.focus-mode #right-panel,
#app.focus-mode #reference-panel.open {
display: none !important;
}
#app.focus-mode #topbar .tab-bar,
#app.focus-mode #topbar .top-btn:not([data-testid='topbar-focus-mode']) {
display: none;
}
.focus-mode-exit {
position: fixed;
top: 10px;
right: 10px;
z-index: 10001;
padding: 8px 14px;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: var(--bg-secondary);
color: var(--text-primary);
font-size: 12px;
cursor: pointer;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
}
.focus-mode-exit:hover {
background: var(--bg-hover);
}
.top-btn {
width: 32px;
height: 32px;
@@ -1070,6 +1123,171 @@
opacity: 1;
}
.ch-delete-btn {
background: none;
border: none;
cursor: pointer;
font-size: 14px;
line-height: 1;
padding: 0 4px;
opacity: 0;
color: var(--red, #c44);
}
.chapter-item:hover .ch-delete-btn {
opacity: 0.7;
}
.ch-delete-btn:hover {
opacity: 1;
}
.chapter-rename-input {
flex: 1;
min-width: 0;
font-size: 12px;
padding: 2px 4px;
border: 1px solid var(--accent);
border-radius: 4px;
background: var(--bg-primary);
color: var(--text-primary);
}
.context-menu {
position: fixed;
z-index: 10000;
min-width: 140px;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
padding: 4px;
}
.context-menu button {
display: block;
width: 100%;
text-align: left;
padding: 8px 12px;
border: none;
background: none;
color: var(--text-primary);
font-size: 12px;
cursor: pointer;
border-radius: 4px;
}
.context-menu button:hover {
background: var(--bg-hover);
}
.context-menu button.danger {
color: var(--red, #c44);
}
.chapter-meta-modal .modal-body {
display: flex;
flex-direction: column;
gap: 8px;
}
.chapter-meta-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.tag-chip {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: 12px;
background: var(--bg-tertiary);
font-size: 12px;
}
.tag-chip button {
border: none;
background: none;
cursor: pointer;
padding: 0;
line-height: 1;
}
.chapter-meta-tag-add {
display: flex;
gap: 8px;
align-items: center;
}
.chapter-meta-published,
.status-published-at {
font-size: 11px;
color: var(--text-secondary);
}
.status-word-over-threshold {
color: #e67e22;
font-weight: 600;
}
.status-meta-btn {
margin-left: 4px;
}
.ai-session-menu-wrap {
position: relative;
}
.ai-session-menu-btn {
padding: 4px 8px;
min-width: 32px;
}
.ai-session-menu {
right: 0;
left: auto;
top: 100%;
}
.ai-session-export-submenu {
padding-left: 8px;
}
.ai-session-export-submenu button {
font-size: 11px;
}
.book-settings-tab {
padding: 12px;
display: flex;
flex-direction: column;
gap: 10px;
}
.book-settings-cover-row {
display: flex;
align-items: center;
gap: 8px;
}
.book-settings-cover-path {
font-size: 12px;
color: var(--text-secondary);
}
.book-settings-danger {
margin-top: 16px;
padding-top: 12px;
border-top: 1px solid var(--border);
}
.btn-danger {
color: #fff;
background: var(--red, #c44);
}
.status-stock-warning {
color: var(--warning, #e6a23c);
margin-left: 12px;
+35
View File
@@ -1,3 +1,5 @@
import type { Chapter, OutlineItem, SettingEntry, Volume } from './types'
export interface TemplateContext {
chapterNumber: number
chapterTitle: string
@@ -20,6 +22,39 @@ const VARS = [
'characterList'
] as const
export function buildTemplateContext(input: {
chapterTitle: string
penName: string
volumes: Volume[]
chapters: Chapter[]
outlines: OutlineItem[]
settings: SettingEntry[]
activeVolumeId: string | null
selectedChapterId?: string | null
}): TemplateContext {
const volChapters = input.chapters
.filter((c) => c.volumeId === input.activeVolumeId)
.sort((a, b) => a.sortOrder - b.sortOrder)
const chapterNumber = volChapters.length + 1
const outlineItem =
input.outlines.find((o) => o.chapterId === input.selectedChapterId)?.title ?? ''
const characterList = input.settings
.filter((s) => s.type === 'character')
.slice(0, 8)
.map((s) => s.name)
.join('、')
return {
chapterNumber,
chapterTitle: input.chapterTitle,
penName: input.penName,
date: new Date().toLocaleDateString('zh-CN'),
volumeName: input.volumes.find((v) => v.id === input.activeVolumeId)?.name ?? '',
previousChapterTitle: volChapters.at(-1)?.title ?? '',
outlineItem,
characterList
}
}
export function replaceChapterTemplate(body: string, ctx: TemplateContext): string {
let out = body
for (const key of VARS) {
+18 -4
View File
@@ -32,6 +32,8 @@ import type {
Snapshot,
SnapshotType,
UpdateChapterParams,
UpdateBookMetaParams,
ChapterTag,
Volume,
WordFreqResult,
KnowledgeEntry,
@@ -73,10 +75,12 @@ export interface ElectronAPI {
create: (params: CreateBookParams) => Promise<IpcResult<BookMeta>>
delete: (bookId: string) => Promise<IpcResult<void>>
open: (bookId: string) => Promise<IpcResult<BookOpenResult>>
updateMeta: (
bookId: string,
patch: { lastChapterId?: string | null; status?: BookMeta['status'] }
) => Promise<IpcResult<BookMeta>>
updateMeta: (bookId: string, patch: UpdateBookMetaParams) => Promise<IpcResult<BookMeta>>
pickCover: (bookId: string) => Promise<IpcResult<BookMeta | null>>
}
bookPrefs: {
get: (bookId: string, key: string) => Promise<IpcResult<string | null>>
set: (bookId: string, key: string, value: string) => Promise<IpcResult<void>>
}
volume: {
create: (bookId: string, name: string) => Promise<IpcResult<Volume>>
@@ -105,6 +109,11 @@ export interface ElectronAPI {
publishStatus: PublishStatus
) => Promise<IpcResult<Chapter>>
}
chapterTag: {
list: (bookId: string, chapterId: string) => Promise<IpcResult<ChapterTag[]>>
set: (bookId: string, chapterId: string, tag: string, color?: string) => Promise<IpcResult<void>>
remove: (bookId: string, chapterId: string, tag: string) => Promise<IpcResult<void>>
}
outline: {
list: (bookId: string) => Promise<IpcResult<OutlineItem[]>>
create: (
@@ -221,6 +230,11 @@ export interface ElectronAPI {
patch: Partial<{ title: string; model: string; archived: boolean; context: AiContextBinding }>
) => Promise<IpcResult<AiSession>>
sessionDelete: (bookId: string, sessionId: string) => Promise<IpcResult<void>>
sessionExport: (
bookId: string,
sessionId: string,
format?: 'markdown' | 'txt'
) => Promise<IpcResult<string | null>>
messageList: (bookId: string, sessionId: string) => Promise<IpcResult<AiMessage[]>>
chat: (
bookId: string,
+8 -1
View File
@@ -6,6 +6,7 @@ export const IPC = {
BOOK_DELETE: 'book:delete',
BOOK_OPEN: 'book:open',
BOOK_UPDATE_META: 'book:updateMeta',
BOOK_PICK_COVER: 'book:pickCover',
VOLUME_CREATE: 'volume:create',
VOLUME_UPDATE: 'volume:update',
VOLUME_DELETE: 'volume:delete',
@@ -143,5 +144,11 @@ export const IPC = {
BRIDGE_GET: 'bridge:get',
BRIDGE_DISMISS: 'bridge:dismiss',
BRIDGE_SHOULD_PROMPT: 'bridge:shouldPrompt',
CHAPTER_SET_PUBLISH_STATUS: 'chapter:setPublishStatus'
CHAPTER_SET_PUBLISH_STATUS: 'chapter:setPublishStatus',
CHAPTER_TAG_LIST: 'chapter:tagList',
CHAPTER_TAG_SET: 'chapter:tagSet',
CHAPTER_TAG_REMOVE: 'chapter:tagRemove',
BOOK_PREFS_GET: 'book:prefsGet',
BOOK_PREFS_SET: 'book:prefsSet',
AI_SESSION_EXPORT: 'ai:sessionExport'
} as const
+26
View File
@@ -301,6 +301,25 @@ export interface BookMeta {
lastOpenedAt: string
lastChapterId: string | null
createdAt: string
coverPath?: string | null
synopsis?: string
}
export interface UpdateBookMetaParams {
name?: string
category?: string
targetWordCount?: number | null
coverPath?: string | null
synopsis?: string
lastChapterId?: string | null
lastOpenedAt?: string
status?: BookStatus
}
export interface ChapterTag {
chapterId: string
tag: string
color: string
}
export interface CreateBookParams {
@@ -331,6 +350,8 @@ export interface Chapter {
povCharacterId?: string | null
summary?: string
storyTime?: string | null
publishedAt?: string | null
wordCountThreshold?: number | null
}
export interface OutlineItem {
@@ -422,6 +443,11 @@ export interface UpdateChapterParams {
status?: ChapterStatus
cursorOffset?: number
povCharacterId?: string | null
summary?: string
storyTime?: string | null
wordCountThreshold?: number | null
publishStatus?: PublishStatus
publishedAt?: string | null
}
export type AiBackend = 'lmstudio' | 'openai' | 'anthropic'