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:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
@@ -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))
|
||||
)
|
||||
}
|
||||
@@ -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))
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)} />
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 })
|
||||
}))
|
||||
@@ -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
|
||||
}
|
||||
}))
|
||||
@@ -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[],
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Vendored
+47
-1
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user