feat: ship v0.6.0 with cockpit, knowledge base, and chapter bridge

Deliver P5 writer workflow: writing cockpit, manual knowledge CRUD with review, chapter bridge with optional AI, publish status, and stock buffer reminders.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-06 23:03:11 +08:00
parent 78f046890d
commit b33d2e7b34
45 changed files with 2389 additions and 29 deletions
+8 -1
View File
@@ -4,8 +4,9 @@ import schemaV2 from './schema-v2.sql?raw'
import schemaV3 from './schema-v3.sql?raw'
import schemaV4 from './schema-v4.sql?raw'
import schemaV5 from './schema-v5.sql?raw'
import schemaV6 from './schema-v6.sql?raw'
const CURRENT_VERSION = 5
const CURRENT_VERSION = 6
function tryAlter(db: SqliteDb, sql: string): void {
try {
@@ -64,5 +65,11 @@ export function migrate(db: SqliteDb): void {
tryAlter(db, "ALTER TABLE interactive_flows ADD COLUMN flow_mode TEXT NOT NULL DEFAULT 'interactive'")
tryAlter(db, "ALTER TABLE interactive_flows ADD COLUMN mode_config_json TEXT NOT NULL DEFAULT '{}'")
db.prepare('INSERT INTO schema_version (version, description) VALUES (?, ?)').run(5, 'P4.1 auto/wizard schema')
current = 5
}
if (current < 6) {
db.exec(schemaV6)
db.prepare('INSERT INTO schema_version (version, description) VALUES (?, ?)').run(6, 'P5 knowledge/cockpit schema')
}
}
@@ -0,0 +1,21 @@
import type { SqliteDb } from '../types'
export class BookPrefsRepository {
constructor(private db: SqliteDb) {}
get(key: string): string | null {
const row = this.db.prepare('SELECT value FROM book_preferences WHERE key = ?').get(key) as
| { value: string }
| undefined
return row?.value ?? null
}
set(key: string, value: string): void {
this.db
.prepare(
`INSERT INTO book_preferences (key, value) VALUES (?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value`
)
.run(key, value)
}
}
@@ -0,0 +1,21 @@
import type { SqliteDb } from '../types'
export class BridgeDismissRepository {
constructor(private db: SqliteDb) {}
isDismissed(chapterId: string): boolean {
const row = this.db
.prepare('SELECT chapter_id FROM chapter_bridge_dismiss WHERE chapter_id = ?')
.get(chapterId)
return Boolean(row)
}
dismiss(chapterId: string): void {
this.db
.prepare(
`INSERT INTO chapter_bridge_dismiss (chapter_id) VALUES (?)
ON CONFLICT(chapter_id) DO UPDATE SET dismissed_at = datetime('now')`
)
.run(chapterId)
}
}
+36 -4
View File
@@ -1,6 +1,6 @@
import { randomUUID } from 'crypto'
import { countWords } from '../../services/word-count'
import type { Chapter, ChapterOrigin, ChapterStatus } from '../../../shared/types'
import type { Chapter, ChapterOrigin, ChapterStatus, PublishStatus } from '../../../shared/types'
import type { SqliteDb } from '../types'
function mapRow(row: Record<string, unknown>): Chapter {
return {
@@ -12,7 +12,8 @@ function mapRow(row: Record<string, unknown>): Chapter {
wordCount: row.word_count as number,
sortOrder: row.sort_order as number,
cursorOffset: (row.cursor_offset as number) ?? 0,
origin: ((row.origin as ChapterOrigin) ?? 'manual') as ChapterOrigin
origin: ((row.origin as ChapterOrigin) ?? 'manual') as ChapterOrigin,
publishStatus: ((row.publish_status as PublishStatus) ?? 'draft') as PublishStatus
}
}
@@ -59,7 +60,13 @@ export class ChapterRepository {
update(
id: string,
patch: Partial<{ title: string; content: string; status: ChapterStatus; cursorOffset: number }>
patch: Partial<{
title: string
content: string
status: ChapterStatus
cursorOffset: number
publishStatus: PublishStatus
}>
): Chapter {
const existing = this.get(id)
if (!existing) throw new Error('Chapter not found')
@@ -71,7 +78,7 @@ export class ChapterRepository {
.prepare(
`UPDATE chapters SET
title = ?, content = ?, status = ?, cursor_offset = ?,
word_count = ?, updated_at = datetime('now')
publish_status = ?, word_count = ?, updated_at = datetime('now')
WHERE id = ?`
)
.run(
@@ -79,12 +86,37 @@ export class ChapterRepository {
content,
patch.status ?? existing.status,
patch.cursorOffset ?? existing.cursorOffset,
patch.publishStatus ?? existing.publishStatus ?? 'draft',
wordCount,
id
)
return this.get(id)!
}
setPublishStatus(id: string, publishStatus: PublishStatus): Chapter {
return this.update(id, { publishStatus })
}
countByPublishStatus(status: PublishStatus): number {
const row = this.db
.prepare('SELECT COUNT(*) AS c FROM chapters WHERE publish_status = ?')
.get(status) as { c: number }
return row.c
}
getPreviousChapter(chapterId: string): Chapter | null {
const current = this.get(chapterId)
if (!current) return null
const row = this.db
.prepare(
`SELECT * FROM chapters
WHERE sort_order < ?
ORDER BY sort_order DESC LIMIT 1`
)
.get(current.sortOrder) as Record<string, unknown> | undefined
return row ? mapRow(row) : null
}
delete(id: string): void {
this.db.prepare('DELETE FROM chapters WHERE id = ?').run(id)
}
+114
View File
@@ -0,0 +1,114 @@
import { randomUUID } from 'crypto'
import type {
CreateKnowledgeInput,
ForeshadowStatus,
KnowledgeEntry,
KnowledgeStatus,
KnowledgeType
} from '../../../shared/types'
import type { SqliteDb } from '../types'
function mapRow(row: Record<string, unknown>): KnowledgeEntry {
return {
id: row.id as string,
type: row.type as KnowledgeType,
title: row.title as string,
content: row.content as string,
importance: row.importance as number,
status: row.status as KnowledgeStatus,
foreshadowStatus: (row.foreshadow_status as ForeshadowStatus) || undefined,
sourceChapterId: (row.source_chapter_id as string) || undefined,
lastMentionChapterId: (row.last_mention_chapter_id as string) || undefined,
linkedBookmarkId: (row.linked_bookmark_id as string) || undefined,
createdAt: row.created_at as string,
updatedAt: row.updated_at as string
}
}
export class KnowledgeRepository {
constructor(private db: SqliteDb) {}
create(input: CreateKnowledgeInput): KnowledgeEntry {
const id = randomUUID()
const status = input.status ?? 'pending'
this.db
.prepare(
`INSERT INTO knowledge_entries (
id, type, title, content, importance, status, foreshadow_status,
source_chapter_id, last_mention_chapter_id, linked_bookmark_id
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
)
.run(
id,
input.type,
input.title,
input.content ?? '',
input.importance ?? 3,
status,
input.foreshadowStatus ?? null,
input.sourceChapterId ?? null,
input.lastMentionChapterId ?? null,
input.linkedBookmarkId ?? null
)
return this.get(id)!
}
get(id: string): KnowledgeEntry | null {
const row = this.db.prepare('SELECT * FROM knowledge_entries WHERE id = ?').get(id) as
| Record<string, unknown>
| undefined
return row ? mapRow(row) : null
}
list(filter?: { status?: KnowledgeStatus; type?: KnowledgeType }): KnowledgeEntry[] {
let sql = 'SELECT * FROM knowledge_entries WHERE 1=1'
const params: unknown[] = []
if (filter?.status) {
sql += ' AND status = ?'
params.push(filter.status)
}
if (filter?.type) {
sql += ' AND type = ?'
params.push(filter.type)
}
sql += ' ORDER BY updated_at DESC'
return (this.db.prepare(sql).all(...params) as Record<string, unknown>[]).map(mapRow)
}
update(id: string, patch: Partial<CreateKnowledgeInput & { status: KnowledgeStatus }>): KnowledgeEntry {
const existing = this.get(id)
if (!existing) throw new Error('Knowledge entry not found')
this.db
.prepare(
`UPDATE knowledge_entries SET
type = ?, title = ?, content = ?, importance = ?, status = ?,
foreshadow_status = ?, source_chapter_id = ?, last_mention_chapter_id = ?,
linked_bookmark_id = ?, updated_at = datetime('now')
WHERE id = ?`
)
.run(
patch.type ?? existing.type,
patch.title ?? existing.title,
patch.content ?? existing.content,
patch.importance ?? existing.importance,
patch.status ?? existing.status,
patch.foreshadowStatus ?? existing.foreshadowStatus ?? null,
patch.sourceChapterId ?? existing.sourceChapterId ?? null,
patch.lastMentionChapterId ?? existing.lastMentionChapterId ?? null,
patch.linkedBookmarkId ?? existing.linkedBookmarkId ?? null,
id
)
return this.get(id)!
}
delete(id: string): void {
this.db.prepare('DELETE FROM knowledge_entries WHERE id = ?').run(id)
}
countByStatus(status: KnowledgeStatus): number {
const row = this.db
.prepare('SELECT COUNT(*) AS c FROM knowledge_entries WHERE status = ?')
.get(status) as { c: number }
return row.c
}
}
+30
View File
@@ -0,0 +1,30 @@
CREATE TABLE IF NOT EXISTS knowledge_entries (
id TEXT PRIMARY KEY,
type TEXT NOT NULL,
title TEXT NOT NULL,
content TEXT NOT NULL DEFAULT '',
importance INTEGER NOT NULL DEFAULT 3,
status TEXT NOT NULL DEFAULT 'pending',
foreshadow_status TEXT,
source_chapter_id TEXT,
last_mention_chapter_id TEXT,
linked_bookmark_id TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (source_chapter_id) REFERENCES chapters(id) ON DELETE SET NULL,
FOREIGN KEY (last_mention_chapter_id) REFERENCES chapters(id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS idx_knowledge_status ON knowledge_entries(status);
CREATE INDEX IF NOT EXISTS idx_knowledge_type ON knowledge_entries(type);
CREATE TABLE IF NOT EXISTS book_preferences (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS chapter_bridge_dismiss (
chapter_id TEXT PRIMARY KEY,
dismissed_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (chapter_id) REFERENCES chapters(id) ON DELETE CASCADE
);
+35
View File
@@ -0,0 +1,35 @@
import { ipcMain } from 'electron'
import { IPC } from '../../../shared/ipc-channels'
import { BookRegistryService } from '../../services/book-registry'
import { ChapterBridgeService } from '../../services/chapter-bridge.service'
import { AiClientService } from '../../services/ai-client.service'
import { wrap } from '../result'
export function registerBridgeHandlers(
registry: BookRegistryService,
aiClient: AiClientService
): void {
ipcMain.handle(
IPC.BRIDGE_GET,
(
_e,
{ bookId, chapterId, withAi }: { bookId: string; chapterId: string; withAi?: boolean }
) =>
wrap(() =>
new ChapterBridgeService(registry.getDb(bookId), aiClient).getBridge(
chapterId,
withAi ?? false
)
)
)
ipcMain.handle(IPC.BRIDGE_DISMISS, (_e, { bookId, chapterId }: { bookId: string; chapterId: string }) =>
wrap(() => new ChapterBridgeService(registry.getDb(bookId)).dismiss(chapterId))
)
ipcMain.handle(
IPC.BRIDGE_SHOULD_PROMPT,
(_e, { bookId, chapterId }: { bookId: string; chapterId: string }) =>
wrap(() => new ChapterBridgeService(registry.getDb(bookId)).shouldPrompt(chapterId))
)
}
+9 -1
View File
@@ -1,6 +1,6 @@
import { ipcMain } from 'electron'
import { IPC } from '../../../shared/ipc-channels'
import type { ChapterStatus } from '../../../shared/types'
import type { ChapterStatus, PublishStatus } from '../../../shared/types'
import { BookRegistryService } from '../../services/book-registry'
import { ftsSync } from '../../services/fts-sync.service'
import { wrap } from '../result'
@@ -124,4 +124,12 @@ export function registerChapterHandlers(registry: BookRegistryService): void {
) =>
wrap(() => registry.getChapterRepo(bookId).moveToVolume(chapterId, targetVolumeId, targetIndex))
)
ipcMain.handle(
IPC.CHAPTER_SET_PUBLISH_STATUS,
(
_event,
{ bookId, chapterId, publishStatus }: { bookId: string; chapterId: string; publishStatus: PublishStatus }
) => wrap(() => registry.getChapterRepo(bookId).setPublishStatus(chapterId, publishStatus))
)
}
+27
View File
@@ -0,0 +1,27 @@
import { ipcMain } from 'electron'
import { IPC } from '../../../shared/ipc-channels'
import { BookRegistryService } from '../../services/book-registry'
import { CockpitService } from '../../services/cockpit.service'
import type { GlobalSettingsService } from '../../services/global-settings'
import { wrap } from '../result'
export function registerCockpitHandlers(
registry: BookRegistryService,
settings: GlobalSettingsService
): void {
ipcMain.handle(
IPC.COCKPIT_SUMMARY,
(_e, { bookId, volumeId }: { bookId: string; volumeId?: string }) =>
wrap(() => new CockpitService(registry.getDb(bookId), settings).getSummary(volumeId))
)
ipcMain.handle(IPC.COCKPIT_MARK_SEEN, (_e, { bookId }: { bookId: string }) =>
wrap(() => {
new CockpitService(registry.getDb(bookId), settings).markSeen()
})
)
ipcMain.handle(IPC.COCKPIT_SHOULD_SHOW, (_e, { bookId }: { bookId: string }) =>
wrap(() => new CockpitService(registry.getDb(bookId), settings).shouldShowOnOpen())
)
}
@@ -0,0 +1,68 @@
import { ipcMain } from 'electron'
import { IPC } from '../../../shared/ipc-channels'
import type { CreateKnowledgeInput, KnowledgeStatus, KnowledgeType } from '../../../shared/types'
import { BookRegistryService } from '../../services/book-registry'
import { KnowledgeService } from '../../services/knowledge.service'
import { wrap } from '../result'
export function registerKnowledgeHandlers(registry: BookRegistryService): void {
ipcMain.handle(
IPC.KNOWLEDGE_LIST,
(
_e,
{
bookId,
filter
}: {
bookId: string
filter?: { status?: KnowledgeStatus; type?: KnowledgeType; forgottenOnly?: boolean }
}
) => wrap(() => new KnowledgeService(registry.getDb(bookId)).list(filter))
)
ipcMain.handle(
IPC.KNOWLEDGE_CREATE,
(_e, { bookId, input }: { bookId: string; input: CreateKnowledgeInput }) =>
wrap(() => new KnowledgeService(registry.getDb(bookId)).create(input))
)
ipcMain.handle(
IPC.KNOWLEDGE_UPDATE,
(
_e,
{
bookId,
id,
patch
}: { bookId: string; id: string; patch: Partial<CreateKnowledgeInput & { status: KnowledgeStatus }> }
) => wrap(() => new KnowledgeService(registry.getDb(bookId)).update(id, patch))
)
ipcMain.handle(IPC.KNOWLEDGE_DELETE, (_e, { bookId, id }: { bookId: string; id: string }) =>
wrap(() => new KnowledgeService(registry.getDb(bookId)).delete(id))
)
ipcMain.handle(IPC.KNOWLEDGE_APPROVE, (_e, { bookId, id }: { bookId: string; id: string }) =>
wrap(() => new KnowledgeService(registry.getDb(bookId)).approve(id))
)
ipcMain.handle(IPC.KNOWLEDGE_REJECT, (_e, { bookId, id }: { bookId: string; id: string }) =>
wrap(() => new KnowledgeService(registry.getDb(bookId)).reject(id))
)
ipcMain.handle(
IPC.KNOWLEDGE_BATCH_APPROVE,
(_e, { bookId, ids }: { bookId: string; ids: string[] }) =>
wrap(() => new KnowledgeService(registry.getDb(bookId)).batchApprove(ids))
)
ipcMain.handle(
IPC.KNOWLEDGE_CREATE_FROM_BOOKMARK,
(_e, { bookId, bookmarkId }: { bookId: string; bookmarkId: string }) =>
wrap(() => new KnowledgeService(registry.getDb(bookId)).createFromBookmark(bookmarkId))
)
ipcMain.handle(IPC.KNOWLEDGE_STATS, (_e, { bookId }: { bookId: string }) =>
wrap(() => new KnowledgeService(registry.getDb(bookId)).getForeshadowStats())
)
}
+6
View File
@@ -18,6 +18,9 @@ import { registerAiHandlers } from './handlers/ai.handler'
import { registerInteractiveHandlers } from './handlers/interactive.handler'
import { registerAutoHandlers } from './handlers/auto.handler'
import { registerWizardHandlers } from './handlers/wizard.handler'
import { registerKnowledgeHandlers } from './handlers/knowledge.handler'
import { registerCockpitHandlers } from './handlers/cockpit.handler'
import { registerBridgeHandlers } from './handlers/bridge.handler'
import { AiClientService } from '../services/ai-client.service'
import { NetworkMonitorService } from '../services/network-monitor.service'
@@ -49,6 +52,9 @@ export function registerIpc(): { settings: GlobalSettingsService; books: BookReg
registerInteractiveHandlers(books, settings, aiClient)
registerAutoHandlers(books, settings, aiClient)
registerWizardHandlers(books, settings, aiClient)
registerKnowledgeHandlers(books)
registerCockpitHandlers(books, settings)
registerBridgeHandlers(books, aiClient)
return { settings, books, shortcuts: shortcutManager }
}
@@ -0,0 +1,59 @@
import type { ChapterBridgeResult } from '../../shared/types'
import type { SqliteDb } from '../db/types'
import { BridgeDismissRepository } from '../db/repositories/bridge-dismiss.repo'
import { ChapterRepository } from '../db/repositories/chapter.repo'
import type { AiClientService } from './ai-client.service'
import { stripHtml } from './word-count'
export class ChapterBridgeService {
private chapterRepo: ChapterRepository
private dismissRepo: BridgeDismissRepository
constructor(
private db: SqliteDb,
private aiClient?: AiClientService
) {
this.chapterRepo = new ChapterRepository(db)
this.dismissRepo = new BridgeDismissRepository(db)
}
shouldPrompt(chapterId: string): boolean {
if (this.dismissRepo.isDismissed(chapterId)) return false
const ch = this.chapterRepo.get(chapterId)
if (!ch || stripHtml(ch.content).length >= 200) return false
return this.chapterRepo.getPreviousChapter(chapterId) !== null
}
extract(chapterId: string): ChapterBridgeResult {
const prev = this.chapterRepo.getPreviousChapter(chapterId)
const cur = this.chapterRepo.get(chapterId)
if (!cur) throw new Error('Chapter not found')
const prevText = prev ? stripHtml(prev.content) : ''
const curText = stripHtml(cur.content)
return {
previousChapterId: prev?.id ?? null,
previousTail: prevText.slice(-300),
currentHead: curText.slice(0, 200)
}
}
async getBridge(chapterId: string, withAi: boolean): Promise<ChapterBridgeResult> {
const base = this.extract(chapterId)
if (!withAi || !this.aiClient || !base.previousTail) return base
try {
const suggestion = await this.aiClient.chat([
{
role: 'user',
content: `你是连载小说编辑。上一章末尾:「${base.previousTail}」。本章开头:「${base.currentHead || '(尚未撰写)'}」。用 2-3 句话指出衔接是否顺畅,并给出一个开头方向建议。不要续写正文。`
}
])
return { ...base, aiSuggestion: suggestion.trim() }
} catch {
return base
}
}
dismiss(chapterId: string): void {
this.dismissRepo.dismiss(chapterId)
}
}
+44
View File
@@ -0,0 +1,44 @@
import type { CockpitSummary } from '../../shared/types'
import type { SqliteDb } from '../db/types'
import { BookPrefsRepository } from '../db/repositories/book-prefs.repo'
import { ChapterRepository } from '../db/repositories/chapter.repo'
import type { GlobalSettingsService } from './global-settings'
import { KnowledgeService } from './knowledge.service'
import { stripHtml } from './word-count'
export class CockpitService {
constructor(
private db: SqliteDb,
private settings: GlobalSettingsService
) {}
getSummary(activeVolumeId?: string): CockpitSummary {
const chapters = new ChapterRepository(this.db).list()
const totalWords = chapters.reduce((s, c) => s + stripHtml(c.content).length, 0)
const volumeWords = activeVolumeId
? chapters
.filter((c) => c.volumeId === activeVolumeId)
.reduce((s, c) => s + stripHtml(c.content).length, 0)
: totalWords
const stockReadyCount = new ChapterRepository(this.db).countByPublishStatus('ready')
const stats = new KnowledgeService(this.db).getForeshadowStats()
const { stockBufferThreshold } = this.settings.get()
return {
totalWords,
volumeWords,
stockReadyCount,
stockThreshold: stockBufferThreshold,
foreshadowBuried: stats.buried,
foreshadowResolved: stats.resolved,
foreshadowForgotten: stats.forgotten
}
}
shouldShowOnOpen(): boolean {
return new BookPrefsRepository(this.db).get('cockpitSeen') !== 'true'
}
markSeen(): void {
new BookPrefsRepository(this.db).set('cockpitSeen', 'true')
}
}
+4
View File
@@ -20,6 +20,8 @@ function defaults(): GlobalSettings {
theme: 'default',
language: 'zh-CN',
dailyWordGoal: 0,
updateSchedule: 'none',
stockBufferThreshold: 3,
shortcuts: { ...DEFAULT_SHORTCUTS },
snapshotIntervalMs: 300_000,
snapshotMaxAuto: 20,
@@ -45,6 +47,8 @@ export class GlobalSettingsService {
return {
...defaults(),
...raw,
updateSchedule: raw.updateSchedule ?? 'none',
stockBufferThreshold: raw.stockBufferThreshold ?? 3,
shortcuts: { ...DEFAULT_SHORTCUTS, ...raw.shortcuts },
aiConfig: { ...DEFAULT_AI_CONFIG, ...raw.aiConfig }
}
+117
View File
@@ -0,0 +1,117 @@
import type {
CreateKnowledgeInput,
ForeshadowStatus,
KnowledgeEntry,
KnowledgeStatus,
KnowledgeType
} from '../../shared/types'
import type { SqliteDb } from '../db/types'
import { BookmarkRepository } from '../db/repositories/bookmark.repo'
import { ChapterRepository } from '../db/repositories/chapter.repo'
import { KnowledgeRepository } from '../db/repositories/knowledge.repo'
const FORGOTTEN_GAP = 20
export class KnowledgeService {
private repo: KnowledgeRepository
private chapterRepo: ChapterRepository
private bookmarkRepo: BookmarkRepository
constructor(private db: SqliteDb) {
this.repo = new KnowledgeRepository(db)
this.chapterRepo = new ChapterRepository(db)
this.bookmarkRepo = new BookmarkRepository(db)
}
list(filter?: {
status?: KnowledgeStatus
type?: KnowledgeType
forgottenOnly?: boolean
}): KnowledgeEntry[] {
let entries = this.repo.list({ status: filter?.status, type: filter?.type })
if (filter?.forgottenOnly) {
entries = entries.filter((e) => this.isForgotten(e))
}
return entries
}
create(input: CreateKnowledgeInput): KnowledgeEntry {
const foreshadowStatus =
input.type === 'foreshadow' ? (input.foreshadowStatus ?? 'buried') : input.foreshadowStatus
return this.repo.create({ ...input, foreshadowStatus })
}
update(id: string, patch: Partial<CreateKnowledgeInput & { status: KnowledgeStatus }>): KnowledgeEntry {
return this.repo.update(id, patch)
}
delete(id: string): void {
this.repo.delete(id)
}
approve(id: string): KnowledgeEntry {
return this.repo.update(id, { status: 'approved' })
}
reject(id: string): KnowledgeEntry {
return this.repo.update(id, { status: 'rejected' })
}
batchApprove(ids: string[]): number {
let count = 0
for (const id of ids) {
const entry = this.repo.get(id)
if (entry?.status === 'pending') {
this.repo.update(id, { status: 'approved' })
count++
}
}
return count
}
createFromBookmark(bookmarkId: string): KnowledgeEntry {
const bm = this.bookmarkRepo.get(bookmarkId)
if (!bm) throw new Error('Bookmark not found')
return this.repo.create({
type: 'foreshadow',
title: bm.label,
content: bm.label,
status: 'pending',
foreshadowStatus: 'buried',
sourceChapterId: bm.chapterId,
linkedBookmarkId: bm.id
})
}
countPending(): number {
return this.repo.countByStatus('pending')
}
getForeshadowStats(): { buried: number; resolved: number; forgotten: number } {
const approved = this.repo.list({ type: 'foreshadow', status: 'approved' })
let buried = 0
let resolved = 0
let forgotten = 0
for (const entry of approved) {
const fs = entry.foreshadowStatus ?? 'buried'
if (fs === 'resolved') resolved++
else if (fs === 'buried') {
buried++
if (this.isForgotten(entry)) forgotten++
} else buried++
}
return { buried, resolved, forgotten }
}
isForgotten(entry: KnowledgeEntry): boolean {
if (entry.type !== 'foreshadow' || entry.status !== 'approved') return false
if ((entry.foreshadowStatus ?? 'buried') !== 'buried') return false
const mentionId = entry.lastMentionChapterId ?? entry.sourceChapterId
if (!mentionId) return false
const mention = this.chapterRepo.get(mentionId)
if (!mention) return false
const all = this.chapterRepo.list()
const latestOrder = Math.max(...all.map((c) => c.sortOrder), 0)
return latestOrder - mention.sortOrder >= FORGOTTEN_GAP
}
}
+64 -2
View File
@@ -35,7 +35,14 @@ import type {
SnapshotType,
UpdateChapterParams,
Volume,
WordFreqResult
WordFreqResult,
KnowledgeEntry,
CreateKnowledgeInput,
KnowledgeStatus,
KnowledgeType,
CockpitSummary,
ChapterBridgeResult,
PublishStatus
} from '../shared/types'
const electronAPI = {
@@ -91,7 +98,13 @@ const electronAPI = {
targetVolumeId: string,
targetIndex: number
): Promise<IpcResult<Chapter>> =>
ipcRenderer.invoke(IPC.CHAPTER_MOVE_TO_VOLUME, { bookId, chapterId, targetVolumeId, targetIndex })
ipcRenderer.invoke(IPC.CHAPTER_MOVE_TO_VOLUME, { bookId, chapterId, targetVolumeId, targetIndex }),
setPublishStatus: (
bookId: string,
chapterId: string,
publishStatus: PublishStatus
): Promise<IpcResult<Chapter>> =>
ipcRenderer.invoke(IPC.CHAPTER_SET_PUBLISH_STATUS, { bookId, chapterId, publishStatus })
},
outline: {
list: (bookId: string): Promise<IpcResult<OutlineItem[]>> =>
@@ -408,6 +421,55 @@ const electronAPI = {
abort: (flowId: string): Promise<IpcResult<void>> =>
ipcRenderer.invoke(IPC.WIZARD_ABORT, { flowId })
},
knowledge: {
list: (
bookId: string,
filter?: { status?: KnowledgeStatus; type?: KnowledgeType; forgottenOnly?: boolean }
): Promise<IpcResult<KnowledgeEntry[]>> =>
ipcRenderer.invoke(IPC.KNOWLEDGE_LIST, { bookId, filter }),
create: (bookId: string, input: CreateKnowledgeInput): Promise<IpcResult<KnowledgeEntry>> =>
ipcRenderer.invoke(IPC.KNOWLEDGE_CREATE, { bookId, input }),
update: (
bookId: string,
id: string,
patch: Partial<CreateKnowledgeInput & { status: KnowledgeStatus }>
): Promise<IpcResult<KnowledgeEntry>> =>
ipcRenderer.invoke(IPC.KNOWLEDGE_UPDATE, { bookId, id, patch }),
delete: (bookId: string, id: string): Promise<IpcResult<void>> =>
ipcRenderer.invoke(IPC.KNOWLEDGE_DELETE, { bookId, id }),
approve: (bookId: string, id: string): Promise<IpcResult<KnowledgeEntry>> =>
ipcRenderer.invoke(IPC.KNOWLEDGE_APPROVE, { bookId, id }),
reject: (bookId: string, id: string): Promise<IpcResult<KnowledgeEntry>> =>
ipcRenderer.invoke(IPC.KNOWLEDGE_REJECT, { bookId, id }),
batchApprove: (bookId: string, ids: string[]): Promise<IpcResult<number>> =>
ipcRenderer.invoke(IPC.KNOWLEDGE_BATCH_APPROVE, { bookId, ids }),
createFromBookmark: (bookId: string, bookmarkId: string): Promise<IpcResult<KnowledgeEntry>> =>
ipcRenderer.invoke(IPC.KNOWLEDGE_CREATE_FROM_BOOKMARK, { bookId, bookmarkId }),
stats: (
bookId: string
): Promise<IpcResult<{ buried: number; resolved: number; forgotten: number }>> =>
ipcRenderer.invoke(IPC.KNOWLEDGE_STATS, { bookId })
},
cockpit: {
getSummary: (bookId: string, volumeId?: string): Promise<IpcResult<CockpitSummary>> =>
ipcRenderer.invoke(IPC.COCKPIT_SUMMARY, { bookId, volumeId }),
markSeen: (bookId: string): Promise<IpcResult<void>> =>
ipcRenderer.invoke(IPC.COCKPIT_MARK_SEEN, { bookId }),
shouldShowOnOpen: (bookId: string): Promise<IpcResult<boolean>> =>
ipcRenderer.invoke(IPC.COCKPIT_SHOULD_SHOW, { bookId })
},
bridge: {
get: (
bookId: string,
chapterId: string,
withAi?: boolean
): Promise<IpcResult<ChapterBridgeResult>> =>
ipcRenderer.invoke(IPC.BRIDGE_GET, { bookId, chapterId, withAi }),
dismiss: (bookId: string, chapterId: string): Promise<IpcResult<void>> =>
ipcRenderer.invoke(IPC.BRIDGE_DISMISS, { bookId, chapterId }),
shouldPrompt: (bookId: string, chapterId: string): Promise<IpcResult<boolean>> =>
ipcRenderer.invoke(IPC.BRIDGE_SHOULD_PROMPT, { bookId, chapterId })
},
wordfreq: {
analyze: (
bookId: string,
@@ -0,0 +1,98 @@
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import type { ChapterBridgeResult } from '@shared/types'
import { useBookStore } from '@renderer/stores/useBookStore'
import { ipcCall } from '@renderer/lib/ipc-client'
interface ChapterBridgeModalProps {
open: boolean
chapterId: string | null
onClose: () => void
}
export function ChapterBridgeModal({
open,
chapterId,
onClose
}: ChapterBridgeModalProps): React.JSX.Element | null {
const { t } = useTranslation()
const bookId = useBookStore((s) => s.currentBookId)
const [result, setResult] = useState<ChapterBridgeResult | null>(null)
const [loading, setLoading] = useState(false)
const [dismiss, setDismiss] = useState(false)
useEffect(() => {
if (!open || !bookId || !chapterId) return
setLoading(true)
setResult(null)
void ipcCall(() => window.electronAPI.bridge.get(bookId, chapterId, true))
.then(setResult)
.finally(() => setLoading(false))
}, [open, bookId, chapterId])
const handleClose = async (): Promise<void> => {
if (dismiss && bookId && chapterId) {
await ipcCall(() => window.electronAPI.bridge.dismiss(bookId, chapterId))
}
setDismiss(false)
onClose()
}
if (!open) return null
return (
<div className="modal-overlay" data-testid="bridge-modal">
<div className="modal-content modal-content--wide">
<div className="modal-header">
<h3>{t('bridge.title')}</h3>
<button type="button" className="modal-close" onClick={() => void handleClose()}>
</button>
</div>
<div className="modal-body bridge-body">
{loading ? (
<div className="sidebar-empty">{t('bridge.loading')}</div>
) : result ? (
<>
<div className="bridge-section">
<h4>{t('bridge.previousTail')}</h4>
<p data-testid="bridge-previous-tail">{result.previousTail || t('bridge.none')}</p>
</div>
<div className="bridge-section">
<h4>{t('bridge.currentHead')}</h4>
<p data-testid="bridge-current-head">{result.currentHead || t('bridge.none')}</p>
</div>
{result.aiSuggestion && (
<div className="bridge-section">
<h4>{t('bridge.aiSuggestion')}</h4>
<p data-testid="bridge-ai-suggestion">{result.aiSuggestion}</p>
</div>
)}
</>
) : (
<div className="sidebar-empty">{t('bridge.empty')}</div>
)}
<label className="bridge-dismiss">
<input
type="checkbox"
data-testid="bridge-dismiss-checkbox"
checked={dismiss}
onChange={(e) => setDismiss(e.target.checked)}
/>
{t('bridge.dismissLabel')}
</label>
</div>
<div className="modal-footer">
<button
type="button"
className="btn btn-primary"
data-testid="bridge-start-writing"
onClick={() => void handleClose()}
>
{t('bridge.startWriting')}
</button>
</div>
</div>
</div>
)
}
@@ -0,0 +1,120 @@
import { useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { useBookStore } from '@renderer/stores/useBookStore'
import { useEditStore } from '@renderer/stores/useEditStore'
import { useCockpitStore } from '@renderer/stores/useCockpitStore'
import { useKnowledgeStore } from '@renderer/stores/useKnowledgeStore'
interface CockpitModalProps {
onOpenBridge: (chapterId: string) => void
}
export function CockpitModal({ onOpenBridge }: CockpitModalProps): React.JSX.Element | null {
const { t } = useTranslation()
const bookId = useBookStore((s) => s.currentBookId)
const activeVolumeId = useBookStore((s) => s.activeVolumeId)
const selectedChapterId = useBookStore((s) => s.selectedChapterId)
const setSelectedChapter = useBookStore((s) => s.setSelectedChapter)
const switchTarget = useEditStore((s) => s.switchTarget)
const { open, summary, loading, close, loadSummary } = useCockpitStore()
const openForgottenFilter = useKnowledgeStore((s) => s.openForgottenFilter)
useEffect(() => {
if (!open || !bookId) return
void loadSummary(bookId, activeVolumeId ?? undefined)
}, [open, bookId, activeVolumeId, loadSummary])
if (!open) return null
const handleResume = async (): Promise<void> => {
if (selectedChapterId) {
setSelectedChapter(selectedChapterId)
await switchTarget({ kind: 'chapter', id: selectedChapterId })
}
close()
}
const handleBridge = (): void => {
const chapterId = selectedChapterId
if (chapterId) {
close()
onOpenBridge(chapterId)
}
}
const handleForgotten = (): void => {
openForgottenFilter()
close()
}
return (
<div className="modal-overlay" data-testid="cockpit-modal">
<div className="modal-content modal-content--wide">
<div className="modal-header">
<h3>{t('cockpit.title')}</h3>
<button type="button" className="modal-close" onClick={close}>
</button>
</div>
<div className="cockpit-body">
{loading || !summary ? (
<div className="sidebar-empty">{t('cockpit.loading')}</div>
) : (
<div className="cockpit-grid">
<div className="cockpit-card" data-testid="cockpit-total-words">
<div className="cockpit-card-label">{t('cockpit.totalWords')}</div>
<div className="cockpit-card-value">{summary.totalWords.toLocaleString()}</div>
</div>
<div className="cockpit-card" data-testid="cockpit-volume-words">
<div className="cockpit-card-label">{t('cockpit.volumeWords')}</div>
<div className="cockpit-card-value">{summary.volumeWords.toLocaleString()}</div>
</div>
<div className="cockpit-card" data-testid="cockpit-stock-count">
<div className="cockpit-card-label">{t('cockpit.stockReady')}</div>
<div className="cockpit-card-value">
{summary.stockReadyCount} / {summary.stockThreshold}
</div>
</div>
<div className="cockpit-card" data-testid="cockpit-foreshadow-summary">
<div className="cockpit-card-label">{t('cockpit.foreshadow')}</div>
<div className="cockpit-card-value">
{t('cockpit.foreshadowDetail', {
buried: summary.foreshadowBuried,
resolved: summary.foreshadowResolved,
forgotten: summary.foreshadowForgotten
})}
</div>
</div>
</div>
)}
</div>
<div className="modal-footer cockpit-actions">
<button
type="button"
className="btn btn-primary"
data-testid="cockpit-resume-edit"
onClick={() => void handleResume()}
>
{t('cockpit.resumeEdit')}
</button>
<button
type="button"
className="btn"
data-testid="cockpit-bridge-check"
onClick={handleBridge}
>
{t('cockpit.bridgeCheck')}
</button>
<button
type="button"
className="btn"
data-testid="cockpit-forgotten-foreshadow"
onClick={handleForgotten}
>
{t('cockpit.forgottenForeshadow')}
</button>
</div>
</div>
</div>
)
}
@@ -0,0 +1,216 @@
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import type {
CreateKnowledgeInput,
ForeshadowStatus,
KnowledgeEntry,
KnowledgeType
} from '@shared/types'
import { useBookStore } from '@renderer/stores/useBookStore'
import { useKnowledgeStore } from '@renderer/stores/useKnowledgeStore'
import { ipcCall } from '@renderer/lib/ipc-client'
interface KnowledgeEditorDialogProps {
open: boolean
entryId: string | null
onClose: () => void
}
const TYPES: KnowledgeType[] = ['character', 'foreshadow', 'location', 'relationship', 'fact']
export function KnowledgeEditorDialog({
open,
entryId,
onClose
}: KnowledgeEditorDialogProps): React.JSX.Element | null {
const { t } = useTranslation()
const bookId = useBookStore((s) => s.currentBookId)
const chapters = useBookStore((s) => s.chapters)
const entries = useKnowledgeStore((s) => s.entries)
const create = useKnowledgeStore((s) => s.create)
const update = useKnowledgeStore((s) => s.update)
const existing = entryId ? entries.find((e) => e.id === entryId) : null
const [type, setType] = useState<KnowledgeType>('fact')
const [title, setTitle] = useState('')
const [content, setContent] = useState('')
const [importance, setImportance] = useState(3)
const [foreshadowStatus, setForeshadowStatus] = useState<ForeshadowStatus>('buried')
const [sourceChapterId, setSourceChapterId] = useState('')
const [approveNow, setApproveNow] = useState(false)
useEffect(() => {
if (!open) return
if (existing) {
setType(existing.type)
setTitle(existing.title)
setContent(existing.content)
setImportance(existing.importance)
setForeshadowStatus(existing.foreshadowStatus ?? 'buried')
setSourceChapterId(existing.sourceChapterId ?? '')
setApproveNow(existing.status === 'approved')
} else {
setType('fact')
setTitle('')
setContent('')
setImportance(3)
setForeshadowStatus('buried')
setSourceChapterId(chapters[0]?.id ?? '')
setApproveNow(false)
}
}, [open, existing, chapters])
const handleSave = async (): Promise<void> => {
if (!bookId || !title.trim()) return
const input: CreateKnowledgeInput = {
type,
title: title.trim(),
content,
importance,
foreshadowStatus: type === 'foreshadow' ? foreshadowStatus : undefined,
sourceChapterId: sourceChapterId || undefined,
status: approveNow ? 'approved' : 'pending'
}
if (existing) {
await update(bookId, existing.id, input)
} else {
await create(bookId, input)
}
onClose()
}
if (!open) return null
return (
<div className="modal-overlay" data-testid="knowledge-editor-dialog">
<div className="modal-content">
<div className="modal-header">
<h3>{existing ? t('knowledge.edit') : t('knowledge.new')}</h3>
<button type="button" className="modal-close" onClick={onClose}>
</button>
</div>
<div className="modal-body" style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
<label>
{t('knowledge.type')}
<select
className="form-control"
data-testid="knowledge-type-select"
value={type}
onChange={(e) => setType(e.target.value as KnowledgeType)}
>
{TYPES.map((tp) => (
<option key={tp} value={tp}>
{t(`knowledge.type.${tp}`)}
</option>
))}
</select>
</label>
<label>
{t('knowledge.titleLabel')}
<input
className="form-control"
data-testid="knowledge-title-input"
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
</label>
<label>
{t('knowledge.contentLabel')}
<textarea
className="form-control"
rows={4}
data-testid="knowledge-content-input"
value={content}
onChange={(e) => setContent(e.target.value)}
/>
</label>
<label>
{t('knowledge.importance')}
<input
type="range"
min={1}
max={5}
value={importance}
onChange={(e) => setImportance(Number(e.target.value))}
/>
{importance}
</label>
{type === 'foreshadow' && (
<label>
{t('knowledge.foreshadowStatus')}
<select
className="form-control"
value={foreshadowStatus}
onChange={(e) => setForeshadowStatus(e.target.value as ForeshadowStatus)}
>
<option value="buried">{t('knowledge.foreshadow.buried')}</option>
<option value="partial">{t('knowledge.foreshadow.partial')}</option>
<option value="resolved">{t('knowledge.foreshadow.resolved')}</option>
</select>
</label>
)}
<label>
{t('knowledge.sourceChapter')}
<select
className="form-control"
value={sourceChapterId}
onChange={(e) => setSourceChapterId(e.target.value)}
>
<option value="">{t('knowledge.noChapter')}</option>
{chapters.map((ch) => (
<option key={ch.id} value={ch.id}>
{ch.title}
</option>
))}
</select>
</label>
{!existing && (
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<input
type="checkbox"
data-testid="knowledge-approve-now"
checked={approveNow}
onChange={(e) => setApproveNow(e.target.checked)}
/>
{t('knowledge.approveNow')}
</label>
)}
</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="knowledge-save"
onClick={() => void handleSave()}
>
{t('dialog.save')}
</button>
</div>
</div>
</div>
)
}
export async function loadKnowledgeEntries(
bookId: string,
tab: 'pending' | 'all' | 'foreshadow',
forgottenOnly: boolean
): Promise<{ entries: KnowledgeEntry[]; stats: { buried: number; resolved: number; forgotten: number } }> {
let filter: { status?: 'pending'; type?: 'foreshadow'; forgottenOnly?: boolean } | undefined
if (tab === 'pending') filter = { status: 'pending' }
else if (tab === 'foreshadow') {
filter = forgottenOnly
? { type: 'foreshadow', forgottenOnly: true }
: { type: 'foreshadow' }
}
const [entries, stats] = await Promise.all([
ipcCall(() => window.electronAPI.knowledge.list(bookId, filter)),
ipcCall(() => window.electronAPI.knowledge.stats(bookId))
])
return { entries, stats }
}
@@ -1,15 +1,68 @@
import { useState } from 'react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useBookStore } from '@renderer/stores/useBookStore'
import { useKnowledgeStore } from '@renderer/stores/useKnowledgeStore'
import { ipcCall } from '@renderer/lib/ipc-client'
import { NamingCheckModal } from '@renderer/components/ai/NamingCheckModal'
import {
KnowledgeEditorDialog,
loadKnowledgeEntries
} from '@renderer/components/knowledge/KnowledgeEditorDialog'
export function KnowledgePanel(): React.JSX.Element {
const { t } = useTranslation()
const bookId = useBookStore((s) => s.currentBookId)
const {
entries,
stats,
tab,
forgottenOnly,
editorOpen,
editingId,
setTab,
openEditor,
closeEditor,
approve,
reject,
deleteEntry,
batchApprove
} = useKnowledgeStore()
const [namingOpen, setNamingOpen] = useState(false)
const [selected, setSelected] = useState<Set<string>>(new Set())
const [pendingCount, setPendingCount] = useState(0)
useEffect(() => {
if (!bookId) return
void loadKnowledgeEntries(bookId, tab, forgottenOnly).then(({ entries, stats }) => {
useKnowledgeStore.setState({ entries, stats })
})
}, [bookId, tab, forgottenOnly])
useEffect(() => {
if (!bookId) return
void ipcCall(() => window.electronAPI.knowledge.list(bookId, { status: 'pending' })).then(
(list) => setPendingCount(list.length)
)
}, [bookId, entries])
const handleBatchApprove = async (): Promise<void> => {
if (!bookId || selected.size === 0) return
await batchApprove(bookId, [...selected])
setSelected(new Set())
}
return (
<>
<div className="knowledge-panel" data-testid="knowledge-panel">
<div className="knowledge-toolbar">
<button
type="button"
className="btn btn-primary"
data-testid="knowledge-new"
onClick={() => openEditor(null)}
>
{t('knowledge.new')}
</button>
<button
type="button"
className="btn"
@@ -19,8 +72,115 @@ export function KnowledgePanel(): React.JSX.Element {
{t('naming.open')}
</button>
</div>
<div className="placeholder-box">{t('knowledge.placeholder')}</div>
<div className="knowledge-tabs">
<button
type="button"
className={`kb-tab ${tab === 'pending' ? 'active' : ''}`}
data-testid="knowledge-tab-pending"
onClick={() => setTab('pending')}
>
{t('knowledge.tab.pending')}
{pendingCount > 0 && <span className="kb-badge">{pendingCount}</span>}
</button>
<button
type="button"
className={`kb-tab ${tab === 'all' ? 'active' : ''}`}
data-testid="knowledge-tab-all"
onClick={() => setTab('all')}
>
{t('knowledge.tab.all')}
</button>
<button
type="button"
className={`kb-tab ${tab === 'foreshadow' ? 'active' : ''}`}
data-testid="knowledge-tab-foreshadow"
onClick={() => setTab('foreshadow')}
>
{t('knowledge.tab.foreshadow')}
</button>
</div>
{tab === 'foreshadow' && (
<div className="knowledge-stats" data-testid="knowledge-foreshadow-stats">
{t('knowledge.statsSummary', {
buried: stats.buried,
resolved: stats.resolved,
forgotten: stats.forgotten
})}
</div>
)}
{tab === 'pending' && selected.size > 0 && (
<button
type="button"
className="btn"
data-testid="knowledge-batch-approve"
onClick={() => void handleBatchApprove()}
>
{t('knowledge.batchApprove', { count: selected.size })}
</button>
)}
<div className="knowledge-list">
{entries.map((entry) => (
<div key={entry.id} className="kb-item" data-testid={`knowledge-item-${entry.id}`}>
{tab === 'pending' && (
<input
type="checkbox"
checked={selected.has(entry.id)}
onChange={(e) => {
const next = new Set(selected)
if (e.target.checked) next.add(entry.id)
else next.delete(entry.id)
setSelected(next)
}}
/>
)}
<div className="kb-item-main">
<div className="kb-item-title">{entry.title}</div>
<div className="kb-item-meta">
{t(`knowledge.type.${entry.type}`)} · {t(`knowledge.status.${entry.status}`)}
</div>
{entry.content && <div className="kb-item-content">{entry.content}</div>}
</div>
<div className="kb-item-actions">
{entry.status === 'pending' && bookId && (
<>
<button
type="button"
className="btn btn-sm"
data-testid={`knowledge-approve-${entry.id}`}
onClick={() => void approve(bookId, entry.id)}
>
{t('knowledge.approve')}
</button>
<button
type="button"
className="btn btn-sm"
onClick={() => void reject(bookId, entry.id)}
>
{t('knowledge.reject')}
</button>
</>
)}
<button type="button" className="btn btn-sm" onClick={() => openEditor(entry.id)}>
{t('knowledge.edit')}
</button>
{bookId && (
<button
type="button"
className="btn btn-sm"
onClick={() => void deleteEntry(bookId, entry.id)}
>
{t('knowledge.delete')}
</button>
)}
</div>
</div>
))}
{entries.length === 0 && (
<div className="sidebar-empty">{t('knowledge.empty')}</div>
)}
</div>
</div>
<KnowledgeEditorDialog open={editorOpen} entryId={editingId} onClose={closeEditor} />
<NamingCheckModal open={namingOpen} onClose={() => setNamingOpen(false)} />
</>
)
+109 -4
View File
@@ -1,11 +1,14 @@
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useAtomValue } from 'jotai'
import type { CockpitSummary, PublishStatus } from '@shared/types'
import { useAppStore } from '@renderer/stores/useAppStore'
import { useBookStore } from '@renderer/stores/useBookStore'
import { useEditStore } from '@renderer/stores/useEditStore'
import { useOutlineStore } from '@renderer/stores/useOutlineStore'
import { useReferenceStore } from '@renderer/stores/useReferenceStore'
import { useSettingsStore } from '@renderer/stores/useSettingsStore'
import { useCockpitStore } from '@renderer/stores/useCockpitStore'
import { ipcCall } from '@renderer/lib/ipc-client'
import { TipTapEditor, flushEditorSave } from '@renderer/components/editor/TipTapEditor'
import { insertLandmarkInEditor } from '@renderer/lib/editor-commands'
@@ -17,6 +20,8 @@ import { SettingList } from '@renderer/components/setting/SettingList'
import { InspirationList } from '@renderer/components/inspiration/InspirationList'
import { VersionModal } from '@renderer/components/version/VersionModal'
import { LandmarkModal } from '@renderer/components/landmark/LandmarkModal'
import { CockpitModal } from '@renderer/components/cockpit/CockpitModal'
import { ChapterBridgeModal } from '@renderer/components/bridge/ChapterBridgeModal'
import {
editorDirtyAtom,
editorSavingAtom,
@@ -24,6 +29,19 @@ import {
wordCountAtom
} from '@renderer/atoms/editorAtoms'
const PUBLISH_CYCLE: PublishStatus[] = ['draft', 'ready', 'published']
function nextPublishStatus(current: PublishStatus | undefined): PublishStatus {
const idx = PUBLISH_CYCLE.indexOf(current ?? 'draft')
return PUBLISH_CYCLE[(idx + 1) % PUBLISH_CYCLE.length]
}
function publishIcon(status: PublishStatus | undefined): string {
if (status === 'ready') return '📦'
if (status === 'published') return '✅'
return '📝'
}
function useDocumentTitle(): string {
const target = useEditStore((s) => s.target)
const chapters = useBookStore((s) => s.chapters)
@@ -71,6 +89,10 @@ export function EditorLayout(): React.JSX.Element {
const target = useEditStore((s) => s.target)
const switchTarget = useEditStore((s) => s.switchTarget)
const setTarget = useEditStore((s) => s.setTarget)
const updateSchedule = useSettingsStore((s) => s.updateSchedule)
const bridgeChapterId = useCockpitStore((s) => s.bridgeChapterId)
const clearBridgeChapter = useCockpitStore((s) => s.clearBridgeChapter)
const updateChapterLocal = useBookStore((s) => s.updateChapterLocal)
const dirty = useAtomValue(editorDirtyAtom)
const saving = useAtomValue(editorSavingAtom)
@@ -78,6 +100,9 @@ export function EditorLayout(): React.JSX.Element {
const wordCount = useAtomValue(wordCountAtom)
const documentTitle = useDocumentTitle()
const [bridgeOpen, setBridgeOpen] = useState(false)
const [cockpitSummary, setCockpitSummary] = useState<CockpitSummary | null>(null)
const bookMeta = useBookStore.getState().books.find((b) => b.id === currentBookId)
const isChapterTarget = target?.kind === 'chapter'
const chapterId = target?.kind === 'chapter' ? target.id : selectedChapterId
@@ -97,6 +122,47 @@ export function EditorLayout(): React.JSX.Element {
}
}, [currentBookId, target?.kind, target?.id])
useEffect(() => {
if (!currentBookId) return
void ipcCall(() =>
window.electronAPI.cockpit.getSummary(currentBookId, activeVolumeId ?? undefined)
).then(setCockpitSummary)
}, [currentBookId, activeVolumeId, chapters])
useEffect(() => {
if (bridgeChapterId) setBridgeOpen(true)
}, [bridgeChapterId])
useEffect(() => {
if (!currentBookId || target?.kind !== 'chapter') return
const chapterId = target.id
void ipcCall(() => window.electronAPI.bridge.shouldPrompt(currentBookId, chapterId)).then(
(should) => {
if (should) setTimeout(() => setBridgeOpen(true), 500)
}
)
}, [currentBookId, target?.kind, target?.id])
const activeBridgeChapterId =
bridgeChapterId ?? (target?.kind === 'chapter' ? target.id : selectedChapterId)
const showStockWarning =
updateSchedule !== 'none' &&
cockpitSummary != null &&
cockpitSummary.stockReadyCount < cockpitSummary.stockThreshold
const handlePublishCycle = async (chapterId: string, e: React.MouseEvent): Promise<void> => {
e.stopPropagation()
if (!currentBookId) return
const ch = chapters.find((c) => c.id === chapterId)
if (!ch) return
const next = nextPublishStatus(ch.publishStatus)
const updated = await ipcCall(() =>
window.electronAPI.chapter.setPublishStatus(currentBookId, chapterId, next)
)
updateChapterLocal(updated)
}
const handleSelectChapter = async (chapterId: string): Promise<void> => {
setSelectedChapter(chapterId)
await switchTarget({ kind: 'chapter', id: chapterId })
@@ -178,11 +244,19 @@ export function EditorLayout(): React.JSX.Element {
}
const label = prompt(t('landmark.prompt'))
if (!label?.trim()) return
insertLandmarkInEditor({ label: label.trim(), landmarkType: 'todo' })
await ipcCall(() =>
window.electronAPI.bookmark.create(currentBookId, target.id, 0, label.trim(), 'todo')
const isForeshadow = confirm(t('landmark.foreshadowConfirm'))
const landmarkType = isForeshadow ? 'foreshadow' : 'todo'
const syncKnowledge = isForeshadow && confirm(t('knowledge.syncFromLandmark'))
insertLandmarkInEditor({ label: label.trim(), landmarkType })
const bm = await ipcCall(() =>
window.electronAPI.bookmark.create(currentBookId, target.id, 0, label.trim(), landmarkType)
)
showToast(t('landmark.created'))
if (syncKnowledge) {
await ipcCall(() => window.electronAPI.knowledge.createFromBookmark(currentBookId, bm.id))
showToast(t('knowledge.syncedFromLandmark'))
} else {
showToast(t('landmark.created'))
}
}
const saveStatus = saving ? t('editor.saving') : dirty ? t('editor.unsaved') : t('editor.saved')
@@ -254,6 +328,15 @@ export function EditorLayout(): React.JSX.Element {
>
<span>{idx + 1}.</span>
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis' }}>{ch.title}</span>
<button
type="button"
className="ch-publish-btn"
title={t(`publish.status.${ch.publishStatus ?? 'draft'}`)}
data-testid={`chapter-publish-${ch.id}`}
onClick={(e) => void handlePublishCycle(ch.id, e)}
>
{publishIcon(ch.publishStatus)}
</button>
<span className={`ch-badge ${ch.status === 'done' ? 'done' : 'draft'}`}>{ch.status}</span>
</div>
))}
@@ -322,6 +405,14 @@ export function EditorLayout(): React.JSX.Element {
/>
<div id="editor-statusbar">
<span>{saveStatus}{lastSaved ? ` · ${lastSaved.toLocaleTimeString()}` : ''}</span>
{showStockWarning && (
<span className="status-stock-warning" data-testid="status-stock-warning">
{t('stock.warning', {
count: cockpitSummary!.stockReadyCount,
threshold: cockpitSummary!.stockThreshold
})}
</span>
)}
{isChapterTarget ? (
<>
<span>{t('status.chapter')}: {t('editor.words', { count: wordCount.chapter })}</span>
@@ -343,6 +434,20 @@ export function EditorLayout(): React.JSX.Element {
chapterId={chapterId}
/>
<LandmarkModal open={landmarksModalOpen} onClose={() => setLandmarksModalOpen(false)} />
<CockpitModal
onOpenBridge={(chapterId) => {
useCockpitStore.getState().openBridgeForChapter(chapterId)
setBridgeOpen(true)
}}
/>
<ChapterBridgeModal
open={bridgeOpen}
chapterId={activeBridgeChapterId}
onClose={() => {
setBridgeOpen(false)
clearBridgeChapter()
}}
/>
</div>
)
}
+16 -1
View File
@@ -2,6 +2,7 @@ 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'
export function TopBar(): React.JSX.Element {
const { t } = useTranslation()
@@ -9,6 +10,9 @@ export function TopBar(): React.JSX.Element {
const showToast = useAppStore((s) => s.showToast)
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 handleCloseTab = (tabId: string, e: React.MouseEvent): void => {
e.stopPropagation()
@@ -60,7 +64,18 @@ export function TopBar(): React.JSX.Element {
))}
</div>
<div className="top-spacer" />
<button type="button" className="top-btn" title={t('feature.comingSoon')} onClick={() => showToast(t('feature.comingSoon'))}>
<button
type="button"
className="top-btn"
title={t('cockpit.title')}
data-testid="topbar-cockpit"
disabled={!currentBookId}
onClick={() => {
if (!currentBookId) return
openCockpit()
void loadCockpitSummary(currentBookId, activeVolumeId ?? undefined)
}}
>
📊
</button>
<button type="button" className="top-btn" onClick={() => { openSettingsTab(); setView('settings') }}>
@@ -97,13 +97,47 @@ export function SettingsPage(): React.JSX.Element {
<option value="en">English</option>
</select>
</div>
<div className="setting-item">
<span>{t('settings.updateSchedule')}</span>
<select
data-testid="settings-update-schedule"
className="form-control"
value={settings.updateSchedule}
onChange={(e) =>
void settings.update({
updateSchedule: e.target.value as typeof settings.updateSchedule
})
}
>
<option value="none">{t('settings.updateSchedule.none')}</option>
<option value="daily">{t('settings.updateSchedule.daily')}</option>
<option value="alternate">{t('settings.updateSchedule.alternate')}</option>
<option value="weekly">{t('settings.updateSchedule.weekly')}</option>
</select>
</div>
<div className="setting-item">
<span>{t('settings.stockBufferThreshold')}</span>
<input
type="number"
min={1}
max={20}
data-testid="settings-stock-threshold"
className="form-control form-control--narrow"
value={settings.stockBufferThreshold}
onChange={(e) =>
void settings.update({
stockBufferThreshold: Math.min(20, Math.max(1, Number(e.target.value) || 3))
})
}
/>
</div>
</>
)}
{section === 'ai' && <AiSettingsPage />}
{section === 'shortcuts' && <ShortcutEditor />}
{section === 'about' && (
<p style={{ color: 'var(--text-secondary)', lineHeight: 1.8 }}>
{t('app.name')} v0.5.0
{t('app.name')} v0.6.0
<br />
{t('app.tagline')}
</p>
+11
View File
@@ -1,6 +1,7 @@
import { create } from 'zustand'
import type { BookMeta, Volume, Chapter, OutlineItem, SettingEntry, InspirationEntry } from '@shared/types'
import { ipcCall } from '@renderer/lib/ipc-client'
import { useCockpitStore } from '@renderer/stores/useCockpitStore'
interface BookState {
books: BookMeta[]
@@ -62,6 +63,16 @@ export const useBookStore = create<BookState>((set, get) => ({
activeVolumeId: result.chapters.find((c) => c.id === lastChapter)?.volumeId ?? firstVolume,
selectedChapterId: lastChapter
})
const cockpit = useCockpitStore.getState()
const shouldShow = await cockpit.shouldShowOnOpen(bookId)
if (shouldShow) {
await cockpit.markSeen(bookId)
cockpit.openModal()
void cockpit.loadSummary(
bookId,
result.chapters.find((c) => c.id === lastChapter)?.volumeId ?? firstVolume ?? undefined
)
}
},
setSelectedChapter: (chapterId) => {
const ch = get().chapters.find((c) => c.id === chapterId)
+42
View File
@@ -0,0 +1,42 @@
import { create } from 'zustand'
import type { CockpitSummary } from '@shared/types'
import { ipcCall } from '@renderer/lib/ipc-client'
interface CockpitState {
open: boolean
summary: CockpitSummary | null
loading: boolean
bridgeChapterId: string | null
loadSummary: (bookId: string, volumeId?: string) => Promise<void>
openModal: () => void
close: () => void
markSeen: (bookId: string) => Promise<void>
shouldShowOnOpen: (bookId: string) => Promise<boolean>
openBridgeForChapter: (chapterId: string) => void
clearBridgeChapter: () => void
}
export const useCockpitStore = create<CockpitState>((set) => ({
open: false,
summary: null,
loading: false,
bridgeChapterId: null,
loadSummary: async (bookId, volumeId) => {
set({ loading: true })
try {
const summary = await ipcCall(() => window.electronAPI.cockpit.getSummary(bookId, volumeId))
set({ summary })
} finally {
set({ loading: false })
}
},
openModal: () => set({ open: true }),
close: () => set({ open: false }),
markSeen: async (bookId) => {
await ipcCall(() => window.electronAPI.cockpit.markSeen(bookId))
},
shouldShowOnOpen: async (bookId) =>
ipcCall(() => window.electronAPI.cockpit.shouldShowOnOpen(bookId)),
openBridgeForChapter: (chapterId) => set({ bridgeChapterId: chapterId, open: false }),
clearBridgeChapter: () => set({ bridgeChapterId: null })
}))
+97
View File
@@ -0,0 +1,97 @@
import { create } from 'zustand'
import type {
CreateKnowledgeInput,
KnowledgeEntry,
KnowledgeStatus,
KnowledgeType
} from '@shared/types'
import { ipcCall } from '@renderer/lib/ipc-client'
import { useAppStore } from '@renderer/stores/useAppStore'
export type KnowledgeTab = 'pending' | 'all' | 'foreshadow'
interface KnowledgeState {
entries: KnowledgeEntry[]
stats: { buried: number; resolved: number; forgotten: number }
tab: KnowledgeTab
forgottenOnly: boolean
editorOpen: boolean
editingId: string | null
load: (bookId: string) => Promise<void>
setTab: (tab: KnowledgeTab) => void
openForgottenFilter: () => void
openEditor: (id?: string | null) => void
closeEditor: () => void
create: (bookId: string, input: CreateKnowledgeInput) => Promise<KnowledgeEntry>
update: (
bookId: string,
id: string,
patch: Partial<CreateKnowledgeInput & { status: KnowledgeStatus }>
) => Promise<KnowledgeEntry>
deleteEntry: (bookId: string, id: string) => Promise<void>
approve: (bookId: string, id: string) => Promise<void>
reject: (bookId: string, id: string) => Promise<void>
batchApprove: (bookId: string, ids: string[]) => Promise<void>
filteredEntries: () => KnowledgeEntry[]
}
export const useKnowledgeStore = create<KnowledgeState>((set, get) => ({
entries: [],
stats: { buried: 0, resolved: 0, forgotten: 0 },
tab: 'all',
forgottenOnly: false,
editorOpen: false,
editingId: null,
load: async (bookId) => {
const [entries, stats] = await Promise.all([
ipcCall(() => window.electronAPI.knowledge.list(bookId)),
ipcCall(() => window.electronAPI.knowledge.stats(bookId))
])
set({ entries, stats })
},
setTab: (tab) => set({ tab, forgottenOnly: tab === 'foreshadow' ? get().forgottenOnly : false }),
openForgottenFilter: () => {
useAppStore.getState().setRightPanel('knowledge')
set({ tab: 'foreshadow', forgottenOnly: true })
},
openEditor: (id = null) => set({ editorOpen: true, editingId: id }),
closeEditor: () => set({ editorOpen: false, editingId: null }),
create: async (bookId, input) => {
const entry = await ipcCall(() => window.electronAPI.knowledge.create(bookId, input))
await get().load(bookId)
return entry
},
update: async (bookId, id, patch) => {
const entry = await ipcCall(() => window.electronAPI.knowledge.update(bookId, id, patch))
await get().load(bookId)
return entry
},
deleteEntry: async (bookId, id) => {
await ipcCall(() => window.electronAPI.knowledge.delete(bookId, id))
await get().load(bookId)
},
approve: async (bookId, id) => {
await ipcCall(() => window.electronAPI.knowledge.approve(bookId, id))
await get().load(bookId)
},
reject: async (bookId, id) => {
await ipcCall(() => window.electronAPI.knowledge.reject(bookId, id))
await get().load(bookId)
},
batchApprove: async (bookId, ids) => {
await ipcCall(() => window.electronAPI.knowledge.batchApprove(bookId, ids))
await get().load(bookId)
},
filteredEntries: () => {
const { entries, tab, forgottenOnly } = get()
let list = entries
if (tab === 'pending') list = list.filter((e) => e.status === 'pending')
else if (tab === 'foreshadow') {
list = list.filter((e) => e.type === 'foreshadow')
if (forgottenOnly) {
// forgotten filter applied client-side via reload with filter in panel
}
}
return list
}
}))
+2
View File
@@ -17,6 +17,8 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
theme: 'default',
language: 'zh-CN',
dailyWordGoal: 0,
updateSchedule: 'none',
stockBufferThreshold: 3,
shortcuts: {} as Record<ActionId, string>,
aiConfig: { ...DEFAULT_AI_CONFIG },
namingIgnoreWords: [] as string[],
+174
View File
@@ -741,6 +741,180 @@
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.knowledge-toolbar {
display: flex;
gap: 6px;
padding: 8px;
flex-shrink: 0;
}
.knowledge-tabs {
display: flex;
gap: 4px;
padding: 0 8px 8px;
flex-shrink: 0;
}
.kb-tab {
flex: 1;
padding: 6px 8px;
font-size: 12px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-secondary);
cursor: pointer;
}
.kb-tab.active {
background: var(--accent);
color: #fff;
border-color: var(--accent);
}
.kb-badge {
margin-left: 4px;
background: rgba(255, 255, 255, 0.25);
padding: 0 5px;
border-radius: 8px;
font-size: 10px;
}
.knowledge-stats {
font-size: 11px;
color: var(--text-secondary);
padding: 0 8px 8px;
}
.knowledge-list {
flex: 1;
overflow-y: auto;
padding: 0 8px 8px;
}
.kb-item {
display: flex;
gap: 8px;
padding: 10px;
margin-bottom: 6px;
border-radius: var(--radius-sm);
background: var(--bg-tertiary);
border: 1px solid var(--border);
}
.kb-item-main {
flex: 1;
min-width: 0;
}
.kb-item-title {
font-size: 13px;
font-weight: 600;
margin-bottom: 4px;
}
.kb-item-meta {
font-size: 11px;
color: var(--text-secondary);
}
.kb-item-content {
font-size: 12px;
margin-top: 4px;
color: var(--text-secondary);
}
.kb-item-actions {
display: flex;
flex-direction: column;
gap: 4px;
}
.btn-sm {
font-size: 11px;
padding: 2px 6px;
}
.cockpit-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
padding: 12px;
}
.cockpit-card {
padding: 14px;
border-radius: var(--radius-sm);
background: var(--bg-tertiary);
border: 1px solid var(--border);
}
.cockpit-card-label {
font-size: 11px;
color: var(--text-secondary);
margin-bottom: 6px;
}
.cockpit-card-value {
font-size: 18px;
font-weight: 600;
}
.cockpit-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.bridge-body {
max-height: 420px;
overflow-y: auto;
}
.bridge-section {
margin-bottom: 14px;
}
.bridge-section h4 {
font-size: 12px;
color: var(--text-secondary);
margin-bottom: 6px;
}
.bridge-section p {
font-size: 13px;
line-height: 1.6;
padding: 10px;
background: var(--bg-tertiary);
border-radius: var(--radius-sm);
}
.bridge-dismiss {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
margin-top: 8px;
}
.ch-publish-btn {
background: none;
border: none;
cursor: pointer;
font-size: 12px;
padding: 0 2px;
opacity: 0.85;
}
.ch-publish-btn:hover {
opacity: 1;
}
.status-stock-warning {
color: var(--warning, #e6a23c);
margin-left: 12px;
}
.naming-modal-body {
+47 -1
View File
@@ -33,7 +33,14 @@ import type {
SnapshotType,
UpdateChapterParams,
Volume,
WordFreqResult
WordFreqResult,
KnowledgeEntry,
CreateKnowledgeInput,
KnowledgeStatus,
KnowledgeType,
CockpitSummary,
ChapterBridgeResult,
PublishStatus
} from './types'
export interface ElectronAPI {
@@ -77,6 +84,11 @@ export interface ElectronAPI {
targetVolumeId: string,
targetIndex: number
) => Promise<IpcResult<Chapter>>
setPublishStatus: (
bookId: string,
chapterId: string,
publishStatus: PublishStatus
) => Promise<IpcResult<Chapter>>
}
outline: {
list: (bookId: string) => Promise<IpcResult<OutlineItem[]>>
@@ -307,6 +319,40 @@ export interface ElectronAPI {
handoffInteractive: (bookId: string, flowId: string) => Promise<IpcResult<InteractiveFlow>>
abort: (flowId: string) => Promise<IpcResult<void>>
}
knowledge: {
list: (
bookId: string,
filter?: { status?: KnowledgeStatus; type?: KnowledgeType; forgottenOnly?: boolean }
) => Promise<IpcResult<KnowledgeEntry[]>>
create: (bookId: string, input: CreateKnowledgeInput) => Promise<IpcResult<KnowledgeEntry>>
update: (
bookId: string,
id: string,
patch: Partial<CreateKnowledgeInput & { status: KnowledgeStatus }>
) => Promise<IpcResult<KnowledgeEntry>>
delete: (bookId: string, id: string) => Promise<IpcResult<void>>
approve: (bookId: string, id: string) => Promise<IpcResult<KnowledgeEntry>>
reject: (bookId: string, id: string) => Promise<IpcResult<KnowledgeEntry>>
batchApprove: (bookId: string, ids: string[]) => Promise<IpcResult<number>>
createFromBookmark: (bookId: string, bookmarkId: string) => Promise<IpcResult<KnowledgeEntry>>
stats: (
bookId: string
) => Promise<IpcResult<{ buried: number; resolved: number; forgotten: number }>>
}
cockpit: {
getSummary: (bookId: string, volumeId?: string) => Promise<IpcResult<CockpitSummary>>
markSeen: (bookId: string) => Promise<IpcResult<void>>
shouldShowOnOpen: (bookId: string) => Promise<IpcResult<boolean>>
}
bridge: {
get: (
bookId: string,
chapterId: string,
withAi?: boolean
) => Promise<IpcResult<ChapterBridgeResult>>
dismiss: (bookId: string, chapterId: string) => Promise<IpcResult<void>>
shouldPrompt: (bookId: string, chapterId: string) => Promise<IpcResult<boolean>>
}
wordfreq: {
analyze: (
bookId: string,
+17 -1
View File
@@ -100,5 +100,21 @@ export const IPC = {
WIZARD_BUILD_PREVIEW: 'wizard:buildPreview',
WIZARD_START_GENERATE: 'wizard:startGenerate',
WIZARD_HANDOFF_INTERACTIVE: 'wizard:handoffInteractive',
WIZARD_ABORT: 'wizard:abort'
WIZARD_ABORT: 'wizard:abort',
KNOWLEDGE_LIST: 'knowledge:list',
KNOWLEDGE_CREATE: 'knowledge:create',
KNOWLEDGE_UPDATE: 'knowledge:update',
KNOWLEDGE_DELETE: 'knowledge:delete',
KNOWLEDGE_APPROVE: 'knowledge:approve',
KNOWLEDGE_REJECT: 'knowledge:reject',
KNOWLEDGE_BATCH_APPROVE: 'knowledge:batchApprove',
KNOWLEDGE_CREATE_FROM_BOOKMARK: 'knowledge:createFromBookmark',
KNOWLEDGE_STATS: 'knowledge:stats',
COCKPIT_SUMMARY: 'cockpit:summary',
COCKPIT_MARK_SEEN: 'cockpit:markSeen',
COCKPIT_SHOULD_SHOW: 'cockpit:shouldShowOnOpen',
BRIDGE_GET: 'bridge:get',
BRIDGE_DISMISS: 'bridge:dismiss',
BRIDGE_SHOULD_PROMPT: 'bridge:shouldPrompt',
CHAPTER_SET_PUBLISH_STATUS: 'chapter:setPublishStatus'
} as const
+51
View File
@@ -50,12 +50,63 @@ export interface IpcError {
export type IpcResult<T> = { ok: true; data: T } | { ok: false; error: IpcError }
export type KnowledgeType = 'character' | 'foreshadow' | 'location' | 'relationship' | 'fact'
export type KnowledgeStatus = 'pending' | 'approved' | 'rejected'
export type ForeshadowStatus = 'buried' | 'partial' | 'resolved'
export type UpdateSchedule = 'daily' | 'alternate' | 'weekly' | 'none'
export interface KnowledgeEntry {
id: string
type: KnowledgeType
title: string
content: string
importance: number
status: KnowledgeStatus
foreshadowStatus?: ForeshadowStatus
sourceChapterId?: string
lastMentionChapterId?: string
linkedBookmarkId?: string
createdAt: string
updatedAt: string
}
export interface CreateKnowledgeInput {
type: KnowledgeType
title: string
content?: string
importance?: number
status?: KnowledgeStatus
foreshadowStatus?: ForeshadowStatus
sourceChapterId?: string
lastMentionChapterId?: string
linkedBookmarkId?: string
}
export interface CockpitSummary {
totalWords: number
volumeWords: number
stockReadyCount: number
stockThreshold: number
foreshadowBuried: number
foreshadowResolved: number
foreshadowForgotten: number
}
export interface ChapterBridgeResult {
previousChapterId: string | null
previousTail: string
currentHead: string
aiSuggestion?: string
}
export interface GlobalSettings {
penName: string
onboardingCompleted: boolean
theme: ThemeId
language: Language
dailyWordGoal: number
updateSchedule: UpdateSchedule
stockBufferThreshold: number
shortcuts: Record<ActionId, string>
snapshotIntervalMs: number
snapshotMaxAuto: number