feat: ship v0.4.0 with interactive writing mode

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-06 17:46:16 +08:00
parent 0a054606fe
commit a8e0ba9ac9
39 changed files with 2113 additions and 89 deletions
+9 -1
View File
@@ -2,8 +2,9 @@ import type { SqliteDb } from './types'
import schemaV1 from './schema-v1.sql?raw'
import schemaV2 from './schema-v2.sql?raw'
import schemaV3 from './schema-v3.sql?raw'
import schemaV4 from './schema-v4.sql?raw'
const CURRENT_VERSION = 3
const CURRENT_VERSION = 4
function tryAlter(db: SqliteDb, sql: string): void {
try {
@@ -48,5 +49,12 @@ export function migrate(db: SqliteDb): void {
if (current < 3) {
db.exec(schemaV3)
db.prepare('INSERT INTO schema_version (version, description) VALUES (?, ?)').run(3, 'P3 AI schema')
current = 3
}
if (current < 4) {
tryAlter(db, "ALTER TABLE chapters ADD COLUMN origin TEXT NOT NULL DEFAULT 'manual'")
db.exec(schemaV4)
db.prepare('INSERT INTO schema_version (version, description) VALUES (?, ?)').run(4, 'P4 interactive schema')
}
}
+13 -6
View File
@@ -1,6 +1,6 @@
import { randomUUID } from 'crypto'
import { countWords } from '../../services/word-count'
import type { Chapter, ChapterStatus } from '../../../shared/types'
import type { Chapter, ChapterOrigin, ChapterStatus } from '../../../shared/types'
import type { SqliteDb } from '../types'
function mapRow(row: Record<string, unknown>): Chapter {
return {
@@ -11,21 +11,28 @@ function mapRow(row: Record<string, unknown>): Chapter {
status: row.status as ChapterStatus,
wordCount: row.word_count as number,
sortOrder: row.sort_order as number,
cursorOffset: (row.cursor_offset as number) ?? 0
cursorOffset: (row.cursor_offset as number) ?? 0,
origin: ((row.origin as ChapterOrigin) ?? 'manual') as ChapterOrigin
}
}
export class ChapterRepository {
constructor(private db: SqliteDb) {}
create(volumeId: string, title: string, sortOrder: number, content = ''): Chapter {
create(
volumeId: string,
title: string,
sortOrder: number,
content = '',
origin: ChapterOrigin = 'manual'
): Chapter {
const id = randomUUID()
const wordCount = countWords(content)
this.db
.prepare(
`INSERT INTO chapters (id, volume_id, title, content, word_count, sort_order)
VALUES (?, ?, ?, ?, ?, ?)`
`INSERT INTO chapters (id, volume_id, title, content, word_count, sort_order, origin)
VALUES (?, ?, ?, ?, ?, ?, ?)`
)
.run(id, volumeId, title, content, wordCount, sortOrder)
.run(id, volumeId, title, content, wordCount, sortOrder, origin)
return this.get(id)!
}
@@ -0,0 +1,169 @@
import { randomUUID } from 'crypto'
import type {
InteractiveFlow,
InteractiveFlowStatus,
InteractiveScene,
InteractiveStep,
NamingPause
} from '../../../shared/types'
import type { SqliteDb } from '../types'
export class InteractiveRepository {
constructor(private db: SqliteDb) {}
getBySession(sessionId: string): InteractiveFlow | null {
const row = this.db
.prepare(
`SELECT * FROM interactive_flows WHERE session_id = ? AND status = 'in_progress' ORDER BY updated_at DESC LIMIT 1`
)
.get(sessionId) as Record<string, unknown> | undefined
return row ? this.mapFlow(row) : null
}
get(flowId: string): InteractiveFlow | null {
const row = this.db.prepare('SELECT * FROM interactive_flows WHERE id = ?').get(flowId) as
| Record<string, unknown>
| undefined
return row ? this.mapFlow(row) : null
}
create(sessionId: string): InteractiveFlow {
const id = randomUUID()
this.db
.prepare(
`INSERT INTO interactive_flows (id, session_id, step, status, context_json) VALUES (?, ?, 'context_confirm', 'in_progress', '{}')`
)
.run(id, sessionId)
return this.get(id)!
}
resetInProgress(sessionId: string): InteractiveFlow {
this.db
.prepare(
`UPDATE interactive_flows SET status = 'abandoned', updated_at = datetime('now') WHERE session_id = ? AND status = 'in_progress'`
)
.run(sessionId)
return this.create(sessionId)
}
updateStep(
flowId: string,
step: InteractiveStep,
patch: {
contextJson?: string
plotSelectionJson?: string | null
namingPendingJson?: string | null
sceneDraftHtml?: string | null
status?: InteractiveFlowStatus
} = {}
): InteractiveFlow {
const sets: string[] = ['step = ?', `updated_at = datetime('now')`]
const vals: unknown[] = [step]
if (patch.contextJson !== undefined) {
sets.push('context_json = ?')
vals.push(patch.contextJson)
}
if (patch.plotSelectionJson !== undefined) {
sets.push('plot_selection_json = ?')
vals.push(patch.plotSelectionJson)
}
if (patch.namingPendingJson !== undefined) {
sets.push('naming_pending_json = ?')
vals.push(patch.namingPendingJson)
}
if (patch.sceneDraftHtml !== undefined) {
sets.push('scene_draft_html = ?')
vals.push(patch.sceneDraftHtml)
}
if (patch.status !== undefined) {
sets.push('status = ?')
vals.push(patch.status)
}
vals.push(flowId)
this.db.prepare(`UPDATE interactive_flows SET ${sets.join(', ')} WHERE id = ?`).run(...vals)
return this.get(flowId)!
}
listScenes(flowId: string): InteractiveScene[] {
const rows = this.db
.prepare('SELECT * FROM interactive_scenes WHERE flow_id = ? ORDER BY sort_order')
.all(flowId) as Record<string, unknown>[]
return rows.map((r) => ({
id: r.id as string,
sortOrder: r.sort_order as number,
contentHtml: r.content_html as string,
plotLabel: r.plot_label as string,
refinedCount: r.refined_count as number
}))
}
addScene(flowId: string, contentHtml: string, plotLabel: string): InteractiveScene {
const id = randomUUID()
const max = this.db
.prepare('SELECT COALESCE(MAX(sort_order), -1) AS m FROM interactive_scenes WHERE flow_id = ?')
.get(flowId) as { m: number }
const sortOrder = max.m + 1
this.db
.prepare(
`INSERT INTO interactive_scenes (id, flow_id, sort_order, content_html, plot_label) VALUES (?, ?, ?, ?, ?)`
)
.run(id, flowId, sortOrder, contentHtml, plotLabel)
return this.listScenes(flowId).find((s) => s.id === id)!
}
updateSceneContent(flowId: string, sceneId: string, contentHtml: string, refinedCount: number): void {
this.db
.prepare(
`UPDATE interactive_scenes SET content_html = ?, refined_count = ? WHERE id = ? AND flow_id = ?`
)
.run(contentHtml, refinedCount, sceneId, flowId)
}
getSceneDraft(flowId: string): string {
const row = this.db
.prepare('SELECT scene_draft_html FROM interactive_flows WHERE id = ?')
.get(flowId) as { scene_draft_html: string | null } | undefined
return row?.scene_draft_html ?? ''
}
setSceneDraft(flowId: string, html: string): void {
this.db
.prepare(`UPDATE interactive_flows SET scene_draft_html = ?, updated_at = datetime('now') WHERE id = ?`)
.run(html, flowId)
}
getPlotSelection(flowId: string): string {
const row = this.db
.prepare('SELECT plot_selection_json FROM interactive_flows WHERE id = ?')
.get(flowId) as { plot_selection_json: string | null } | undefined
return row?.plot_selection_json ?? '{}'
}
getContextJson(flowId: string): string {
const row = this.db
.prepare('SELECT context_json FROM interactive_flows WHERE id = ?')
.get(flowId) as { context_json: string } | undefined
return row?.context_json ?? '{}'
}
private mapFlow(row: Record<string, unknown>): InteractiveFlow {
const flowId = row.id as string
let namingPending: NamingPause | null = null
if (row.naming_pending_json) {
try {
namingPending = JSON.parse(row.naming_pending_json as string) as NamingPause
} catch {
namingPending = null
}
}
return {
id: flowId,
sessionId: row.session_id as string,
step: row.step as InteractiveStep,
status: row.status as InteractiveFlowStatus,
scenes: this.listScenes(flowId),
namingPending,
contextSummary: undefined
}
}
}
+27
View File
@@ -0,0 +1,27 @@
CREATE TABLE IF NOT EXISTS interactive_flows (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
step TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'in_progress',
context_json TEXT NOT NULL DEFAULT '{}',
plot_selection_json TEXT,
naming_pending_json TEXT,
scene_draft_html TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (session_id) REFERENCES ai_sessions(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS interactive_scenes (
id TEXT PRIMARY KEY,
flow_id TEXT NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0,
content_html TEXT NOT NULL DEFAULT '',
plot_label TEXT NOT NULL DEFAULT '',
refined_count INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (flow_id) REFERENCES interactive_flows(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_interactive_flows_session ON interactive_flows(session_id);
CREATE INDEX IF NOT EXISTS idx_interactive_scenes_flow ON interactive_scenes(flow_id, sort_order);
@@ -0,0 +1,212 @@
import { BrowserWindow, ipcMain } from 'electron'
import { IPC } from '../../../shared/ipc-channels'
import type { AiContextBinding, PlotSelection } from '../../../shared/types'
import { BookRegistryService } from '../../services/book-registry'
import { GlobalSettingsService } from '../../services/global-settings'
import { AiClientService } from '../../services/ai-client.service'
import { aiContextBuilder } from '../../services/ai-context-builder.service'
import { checkInteractiveGate } from '../../services/interactive-gate.service'
import { InteractiveWritingService } from '../../services/interactive-writing.service'
import { wrap } from '../result'
const activeInteractive = new Map<string, AbortController>()
function serviceFor(registry: BookRegistryService, bookId: string, aiClient: AiClientService) {
return new InteractiveWritingService(registry.getDb(bookId), aiClient)
}
function enrich(registry: BookRegistryService, bookId: string, aiClient: AiClientService, sessionId: string) {
const svc = serviceFor(registry, bookId, aiClient)
const flow = svc.getFlow(sessionId)
return flow ? svc.enrichFlow(flow) : null
}
export function registerInteractiveHandlers(
registry: BookRegistryService,
settings: GlobalSettingsService,
aiClient: AiClientService
): void {
ipcMain.handle(IPC.INTERACTIVE_CHECK_GATE, (_e, { bookId }: { bookId: string }) =>
wrap(() => checkInteractiveGate(registry.getDb(bookId)))
)
ipcMain.handle(
IPC.INTERACTIVE_GET_FLOW,
(_e, { bookId, sessionId }: { bookId: string; sessionId: string }) =>
wrap(() => enrich(registry, bookId, aiClient, sessionId))
)
ipcMain.handle(
IPC.INTERACTIVE_START,
(_e, { bookId, sessionId }: { bookId: string; sessionId: string }) =>
wrap(() => {
const flow = serviceFor(registry, bookId, aiClient).start(sessionId)
return serviceFor(registry, bookId, aiClient).enrichFlow(flow)
})
)
ipcMain.handle(
IPC.INTERACTIVE_CONFIRM_CONTEXT,
(
_e,
{
bookId,
flowId,
binding
}: { bookId: string; flowId: string; binding: AiContextBinding }
) =>
wrap(() => {
const built = aiContextBuilder.build(binding, registry, bookId, settings.get().penName)
const summary = built.preview.map((p) => p.title).join('、') || '(空)'
const flow = serviceFor(registry, bookId, aiClient).confirmContext(
flowId,
binding,
summary
)
return serviceFor(registry, bookId, aiClient).enrichFlow(flow)
})
)
ipcMain.handle(
IPC.INTERACTIVE_SUGGEST_PLOTS,
(_e, { bookId, flowId }: { bookId: string; flowId: string }) =>
wrap(() => serviceFor(registry, bookId, aiClient).suggestPlots(flowId))
)
ipcMain.handle(
IPC.INTERACTIVE_SELECT_PLOT,
(
_e,
{ bookId, flowId, selection }: { bookId: string; flowId: string; selection: PlotSelection }
) =>
wrap(() => {
const flow = serviceFor(registry, bookId, aiClient).selectPlot(flowId, selection)
return serviceFor(registry, bookId, aiClient).enrichFlow(flow)
})
)
ipcMain.handle(
IPC.INTERACTIVE_GENERATE_SCENE,
(event, { bookId, flowId }: { bookId: string; flowId: string }) =>
wrap(async () => {
const controller = new AbortController()
activeInteractive.set(flowId, controller)
const win = BrowserWindow.fromWebContents(event.sender)
try {
const flow = await serviceFor(registry, bookId, aiClient).generateScene(
flowId,
(delta, done) => {
win?.webContents.send(IPC.INTERACTIVE_STREAM_CHUNK, {
flowId,
phase: 'scene',
delta,
done
})
},
controller.signal
)
return serviceFor(registry, bookId, aiClient).enrichFlow(flow)
} finally {
activeInteractive.delete(flowId)
}
})
)
ipcMain.handle(
IPC.INTERACTIVE_RESOLVE_NAMING,
(
event,
{ bookId, flowId, chosenName }: { bookId: string; flowId: string; chosenName: string }
) =>
wrap(async () => {
const controller = new AbortController()
activeInteractive.set(flowId, controller)
const win = BrowserWindow.fromWebContents(event.sender)
try {
const flow = await serviceFor(registry, bookId, aiClient).resolveNaming(
flowId,
chosenName,
(delta, done) => {
win?.webContents.send(IPC.INTERACTIVE_STREAM_CHUNK, {
flowId,
phase: 'naming_resume',
delta,
done
})
},
controller.signal
)
return serviceFor(registry, bookId, aiClient).enrichFlow(flow)
} finally {
activeInteractive.delete(flowId)
}
})
)
ipcMain.handle(
IPC.INTERACTIVE_REFINE_SCENE,
(
event,
{ bookId, flowId, instruction }: { bookId: string; flowId: string; instruction: string }
) =>
wrap(async () => {
const controller = new AbortController()
activeInteractive.set(flowId, controller)
const win = BrowserWindow.fromWebContents(event.sender)
try {
const flow = await serviceFor(registry, bookId, aiClient).refineScene(
flowId,
instruction,
(delta, done) => {
win?.webContents.send(IPC.INTERACTIVE_STREAM_CHUNK, {
flowId,
phase: 'refine',
delta,
done
})
},
controller.signal
)
return serviceFor(registry, bookId, aiClient).enrichFlow(flow)
} finally {
activeInteractive.delete(flowId)
}
})
)
ipcMain.handle(
IPC.INTERACTIVE_ACCEPT_SCENE,
(_e, { bookId, flowId }: { bookId: string; flowId: string }) =>
wrap(() => {
const flow = serviceFor(registry, bookId, aiClient).acceptScene(flowId)
return serviceFor(registry, bookId, aiClient).enrichFlow(flow)
})
)
ipcMain.handle(
IPC.INTERACTIVE_FINISH_CHAPTER,
(
_e,
{
bookId,
flowId,
volumeId,
title
}: { bookId: string; flowId: string; volumeId: string; title?: string }
) =>
wrap(() => serviceFor(registry, bookId, aiClient).finishChapter(flowId, volumeId, title))
)
ipcMain.handle(IPC.INTERACTIVE_ABORT, (_e, { flowId }: { flowId: string }) =>
wrap(() => {
activeInteractive.get(flowId)?.abort()
activeInteractive.delete(flowId)
})
)
ipcMain.handle(
IPC.INTERACTIVE_GET_SCENE_DRAFT,
(_e, { bookId, flowId }: { bookId: string; flowId: string }) =>
wrap(() => serviceFor(registry, bookId, aiClient).getSceneDraft(flowId))
)
}
+2
View File
@@ -15,6 +15,7 @@ import { registerSnapshotHandlers } from './handlers/snapshot.handler'
import { registerBookmarkHandlers } from './handlers/bookmark.handler'
import { registerSearchHandlers } from './handlers/search.handler'
import { registerAiHandlers } from './handlers/ai.handler'
import { registerInteractiveHandlers } from './handlers/interactive.handler'
import { AiClientService } from '../services/ai-client.service'
import { NetworkMonitorService } from '../services/network-monitor.service'
@@ -42,6 +43,7 @@ export function registerIpc(): { settings: GlobalSettingsService; books: BookReg
registerBookmarkHandlers(books)
registerSearchHandlers(books, settings)
registerAiHandlers(books, settings, new AiClientService(() => settings.get().aiConfig))
registerInteractiveHandlers(books, settings, new AiClientService(() => settings.get().aiConfig))
return { settings, books, shortcuts: shortcutManager }
}
+5
View File
@@ -8,6 +8,7 @@ import { OutlineRepository } from '../db/repositories/outline.repo'
import { SettingRepository } from '../db/repositories/setting.repo'
import { InspirationRepository } from '../db/repositories/inspiration.repo'
import { AiSessionRepository } from '../db/repositories/ai-session.repo'
import { InteractiveRepository } from '../db/repositories/interactive.repo'
import { SnapshotRepository } from '../db/repositories/snapshot.repo'
import { BookmarkRepository } from '../db/repositories/bookmark.repo'
import type { BookMeta, BookOpenResult, CreateBookParams, Chapter, Volume } from '../../shared/types'
@@ -150,6 +151,10 @@ export class BookRegistryService {
return new AiSessionRepository(this.getDb(bookId))
}
getInteractiveRepo(bookId: string): InteractiveRepository {
return new InteractiveRepository(this.getDb(bookId))
}
getSnapshotRepo(bookId: string): SnapshotRepository {
return new SnapshotRepository(this.getDb(bookId))
}
@@ -0,0 +1,24 @@
import type { InteractiveGateResult } from '../../shared/types'
import type { SqliteDb } from '../db/types'
import { stripHtml } from './word-count'
export function checkInteractiveGate(db: SqliteDb): InteractiveGateResult {
const chapters = db.prepare('SELECT content FROM chapters').all() as { content: string }[]
for (const ch of chapters) {
if (stripHtml(ch.content ?? '').trim().length > 0) {
return { eligible: true }
}
}
const outlineCount = db.prepare('SELECT COUNT(*) AS c FROM outline_items').get() as { c: number }
const settingCount = db.prepare('SELECT COUNT(*) AS c FROM settings').get() as { c: number }
if (outlineCount.c > 0 || settingCount.c > 0) {
return { eligible: true }
}
const anyChapter = db.prepare('SELECT COUNT(*) AS c FROM chapters').get() as { c: number }
return {
eligible: false,
reason: anyChapter.c === 0 ? 'no_chapter' : 'no_outline_or_setting'
}
}
@@ -0,0 +1,47 @@
import type { NamingPause, PlotOption } from '../../shared/types'
export function parsePlotsJson(response: string): PlotOption[] {
const trimmed = response.trim()
const start = trimmed.indexOf('{')
const end = trimmed.lastIndexOf('}')
if (start < 0 || end <= start) throw new Error('plots JSON not found')
const parsed = JSON.parse(trimmed.slice(start, end + 1)) as { plots?: unknown }
if (!Array.isArray(parsed.plots) || parsed.plots.length < 3) {
throw new Error('plots array invalid')
}
const ids = ['A', 'B', 'C'] as const
return parsed.plots.slice(0, 3).map((p, i) => {
const row = p as Record<string, unknown>
return {
id: ids[i],
label: String(row.label ?? row.id ?? ids[i]),
summary: String(row.summary ?? '')
}
})
}
export function parseNamingRequiredJson(response: string): NamingPause {
const trimmed = response.trim()
const start = trimmed.indexOf('{')
const end = trimmed.lastIndexOf('}')
if (start < 0 || end <= start) throw new Error('naming JSON not found')
const parsed = JSON.parse(trimmed.slice(start, end + 1)) as Record<string, unknown>
if (parsed.type !== 'naming_required') throw new Error('not naming_required')
const options = Array.isArray(parsed.options) ? parsed.options.map(String) : []
if (options.length < 5) throw new Error('naming options < 5')
return {
elementType: String(parsed.elementType ?? '元素'),
context: String(parsed.context ?? ''),
options: options.slice(0, 5)
}
}
export function tryParseNamingFromBuffer(buffer: string): NamingPause | null {
const t = buffer.trim()
if (!t.startsWith('{') || !t.includes('naming_required')) return null
try {
return parseNamingRequiredJson(t)
} catch {
return null
}
}
@@ -0,0 +1,74 @@
import type { PlotSelection } from '../../shared/types'
import type { ChatMessage } from './ai-client.service'
export function buildPlotSuggestMessages(contextText: string, sceneSummaries: string[]): ChatMessage[] {
const prior =
sceneSummaries.length > 0
? `已确认场景摘要:\n${sceneSummaries.map((s, i) => `${i + 1}. ${s}`).join('\n')}\n\n`
: ''
return [
{
role: 'system',
content:
'你是长篇小说创作助手。根据上下文给出 3 个互斥的剧情走向建议。仅返回 JSON,不要 markdown。格式:{"plots":[{"id":"A","label":"标题","summary":"至少50字的剧情说明"},...]},恰好 3 条。'
},
{
role: 'user',
content: `${prior}上下文:\n${contextText}\n\n请给出接下来剧情的 3 个走向(A/B/C)。`
}
]
}
export function buildSceneGenerateMessages(
contextText: string,
selection: PlotSelection,
sceneSummaries: string[]
): ChatMessage[] {
const plotDesc = selection.optionIds.join('、')
const note = selection.userNote ? `\n用户补充:${selection.userNote}` : ''
const prior =
sceneSummaries.length > 0
? `已写场景:\n${sceneSummaries.join('\n---\n')}\n\n`
: ''
return [
{
role: 'system',
content:
'你是小说场景写作助手。若场景中需要为新角色/地点/技能等命名且尚无定论,先仅返回一行 JSON{"type":"naming_required","elementType":"类型","context":"说明","options":["名1","名2","名3","名4","名5"]}。否则直接输出 600-1500 字的场景正文,使用 HTML <p> 段落,不要其它说明。'
},
{
role: 'user',
content: `${prior}上下文:\n${contextText}\n\n选定走向:${plotDesc}${note}\n\n请生成本场景。`
}
]
}
export function buildNamingResumeMessages(
chosenName: string,
partialScene: string,
context: string
): ChatMessage[] {
return [
{
role: 'system',
content: '继续完成小说场景,输出 HTML <p> 段落,将选定名称写入正文。'
},
{
role: 'user',
content: `命名说明:${context}\n选定名称:${chosenName}\n已有片段:${partialScene}\n\n请续写并完成本场景。`
}
]
}
export function buildRefineMessages(currentSceneHtml: string, instruction: string): ChatMessage[] {
return [
{
role: 'system',
content: '根据用户指令修订当前场景,输出完整修订后的 HTML <p> 段落,不要解释。'
},
{
role: 'user',
content: `当前场景:\n${currentSceneHtml}\n\n修改指令:${instruction}`
}
]
}
@@ -0,0 +1,241 @@
import type {
AiContextBinding,
InteractiveFlow,
PlotOption,
PlotSelection
} from '../../shared/types'
import type { SqliteDb } from '../db/types'
import { ChapterRepository } from '../db/repositories/chapter.repo'
import { InteractiveRepository } from '../db/repositories/interactive.repo'
import type { AiClientService } from './ai-client.service'
import {
buildNamingResumeMessages,
buildPlotSuggestMessages,
buildRefineMessages,
buildSceneGenerateMessages
} from './interactive-prompt-builder.service'
import { parsePlotsJson, tryParseNamingFromBuffer } from './interactive-parse.service'
import { stripHtml } from './word-count'
export class InteractiveWritingService {
private repo: InteractiveRepository
constructor(
private db: SqliteDb,
private aiClient: AiClientService
) {
this.repo = new InteractiveRepository(db)
}
start(sessionId: string): InteractiveFlow {
return this.repo.resetInProgress(sessionId)
}
getFlow(sessionId: string): InteractiveFlow | null {
return this.repo.getBySession(sessionId)
}
confirmContext(flowId: string, binding: AiContextBinding, contextSummary: string): InteractiveFlow {
this.repo.updateStep(flowId, 'plot_suggest', {
contextJson: JSON.stringify({ binding, contextSummary })
})
return this.repo.get(flowId)!
}
async suggestPlots(flowId: string): Promise<PlotOption[]> {
const contextText = this.readContextText(flowId)
const scenes = this.repo.listScenes(flowId)
const summaries = scenes.map((s) => stripHtml(s.contentHtml).slice(0, 120))
const messages = buildPlotSuggestMessages(contextText, summaries)
let response = await this.aiClient.chat(messages)
try {
const plots = parsePlotsJson(response)
this.repo.updateStep(flowId, 'plot_select')
return plots
} catch {
response = await this.aiClient.chat([
...messages,
{ role: 'user', content: '仅返回 JSON,不要 markdown 或其它说明。' }
])
const plots = parsePlotsJson(response)
this.repo.updateStep(flowId, 'plot_select')
return plots
}
}
selectPlot(flowId: string, selection: PlotSelection): InteractiveFlow {
this.repo.updateStep(flowId, 'scene_generate', {
plotSelectionJson: JSON.stringify(selection),
sceneDraftHtml: '',
namingPendingJson: null
})
return this.repo.get(flowId)!
}
async generateScene(
flowId: string,
onChunk: (delta: string, done: boolean) => void,
abortSignal?: AbortSignal
): Promise<InteractiveFlow> {
const flow = this.repo.get(flowId)
if (!flow) throw new Error('flow not found')
const contextText = this.readContextText(flowId)
const selection = JSON.parse(this.repo.getPlotSelection(flowId)) as PlotSelection
const scenes = this.repo.listScenes(flowId)
const summaries = scenes.map((s) => stripHtml(s.contentHtml).slice(0, 200))
const messages = buildSceneGenerateMessages(contextText, selection, summaries)
let buffer = ''
const full = await this.aiClient.chat(
messages,
(delta) => {
buffer += delta
const naming = tryParseNamingFromBuffer(buffer)
if (naming) return
onChunk(delta, false)
},
abortSignal
)
const naming = tryParseNamingFromBuffer(full || buffer)
if (naming) {
this.repo.updateStep(flowId, 'naming_pause', {
namingPendingJson: JSON.stringify(naming),
sceneDraftHtml: ''
})
onChunk('', true)
return this.repo.get(flowId)!
}
const html = this.normalizeSceneHtml(full || buffer)
this.repo.setSceneDraft(flowId, html)
this.repo.updateStep(flowId, 'scene_review', { namingPendingJson: null })
onChunk('', true)
return this.repo.get(flowId)!
}
async resolveNaming(
flowId: string,
chosenName: string,
onChunk: (delta: string, done: boolean) => void,
abortSignal?: AbortSignal
): Promise<InteractiveFlow> {
const flow = this.repo.get(flowId)
if (!flow?.namingPending) throw new Error('no naming pause')
const messages = buildNamingResumeMessages(
chosenName,
this.repo.getSceneDraft(flowId),
flow.namingPending.context
)
const full = await this.aiClient.chat(
messages,
(delta) => onChunk(delta, false),
abortSignal
)
const html = this.normalizeSceneHtml(full)
this.repo.setSceneDraft(flowId, html)
this.repo.updateStep(flowId, 'scene_review', { namingPendingJson: null })
onChunk('', true)
return this.repo.get(flowId)!
}
async refineScene(
flowId: string,
instruction: string,
onChunk: (delta: string, done: boolean) => void,
abortSignal?: AbortSignal
): Promise<InteractiveFlow> {
const draft = this.repo.getSceneDraft(flowId)
const messages = buildRefineMessages(draft, instruction)
let buffer = ''
const full = await this.aiClient.chat(
messages,
(delta) => {
buffer += delta
onChunk(delta, false)
},
abortSignal
)
const html = this.normalizeSceneHtml(full || buffer)
this.repo.setSceneDraft(flowId, html)
onChunk('', true)
return this.repo.get(flowId)!
}
acceptScene(flowId: string): InteractiveFlow {
const draft = this.repo.getSceneDraft(flowId)
if (!draft.trim()) throw new Error('empty scene draft')
const selection = JSON.parse(this.repo.getPlotSelection(flowId)) as PlotSelection
const plotLabel = selection.optionIds.join('+')
this.repo.addScene(flowId, draft, plotLabel)
this.repo.setSceneDraft(flowId, '')
this.repo.updateStep(flowId, 'plot_suggest', {
plotSelectionJson: null,
namingPendingJson: null
})
return this.repo.get(flowId)!
}
finishChapter(flowId: string, volumeId: string, title?: string): { chapterId: string } {
const draft = this.repo.getSceneDraft(flowId).trim()
if (draft) {
const selection = JSON.parse(this.repo.getPlotSelection(flowId) || '{}') as PlotSelection
this.repo.addScene(flowId, draft, selection.optionIds?.join('+') ?? '')
this.repo.setSceneDraft(flowId, '')
}
const scenes = this.repo.listScenes(flowId)
if (scenes.length === 0) throw new Error('no scenes to merge')
const merged = scenes.map((s) => s.contentHtml).join('\n')
const chapters = new ChapterRepository(this.db)
const sortOrder = chapters.listByVolume(volumeId).length
const ch = chapters.create(volumeId, title ?? '交互初稿', sortOrder, merged, 'interactive')
this.repo.updateStep(flowId, 'completed', {
status: 'completed',
namingPendingJson: null,
sceneDraftHtml: ''
})
return { chapterId: ch.id }
}
getSceneDraft(flowId: string): string {
return this.repo.getSceneDraft(flowId)
}
enrichFlow(flow: InteractiveFlow): InteractiveFlow {
const raw = this.repo.getContextJson(flow.id)
try {
const parsed = JSON.parse(raw) as { contextSummary?: string }
return { ...flow, contextSummary: parsed.contextSummary }
} catch {
return flow
}
}
private readContextText(flowId: string): string {
try {
const parsed = JSON.parse(this.repo.getContextJson(flowId)) as {
contextSummary?: string
binding?: AiContextBinding
}
return parsed.contextSummary ?? '(无上下文摘要)'
} catch {
return '(无上下文摘要)'
}
}
private normalizeSceneHtml(text: string): string {
const t = text.trim()
if (!t) return ''
if (t.includes('<p>')) return t
return t
.split(/\n\n+/)
.map((p) => `<p>${p.trim()}</p>`)
.join('')
}
}
+61
View File
@@ -17,6 +17,11 @@ import type {
NamingConflict,
AiStreamChunkEvent,
AiNetworkStatusEvent,
InteractiveFlow,
InteractiveGateResult,
InteractiveStreamChunkEvent,
PlotOption,
PlotSelection,
IpcResult,
LandmarkType,
OutlineItem,
@@ -264,6 +269,55 @@ const electronAPI = {
namingCheck: (bookId: string): Promise<IpcResult<NamingConflict[]>> =>
ipcRenderer.invoke(IPC.AI_NAMING_CHECK, { bookId })
},
interactive: {
checkGate: (bookId: string): Promise<IpcResult<InteractiveGateResult>> =>
ipcRenderer.invoke(IPC.INTERACTIVE_CHECK_GATE, { bookId }),
getFlow: (bookId: string, sessionId: string): Promise<IpcResult<InteractiveFlow | null>> =>
ipcRenderer.invoke(IPC.INTERACTIVE_GET_FLOW, { bookId, sessionId }),
start: (bookId: string, sessionId: string): Promise<IpcResult<InteractiveFlow>> =>
ipcRenderer.invoke(IPC.INTERACTIVE_START, { bookId, sessionId }),
confirmContext: (
bookId: string,
flowId: string,
binding: AiContextBinding
): Promise<IpcResult<InteractiveFlow>> =>
ipcRenderer.invoke(IPC.INTERACTIVE_CONFIRM_CONTEXT, { bookId, flowId, binding }),
suggestPlots: (bookId: string, flowId: string): Promise<IpcResult<PlotOption[]>> =>
ipcRenderer.invoke(IPC.INTERACTIVE_SUGGEST_PLOTS, { bookId, flowId }),
selectPlot: (
bookId: string,
flowId: string,
selection: PlotSelection
): Promise<IpcResult<InteractiveFlow>> =>
ipcRenderer.invoke(IPC.INTERACTIVE_SELECT_PLOT, { bookId, flowId, selection }),
generateScene: (bookId: string, flowId: string): Promise<IpcResult<InteractiveFlow>> =>
ipcRenderer.invoke(IPC.INTERACTIVE_GENERATE_SCENE, { bookId, flowId }),
resolveNaming: (
bookId: string,
flowId: string,
chosenName: string
): Promise<IpcResult<InteractiveFlow>> =>
ipcRenderer.invoke(IPC.INTERACTIVE_RESOLVE_NAMING, { bookId, flowId, chosenName }),
refineScene: (
bookId: string,
flowId: string,
instruction: string
): Promise<IpcResult<InteractiveFlow>> =>
ipcRenderer.invoke(IPC.INTERACTIVE_REFINE_SCENE, { bookId, flowId, instruction }),
acceptScene: (bookId: string, flowId: string): Promise<IpcResult<InteractiveFlow>> =>
ipcRenderer.invoke(IPC.INTERACTIVE_ACCEPT_SCENE, { bookId, flowId }),
finishChapter: (
bookId: string,
flowId: string,
volumeId: string,
title?: string
): Promise<IpcResult<{ chapterId: string }>> =>
ipcRenderer.invoke(IPC.INTERACTIVE_FINISH_CHAPTER, { bookId, flowId, volumeId, title }),
abort: (flowId: string): Promise<IpcResult<void>> =>
ipcRenderer.invoke(IPC.INTERACTIVE_ABORT, { flowId }),
getSceneDraft: (bookId: string, flowId: string): Promise<IpcResult<string>> =>
ipcRenderer.invoke(IPC.INTERACTIVE_GET_SCENE_DRAFT, { bookId, flowId })
},
wordfreq: {
analyze: (
bookId: string,
@@ -297,6 +351,13 @@ const electronAPI = {
}
ipcRenderer.on(IPC.AI_NETWORK_STATUS, handler)
return () => ipcRenderer.removeListener(IPC.AI_NETWORK_STATUS, handler)
},
onInteractiveStreamChunk: (callback: (payload: InteractiveStreamChunkEvent) => void): (() => void) => {
const handler = (_event: IpcRendererEvent, payload: InteractiveStreamChunkEvent) => {
callback(payload)
}
ipcRenderer.on(IPC.INTERACTIVE_STREAM_CHUNK, handler)
return () => ipcRenderer.removeListener(IPC.INTERACTIVE_STREAM_CHUNK, handler)
}
}
+55 -13
View File
@@ -9,6 +9,8 @@ import { applySlashCommand } from '@renderer/lib/ai-slash-commands'
import { insertToEditor } from '@renderer/lib/editor-commands'
import { ipcCall } from '@renderer/lib/ipc-client'
import { ContextEditorModal } from '@renderer/components/ai/ContextEditorModal'
import { InteractivePanel } from '@renderer/components/ai/InteractivePanel'
import { useInteractiveStore } from '@renderer/stores/useInteractiveStore'
const MODES: { id: AiWritingMode; labelKey: string }[] = [
{ id: 'chat', labelKey: 'ai.mode.chat' },
@@ -39,6 +41,9 @@ export function AiPanel(): React.JSX.Element {
const createSession = useAiStore((s) => s.createSession)
const sendMessage = useAiStore((s) => s.sendMessage)
const setWritingMode = useAiStore((s) => s.setWritingMode)
const gateEligible = useInteractiveStore((s) => s.gateEligible)
const checkGate = useInteractiveStore((s) => s.checkGate)
const startInteractive = useInteractiveStore((s) => s.start)
const [input, setInput] = useState('')
const [contextOpen, setContextOpen] = useState(false)
const messagesEndRef = useRef<HTMLDivElement>(null)
@@ -57,8 +62,11 @@ export function AiPanel(): React.JSX.Element {
}, [])
useEffect(() => {
if (bookId) void loadSessions(bookId)
}, [bookId, loadSessions])
if (bookId) {
void loadSessions(bookId)
void checkGate(bookId)
}
}, [bookId, loadSessions, checkGate])
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
@@ -86,10 +94,32 @@ export function AiPanel(): React.JSX.Element {
}
const handleModeClick = (mode: AiWritingMode): void => {
if (mode !== 'chat') {
if (mode === 'auto' || mode === 'wizard') {
showToast(t('feature.comingSoon'))
return
}
if (mode === 'interactive') {
void (async () => {
if (bookId) await checkGate(bookId)
if (!useInteractiveStore.getState().gateEligible) {
showToast(t('interactive.gateBlocked'))
return
}
setWritingMode(mode)
const sid = activeSessionId ?? useAiStore.getState().activeSessionId
if (!bookId) return
if (!sid) {
await createSession(bookId)
}
const sessionId = useAiStore.getState().activeSessionId
if (!sessionId) return
await useInteractiveStore.getState().loadFlow(bookId, sessionId)
if (!useInteractiveStore.getState().flow) {
await startInteractive(bookId, sessionId)
}
})()
return
}
setWritingMode(mode)
}
@@ -128,18 +158,28 @@ export function AiPanel(): React.JSX.Element {
</div>
<div className="ai-mode-tabs" role="tablist">
{MODES.map((mode) => (
<button
key={mode.id}
type="button"
className={`ai-mode-tab ${writingMode === mode.id ? 'active' : ''}`}
onClick={() => handleModeClick(mode.id)}
>
{t(mode.labelKey)}
</button>
))}
{MODES.map((mode) => {
const interactiveDisabled = mode.id === 'interactive' && !gateEligible
return (
<button
key={mode.id}
type="button"
className={`ai-mode-tab ${writingMode === mode.id ? 'active' : ''}`}
data-testid={`ai-mode-tab-${mode.id}`}
disabled={interactiveDisabled}
title={interactiveDisabled ? t('interactive.gateTooltip') : undefined}
onClick={() => handleModeClick(mode.id)}
>
{t(mode.labelKey)}
</button>
)
})}
</div>
{writingMode === 'interactive' ? (
<InteractivePanel bookId={bookId} sessionId={activeSessionId} disabled={disabled} />
) : (
<>
<button
type="button"
className="context-preview"
@@ -222,6 +262,8 @@ export function AiPanel(): React.JSX.Element {
{t('ai.send')}
</button>
</div>
</>
)}
</div>
<ContextEditorModal open={contextOpen} onClose={() => setContextOpen(false)} />
</>
@@ -0,0 +1,267 @@
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import type { PlotSelection } from '@shared/types'
import { useSettingsStore } from '@renderer/stores/useSettingsStore'
import { useInteractiveStore } from '@renderer/stores/useInteractiveStore'
import { ContextEditorModal } from '@renderer/components/ai/ContextEditorModal'
import { PlotOptionCard } from '@renderer/components/ai/PlotOptionCard'
interface InteractivePanelProps {
bookId: string
sessionId: string | null
disabled: boolean
}
export function InteractivePanel({
bookId,
sessionId,
disabled
}: InteractivePanelProps): React.JSX.Element {
const { t } = useTranslation()
const backend = useSettingsStore((s) => s.aiConfig.backend)
const {
flow,
plots,
streamBuffer,
streaming,
sceneDraft,
loadFlow,
confirmContextAndStart,
selectPlot,
resolveNaming,
refineScene,
acceptScene,
finishChapter,
mount,
unmount
} = useInteractiveStore()
const [contextOpen, setContextOpen] = useState(false)
const [selectedPlots, setSelectedPlots] = useState<Array<'A' | 'B' | 'C'>>([])
const [userNote, setUserNote] = useState('')
const [refineInput, setRefineInput] = useState('')
const [customName, setCustomName] = useState('')
const [chapterTitle, setChapterTitle] = useState('')
useEffect(() => {
mount()
return () => unmount()
}, [mount, unmount])
useEffect(() => {
if (bookId && sessionId) void loadFlow(bookId, sessionId)
}, [bookId, sessionId, loadFlow])
if (!sessionId || !flow) {
return (
<div className="interactive-panel" data-testid="interactive-panel">
<p className="placeholder-box">{t('ai.noSession')}</p>
</div>
)
}
const step = flow.step
const previewHtml = streaming ? streamBuffer : sceneDraft
const showReview =
step === 'scene_review' ||
(!streaming && sceneDraft.trim().length > 0 && step === 'scene_generate')
const showFinishChapter = flow.scenes.length > 0 && flow.status !== 'completed'
const handleStart = (): void => {
void confirmContextAndStart(bookId, flow.id)
}
const handleConfirmPlot = (): void => {
if (selectedPlots.length === 0) return
const selection: PlotSelection = { optionIds: selectedPlots, userNote: userNote.trim() || undefined }
void selectPlot(bookId, selection)
setSelectedPlots([])
setUserNote('')
}
const togglePlot = (id: 'A' | 'B' | 'C'): void => {
setSelectedPlots((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]))
}
return (
<>
<div className="interactive-panel" data-testid="interactive-panel">
<div className="interactive-step-bar" data-testid="interactive-step-indicator">
{t('interactive.step.context')} {t('interactive.step.plot')} {t('interactive.step.scene')} {' '}
{t('interactive.step.review')}
</div>
{step === 'context_confirm' && (
<div className="interactive-section">
<p>{flow.contextSummary ?? t('ai.contextEmpty')}</p>
<button type="button" className="btn" onClick={() => setContextOpen(true)}>
{t('ai.contextEditorTitle')}
</button>
<button
type="button"
className="btn primary"
data-testid="interactive-start"
disabled={disabled || streaming}
onClick={handleStart}
>
{t('interactive.start')}
</button>
</div>
)}
{(step === 'plot_suggest' || step === 'plot_select') && (
<div className="interactive-section">
{plots.length === 0 ? (
<p className="placeholder-box">{streaming ? t('ai.sending') : t('interactive.loadingPlots')}</p>
) : (
<>
<div className="plot-options">
{plots.map((p) => (
<PlotOptionCard
key={p.id}
option={p}
selected={selectedPlots.includes(p.id)}
onSelect={() => togglePlot(p.id)}
/>
))}
</div>
<textarea
className="form-control"
placeholder={t('interactive.plotNotePlaceholder')}
value={userNote}
onChange={(e) => setUserNote(e.target.value)}
rows={2}
/>
<button
type="button"
className="btn primary"
data-testid="interactive-confirm-plot"
disabled={disabled || selectedPlots.length === 0 || streaming}
onClick={handleConfirmPlot}
>
{t('interactive.generateScene')}
</button>
</>
)}
</div>
)}
{(step === 'scene_generate' || step === 'scene_review') && !showReview && (
<div className="interactive-section">
<div
className="interactive-scene-preview"
data-testid="interactive-scene-preview"
dangerouslySetInnerHTML={{ __html: previewHtml || '<p>…</p>' }}
/>
</div>
)}
{step === 'naming_pause' && flow.namingPending && !showReview && (
<div className="interactive-section naming-options">
<h4>{t('interactive.namingTitle', { type: flow.namingPending.elementType })}</h4>
<p>{flow.namingPending.context}</p>
{flow.namingPending.options.map((name, i) => (
<button
key={name}
type="button"
className="btn"
data-testid={`naming-option-${i}`}
disabled={disabled || streaming}
onClick={() => void resolveNaming(bookId, name)}
>
{name}
</button>
))}
<div className="naming-custom">
<input
className="form-control"
data-testid="naming-custom"
value={customName}
onChange={(e) => setCustomName(e.target.value)}
placeholder={t('interactive.customName')}
/>
<button
type="button"
className="btn primary"
disabled={disabled || streaming || !customName.trim()}
onClick={() => void resolveNaming(bookId, customName.trim())}
>
{t('interactive.useCustomName')}
</button>
</div>
</div>
)}
{showReview && (
<div className="interactive-section">
<div
className="interactive-scene-preview"
data-testid="interactive-scene-preview"
dangerouslySetInnerHTML={{ __html: previewHtml || '<p>…</p>' }}
/>
<textarea
className="form-control"
data-testid="interactive-refine-input"
placeholder={t('interactive.refinePlaceholder')}
value={refineInput}
onChange={(e) => setRefineInput(e.target.value)}
rows={2}
/>
<div className="interactive-actions">
<button
type="button"
className="btn"
disabled={disabled || streaming || !refineInput.trim()}
onClick={() => {
void refineScene(bookId, refineInput)
setRefineInput('')
}}
>
{t('interactive.refine')}
</button>
<button
type="button"
className="btn primary"
data-testid="interactive-accept-scene"
disabled={disabled || streaming || !sceneDraft.trim()}
onClick={() => void acceptScene(bookId)}
>
{t('interactive.acceptScene')}
</button>
</div>
</div>
)}
{showFinishChapter && (
<div className="interactive-section interactive-finish-section">
<div className="interactive-finish">
<input
className="form-control"
value={chapterTitle}
onChange={(e) => setChapterTitle(e.target.value)}
placeholder={t('interactive.chapterTitlePlaceholder')}
/>
<button
type="button"
className="btn primary"
data-testid="interactive-finish-chapter"
disabled={disabled || streaming}
onClick={() => void finishChapter(bookId, chapterTitle.trim() || undefined)}
>
{t('interactive.finishChapter')}
</button>
</div>
<p className="interactive-scene-count">
{t('interactive.sceneCount', { count: flow.scenes.length })}
</p>
</div>
)}
{disabled && backend !== 'lmstudio' && (
<div className="offline-banner show">{t('ai.offlineBanner')}</div>
)}
</div>
<ContextEditorModal open={contextOpen} onClose={() => setContextOpen(false)} />
</>
)
}
@@ -0,0 +1,21 @@
import type { PlotOption } from '@shared/types'
interface PlotOptionCardProps {
option: PlotOption
selected: boolean
onSelect: () => void
}
export function PlotOptionCard({ option, selected, onSelect }: PlotOptionCardProps): React.JSX.Element {
return (
<button
type="button"
className={`plot-option-card ${selected ? 'selected' : ''}`}
data-testid={`plot-option-${option.id}`}
onClick={onSelect}
>
<span className="opt-label">{option.label}</span>
<p className="plot-option-summary">{option.summary}</p>
</button>
)
}
@@ -1,5 +1,7 @@
import { useTranslation } from 'react-i18next'
import { useAppStore } from '@renderer/stores/useAppStore'
import { useBookStore } from '@renderer/stores/useBookStore'
import { useInteractiveStore } from '@renderer/stores/useInteractiveStore'
import { WordFreqPanel } from '@renderer/components/wordfreq/WordFreqPanel'
import { AiPanel } from '@renderer/components/ai/AiPanel'
import { KnowledgePanel } from '@renderer/components/knowledge/KnowledgePanel'
@@ -24,7 +26,13 @@ export function RightPanel(): React.JSX.Element {
type="button"
className={`panel-tab ${rightPanel === tab.id ? 'active' : ''}`}
data-testid={`panel-tab-${tab.id}`}
onClick={() => setRightPanel(tab.id)}
onClick={() => {
if (tab.id === 'ai') {
const bookId = useBookStore.getState().currentBookId
if (bookId) void useInteractiveStore.getState().checkGate(bookId)
}
setRightPanel(tab.id)
}}
>
{tab.label}
</button>
@@ -103,7 +103,7 @@ export function SettingsPage(): React.JSX.Element {
{section === 'shortcuts' && <ShortcutEditor />}
{section === 'about' && (
<p style={{ color: 'var(--text-secondary)', lineHeight: 1.8 }}>
{t('app.name')} v0.3.0
{t('app.name')} v0.4.0
<br />
{t('app.tagline')}
</p>
+198
View File
@@ -0,0 +1,198 @@
import { create } from 'zustand'
import type { InteractiveFlow, PlotOption, PlotSelection } from '@shared/types'
import { ipcCall } from '@renderer/lib/ipc-client'
import { useBookStore } from '@renderer/stores/useBookStore'
import { useAppStore } from '@renderer/stores/useAppStore'
import { useEditStore } from '@renderer/stores/useEditStore'
import { useAiStore } from '@renderer/stores/useAiStore'
import i18n from '@renderer/i18n'
interface InteractiveState {
gateEligible: boolean
gateReason?: string
flow: InteractiveFlow | null
plots: PlotOption[]
streamBuffer: string
streaming: boolean
sceneDraft: string
listenersAttached: boolean
checkGate: (bookId: string) => Promise<void>
loadFlow: (bookId: string, sessionId: string) => Promise<void>
start: (bookId: string, sessionId: string) => Promise<void>
confirmContextAndStart: (bookId: string, flowId: string) => Promise<void>
suggestPlots: (bookId: string) => Promise<void>
selectPlot: (bookId: string, selection: PlotSelection) => Promise<void>
generateScene: (bookId: string) => Promise<void>
resolveNaming: (bookId: string, chosenName: string) => Promise<void>
refineScene: (bookId: string, instruction: string) => Promise<void>
acceptScene: (bookId: string) => Promise<void>
finishChapter: (bookId: string, title?: string) => Promise<void>
refreshSceneDraft: (bookId: string) => Promise<void>
mount: () => void
unmount: () => void
}
let unsubStream: (() => void) | null = null
export const useInteractiveStore = create<InteractiveState>((set, get) => ({
gateEligible: false,
flow: null,
plots: [],
streamBuffer: '',
streaming: false,
sceneDraft: '',
listenersAttached: false,
checkGate: async (bookId) => {
const r = await ipcCall(() => window.electronAPI.interactive.checkGate(bookId))
set({ gateEligible: r.eligible, gateReason: r.reason })
},
loadFlow: async (bookId, sessionId) => {
const flow = await ipcCall(() => window.electronAPI.interactive.getFlow(bookId, sessionId))
set({ flow })
if (flow) await get().refreshSceneDraft(bookId)
},
start: async (bookId, sessionId) => {
const flow = await ipcCall(() => window.electronAPI.interactive.start(bookId, sessionId))
set({ flow, plots: [], streamBuffer: '', sceneDraft: '' })
},
confirmContextAndStart: async (bookId, flowId) => {
const session = useAiStore.getState().sessions.find(
(s) => s.id === useAiStore.getState().activeSessionId
)
const binding = session?.context ?? {
chapterIds: [],
outlineIds: [],
settingIds: [],
inspirationIds: []
}
const flow = await ipcCall(() =>
window.electronAPI.interactive.confirmContext(bookId, flowId, binding)
)
set({ flow })
await get().suggestPlots(bookId)
},
suggestPlots: async (bookId) => {
const flowId = get().flow?.id
if (!flowId) return
set({ streaming: true })
try {
const plots = await ipcCall(() =>
window.electronAPI.interactive.suggestPlots(bookId, flowId)
)
const flow = await ipcCall(() => window.electronAPI.interactive.getFlow(bookId, get().flow!.sessionId))
set({ plots, flow })
} finally {
set({ streaming: false })
}
},
selectPlot: async (bookId, selection) => {
const flowId = get().flow?.id
if (!flowId) return
const flow = await ipcCall(() =>
window.electronAPI.interactive.selectPlot(bookId, flowId, selection)
)
set({ flow, streamBuffer: '', sceneDraft: '' })
await get().generateScene(bookId)
},
generateScene: async (bookId) => {
const flowId = get().flow?.id
if (!flowId) return
set({ streaming: true, streamBuffer: '' })
try {
const flow = await ipcCall(() =>
window.electronAPI.interactive.generateScene(bookId, flowId)
)
set({ flow })
await get().refreshSceneDraft(bookId)
} finally {
set({ streaming: false, streamBuffer: '' })
}
},
resolveNaming: async (bookId, chosenName) => {
const flowId = get().flow?.id
if (!flowId) return
set({ streaming: true, streamBuffer: '' })
try {
const flow = await ipcCall(() =>
window.electronAPI.interactive.resolveNaming(bookId, flowId, chosenName)
)
set({ flow })
await get().refreshSceneDraft(bookId)
} finally {
set({ streaming: false, streamBuffer: '' })
}
},
refineScene: async (bookId, instruction) => {
const flowId = get().flow?.id
if (!flowId || !instruction.trim()) return
set({ streaming: true, streamBuffer: '' })
try {
const flow = await ipcCall(() =>
window.electronAPI.interactive.refineScene(bookId, flowId, instruction.trim())
)
set({ flow })
await get().refreshSceneDraft(bookId)
} finally {
set({ streaming: false, streamBuffer: '' })
}
},
acceptScene: async (bookId) => {
const flowId = get().flow?.id
if (!flowId) return
const flow = await ipcCall(() => window.electronAPI.interactive.acceptScene(bookId, flowId))
set({ flow, plots: [], sceneDraft: '', streamBuffer: '' })
await get().suggestPlots(bookId)
},
finishChapter: async (bookId, title) => {
const flowId = get().flow?.id
const volumeId = useBookStore.getState().activeVolumeId
if (!flowId || !volumeId) throw new Error('no volume')
const { chapterId } = await ipcCall(() =>
window.electronAPI.interactive.finishChapter(bookId, flowId, volumeId, title)
)
await useBookStore.getState().refreshChapters()
await useBookStore.getState().openBook(bookId)
useBookStore.getState().setSelectedChapter(chapterId)
useEditStore.getState().setTarget({ kind: 'chapter', id: chapterId })
useAppStore.getState().setView('editor')
useAppStore.getState().showToast(i18n.t('interactive.chapterCreated'))
},
refreshSceneDraft: async (bookId) => {
const flowId = get().flow?.id
if (!flowId) return
const draft = await ipcCall(() =>
window.electronAPI.interactive.getSceneDraft(bookId, flowId)
)
set({ sceneDraft: draft })
},
mount: () => {
if (get().listenersAttached) return
unsubStream = window.electronAPI.onInteractiveStreamChunk((payload) => {
if (payload.done) return
set((s) => ({
streaming: true,
streamBuffer: s.streamBuffer + payload.delta
}))
})
set({ listenersAttached: true })
},
unmount: () => {
unsubStream?.()
unsubStream = null
set({ listenersAttached: false })
}
}))
+97
View File
@@ -1375,3 +1375,100 @@
.mention-item:hover {
background: var(--bg-hover);
}
.interactive-panel {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 8px;
gap: 10px;
}
.interactive-step-bar {
font-size: 11px;
color: var(--text-muted);
padding-bottom: 6px;
border-bottom: 1px solid var(--border);
}
.interactive-section {
display: flex;
flex-direction: column;
gap: 8px;
}
.plot-options {
display: flex;
flex-direction: column;
gap: 8px;
}
.plot-option-card {
text-align: left;
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-surface);
color: var(--text-primary);
cursor: pointer;
}
.plot-option-card.selected {
border-color: var(--accent);
background: color-mix(in srgb, var(--accent) 12%, var(--bg-surface));
}
.plot-option-card .opt-label {
font-weight: 600;
font-size: 13px;
color: var(--accent-light);
}
.plot-option-summary {
font-size: 12px;
color: var(--text-secondary);
margin-top: 6px;
line-height: 1.5;
}
.interactive-scene-preview {
font-size: 13px;
line-height: 1.7;
color: var(--text-primary);
max-height: 240px;
overflow-y: auto;
padding: 8px;
background: var(--bg-tertiary);
border-radius: var(--radius-sm);
border: 1px solid var(--border);
}
.naming-options .btn {
margin-bottom: 4px;
}
.naming-custom {
display: flex;
gap: 6px;
margin-top: 8px;
}
.interactive-actions,
.interactive-finish {
display: flex;
gap: 8px;
flex-wrap: wrap;
align-items: center;
}
.interactive-scene-count {
font-size: 11px;
color: var(--text-muted);
}
.ai-mode-tab:disabled {
opacity: 0.45;
cursor: not-allowed;
}
+42
View File
@@ -15,6 +15,11 @@ import type {
NamingConflict,
AiStreamChunkEvent,
AiNetworkStatusEvent,
InteractiveFlow,
InteractiveGateResult,
InteractiveStreamChunkEvent,
PlotOption,
PlotSelection,
IpcResult,
LandmarkType,
OutlineItem,
@@ -199,6 +204,42 @@ export interface ElectronAPI {
testConnection: (config?: AiConfig) => Promise<IpcResult<{ ok: true }>>
namingCheck: (bookId: string) => Promise<IpcResult<NamingConflict[]>>
}
interactive: {
checkGate: (bookId: string) => Promise<IpcResult<InteractiveGateResult>>
getFlow: (bookId: string, sessionId: string) => Promise<IpcResult<InteractiveFlow | null>>
start: (bookId: string, sessionId: string) => Promise<IpcResult<InteractiveFlow>>
confirmContext: (
bookId: string,
flowId: string,
binding: AiContextBinding
) => Promise<IpcResult<InteractiveFlow>>
suggestPlots: (bookId: string, flowId: string) => Promise<IpcResult<PlotOption[]>>
selectPlot: (
bookId: string,
flowId: string,
selection: PlotSelection
) => Promise<IpcResult<InteractiveFlow>>
generateScene: (bookId: string, flowId: string) => Promise<IpcResult<InteractiveFlow>>
resolveNaming: (
bookId: string,
flowId: string,
chosenName: string
) => Promise<IpcResult<InteractiveFlow>>
refineScene: (
bookId: string,
flowId: string,
instruction: string
) => Promise<IpcResult<InteractiveFlow>>
acceptScene: (bookId: string, flowId: string) => Promise<IpcResult<InteractiveFlow>>
finishChapter: (
bookId: string,
flowId: string,
volumeId: string,
title?: string
) => Promise<IpcResult<{ chapterId: string }>>
abort: (flowId: string) => Promise<IpcResult<void>>
getSceneDraft: (bookId: string, flowId: string) => Promise<IpcResult<string>>
}
wordfreq: {
analyze: (
bookId: string,
@@ -212,6 +253,7 @@ export interface ElectronAPI {
onShortcutTriggered: (callback: (action: ActionId) => void) => () => void
onAiStreamChunk: (callback: (payload: AiStreamChunkEvent) => void) => () => void
onAiNetworkStatus: (callback: (payload: AiNetworkStatusEvent) => void) => () => void
onInteractiveStreamChunk: (callback: (payload: InteractiveStreamChunkEvent) => void) => () => void
}
declare global {
+15 -1
View File
@@ -63,5 +63,19 @@ export const IPC = {
AI_TEST_CONNECTION: 'ai:testConnection',
AI_NAMING_CHECK: 'ai:namingCheck',
AI_STREAM_CHUNK: 'ai:streamChunk',
AI_NETWORK_STATUS: 'ai:networkStatus'
AI_NETWORK_STATUS: 'ai:networkStatus',
INTERACTIVE_CHECK_GATE: 'interactive:checkGate',
INTERACTIVE_GET_FLOW: 'interactive:getFlow',
INTERACTIVE_START: 'interactive:start',
INTERACTIVE_CONFIRM_CONTEXT: 'interactive:confirmContext',
INTERACTIVE_SUGGEST_PLOTS: 'interactive:suggestPlots',
INTERACTIVE_SELECT_PLOT: 'interactive:selectPlot',
INTERACTIVE_GENERATE_SCENE: 'interactive:generateScene',
INTERACTIVE_RESOLVE_NAMING: 'interactive:resolveNaming',
INTERACTIVE_REFINE_SCENE: 'interactive:refineScene',
INTERACTIVE_ACCEPT_SCENE: 'interactive:acceptScene',
INTERACTIVE_FINISH_CHAPTER: 'interactive:finishChapter',
INTERACTIVE_ABORT: 'interactive:abort',
INTERACTIVE_STREAM_CHUNK: 'interactive:streamChunk',
INTERACTIVE_GET_SCENE_DRAFT: 'interactive:getSceneDraft'
} as const
+60
View File
@@ -9,6 +9,7 @@ export type ThemeId =
export type Language = 'zh-CN' | 'en'
export type ChapterStatus = 'draft' | 'review' | 'done'
export type ChapterOrigin = 'manual' | 'interactive'
export type BookStatus = 'draft' | 'ongoing' | 'done'
export type PublishStatus = 'draft' | 'ready' | 'published'
export type OutlineStatus = 'pending' | 'writing' | 'done' | 'abandoned'
@@ -99,6 +100,7 @@ export interface Chapter {
wordCount: number
sortOrder: number
cursorOffset: number
origin?: ChapterOrigin
publishStatus?: PublishStatus
povCharacterId?: string | null
summary?: string
@@ -273,3 +275,61 @@ export interface AiContextBuildResult {
systemPrompt: string
preview: ContextPreviewItem[]
}
export type InteractiveStep =
| 'context_confirm'
| 'plot_suggest'
| 'plot_select'
| 'scene_generate'
| 'naming_pause'
| 'scene_review'
| 'completed'
export type InteractiveFlowStatus = 'in_progress' | 'completed' | 'abandoned'
export interface PlotOption {
id: 'A' | 'B' | 'C'
label: string
summary: string
}
export interface NamingPause {
elementType: string
context: string
options: string[]
}
export interface PlotSelection {
optionIds: Array<'A' | 'B' | 'C'>
userNote?: string
}
export interface InteractiveScene {
id: string
sortOrder: number
contentHtml: string
plotLabel: string
refinedCount: number
}
export interface InteractiveFlow {
id: string
sessionId: string
step: InteractiveStep
status: InteractiveFlowStatus
scenes: InteractiveScene[]
namingPending?: NamingPause | null
contextSummary?: string
}
export interface InteractiveGateResult {
eligible: boolean
reason?: 'no_chapter' | 'no_outline_or_setting'
}
export interface InteractiveStreamChunkEvent {
flowId: string
phase: 'scene' | 'refine' | 'naming_resume'
delta: string
done: boolean
}