feat: ship v0.5.0 with auto and wizard writing modes
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -3,8 +3,9 @@ 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'
|
||||
import schemaV5 from './schema-v5.sql?raw'
|
||||
|
||||
const CURRENT_VERSION = 4
|
||||
const CURRENT_VERSION = 5
|
||||
|
||||
function tryAlter(db: SqliteDb, sql: string): void {
|
||||
try {
|
||||
@@ -56,5 +57,12 @@ export function migrate(db: SqliteDb): void {
|
||||
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')
|
||||
current = 4
|
||||
}
|
||||
|
||||
if (current < 5) {
|
||||
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')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { randomUUID } from 'crypto'
|
||||
import type {
|
||||
AutoWritingConfig,
|
||||
InteractiveFlow,
|
||||
InteractiveFlowStatus,
|
||||
InteractiveScene,
|
||||
InteractiveStep,
|
||||
NamingPause
|
||||
NamingPause,
|
||||
WizardWritingConfig,
|
||||
WritingFlowMode,
|
||||
WritingStep
|
||||
} from '../../../shared/types'
|
||||
import type { SqliteDb } from '../types'
|
||||
|
||||
@@ -27,33 +30,47 @@ export class InteractiveRepository {
|
||||
return row ? this.mapFlow(row) : null
|
||||
}
|
||||
|
||||
create(sessionId: string): InteractiveFlow {
|
||||
create(
|
||||
sessionId: string,
|
||||
flowMode: WritingFlowMode = 'interactive',
|
||||
initialStep?: WritingStep
|
||||
): InteractiveFlow {
|
||||
const id = randomUUID()
|
||||
const step =
|
||||
initialStep ??
|
||||
(flowMode === 'auto'
|
||||
? 'goal_setup'
|
||||
: flowMode === 'wizard'
|
||||
? 'wizard_goal'
|
||||
: 'context_confirm')
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO interactive_flows (id, session_id, step, status, context_json) VALUES (?, ?, 'context_confirm', 'in_progress', '{}')`
|
||||
`INSERT INTO interactive_flows (id, session_id, step, status, context_json, flow_mode, mode_config_json)
|
||||
VALUES (?, ?, ?, 'in_progress', '{}', ?, '{}')`
|
||||
)
|
||||
.run(id, sessionId)
|
||||
.run(id, sessionId, step, flowMode)
|
||||
return this.get(id)!
|
||||
}
|
||||
|
||||
resetInProgress(sessionId: string): InteractiveFlow {
|
||||
resetInProgress(sessionId: string, flowMode: WritingFlowMode = 'interactive'): 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)
|
||||
return this.create(sessionId, flowMode)
|
||||
}
|
||||
|
||||
updateStep(
|
||||
flowId: string,
|
||||
step: InteractiveStep,
|
||||
step: WritingStep,
|
||||
patch: {
|
||||
contextJson?: string
|
||||
plotSelectionJson?: string | null
|
||||
namingPendingJson?: string | null
|
||||
sceneDraftHtml?: string | null
|
||||
modeConfigJson?: string
|
||||
flowMode?: WritingFlowMode
|
||||
status?: InteractiveFlowStatus
|
||||
} = {}
|
||||
): InteractiveFlow {
|
||||
@@ -75,6 +92,14 @@ export class InteractiveRepository {
|
||||
sets.push('scene_draft_html = ?')
|
||||
vals.push(patch.sceneDraftHtml)
|
||||
}
|
||||
if (patch.modeConfigJson !== undefined) {
|
||||
sets.push('mode_config_json = ?')
|
||||
vals.push(patch.modeConfigJson)
|
||||
}
|
||||
if (patch.flowMode !== undefined) {
|
||||
sets.push('flow_mode = ?')
|
||||
vals.push(patch.flowMode)
|
||||
}
|
||||
if (patch.status !== undefined) {
|
||||
sets.push('status = ?')
|
||||
vals.push(patch.status)
|
||||
@@ -84,6 +109,27 @@ export class InteractiveRepository {
|
||||
return this.get(flowId)!
|
||||
}
|
||||
|
||||
getModeConfig<T extends AutoWritingConfig | WizardWritingConfig>(
|
||||
flowId: string
|
||||
): T {
|
||||
const row = this.db
|
||||
.prepare('SELECT mode_config_json FROM interactive_flows WHERE id = ?')
|
||||
.get(flowId) as { mode_config_json: string } | undefined
|
||||
try {
|
||||
return JSON.parse(row?.mode_config_json ?? '{}') as T
|
||||
} catch {
|
||||
return {} as T
|
||||
}
|
||||
}
|
||||
|
||||
setModeConfig(flowId: string, config: AutoWritingConfig | WizardWritingConfig): void {
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE interactive_flows SET mode_config_json = ?, updated_at = datetime('now') WHERE id = ?`
|
||||
)
|
||||
.run(JSON.stringify(config), flowId)
|
||||
}
|
||||
|
||||
listScenes(flowId: string): InteractiveScene[] {
|
||||
const rows = this.db
|
||||
.prepare('SELECT * FROM interactive_scenes WHERE flow_id = ? ORDER BY sort_order')
|
||||
@@ -156,11 +202,19 @@ export class InteractiveRepository {
|
||||
namingPending = null
|
||||
}
|
||||
}
|
||||
let modeConfig: AutoWritingConfig | WizardWritingConfig | Record<string, never> = {}
|
||||
try {
|
||||
modeConfig = JSON.parse((row.mode_config_json as string) ?? '{}')
|
||||
} catch {
|
||||
modeConfig = {}
|
||||
}
|
||||
return {
|
||||
id: flowId,
|
||||
sessionId: row.session_id as string,
|
||||
step: row.step as InteractiveStep,
|
||||
flowMode: (row.flow_mode as WritingFlowMode) ?? 'interactive',
|
||||
step: row.step as WritingStep,
|
||||
status: row.status as InteractiveFlowStatus,
|
||||
modeConfig,
|
||||
scenes: this.listScenes(flowId),
|
||||
namingPending,
|
||||
contextSummary: undefined
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE interactive_flows ADD COLUMN flow_mode TEXT NOT NULL DEFAULT 'interactive';
|
||||
ALTER TABLE interactive_flows ADD COLUMN mode_config_json TEXT NOT NULL DEFAULT '{}';
|
||||
@@ -0,0 +1,207 @@
|
||||
import { BrowserWindow, ipcMain } from 'electron'
|
||||
import { IPC } from '../../../shared/ipc-channels'
|
||||
import type { AutoWritingConfig } from '../../../shared/types'
|
||||
import { BookRegistryService } from '../../services/book-registry'
|
||||
import { GlobalSettingsService } from '../../services/global-settings'
|
||||
import { AiClientService } from '../../services/ai-client.service'
|
||||
import { checkInteractiveGate } from '../../services/interactive-gate.service'
|
||||
import { AutoWritingService } from '../../services/auto-writing.service'
|
||||
import { InteractiveRepository } from '../../db/repositories/interactive.repo'
|
||||
import { wrap } from '../result'
|
||||
|
||||
const activeAuto = new Map<string, AbortController>()
|
||||
|
||||
function serviceFor(registry: BookRegistryService, bookId: string, aiClient: AiClientService) {
|
||||
return new AutoWritingService(registry.getDb(bookId), aiClient)
|
||||
}
|
||||
|
||||
function enrich(registry: BookRegistryService, bookId: string, aiClient: AiClientService, flowId: string) {
|
||||
const svc = serviceFor(registry, bookId, aiClient)
|
||||
const flow = new InteractiveRepository(registry.getDb(bookId)).get(flowId)
|
||||
return flow ? svc.enrichFlow(flow) : null
|
||||
}
|
||||
|
||||
function readContextText(registry: BookRegistryService, bookId: string, flowId: string): string {
|
||||
const row = registry
|
||||
.getDb(bookId)
|
||||
.prepare('SELECT context_json FROM interactive_flows WHERE id = ?')
|
||||
.get(flowId) as { context_json: string } | undefined
|
||||
try {
|
||||
const parsed = JSON.parse(row?.context_json ?? '{}') as { contextSummary?: string }
|
||||
return parsed.contextSummary ?? '(无上下文摘要)'
|
||||
} catch {
|
||||
return '(无上下文摘要)'
|
||||
}
|
||||
}
|
||||
|
||||
export function registerAutoHandlers(
|
||||
registry: BookRegistryService,
|
||||
settings: GlobalSettingsService,
|
||||
aiClient: AiClientService
|
||||
): void {
|
||||
void settings
|
||||
|
||||
ipcMain.handle(IPC.AUTO_CHECK_GATE, (_e, { bookId }: { bookId: string }) =>
|
||||
wrap(() => checkInteractiveGate(registry.getDb(bookId)))
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
IPC.AUTO_GET_FLOW,
|
||||
(_e, { bookId, sessionId }: { bookId: string; sessionId: string }) =>
|
||||
wrap(() => {
|
||||
const flow = serviceFor(registry, bookId, aiClient).getFlow(sessionId)
|
||||
return flow ? serviceFor(registry, bookId, aiClient).enrichFlow(flow) : null
|
||||
})
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
IPC.AUTO_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.AUTO_SET_GOAL,
|
||||
(
|
||||
_e,
|
||||
{ bookId, flowId, config }: { bookId: string; flowId: string; config: Partial<AutoWritingConfig> }
|
||||
) =>
|
||||
wrap(() => {
|
||||
const svc = serviceFor(registry, bookId, aiClient)
|
||||
const flow = svc.setGoal(flowId, config)
|
||||
return svc.enrichFlow(flow)
|
||||
})
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
IPC.AUTO_PLAN_SCENES,
|
||||
(_e, { bookId, flowId, contextSummary }: { bookId: string; flowId: string; contextSummary?: string }) =>
|
||||
wrap(async () => {
|
||||
const svc = serviceFor(registry, bookId, aiClient)
|
||||
if (contextSummary) svc.setContextSummary(flowId, contextSummary)
|
||||
const text = contextSummary ?? readContextText(registry, bookId, flowId)
|
||||
await svc.planScenes(flowId, text)
|
||||
return enrich(registry, bookId, aiClient, flowId)
|
||||
})
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
IPC.AUTO_GENERATE,
|
||||
(event, { bookId, flowId, contextSummary }: { bookId: string; flowId: string; contextSummary?: string }) =>
|
||||
wrap(async () => {
|
||||
const controller = new AbortController()
|
||||
activeAuto.set(flowId, controller)
|
||||
const win = BrowserWindow.fromWebContents(event.sender)
|
||||
const svc = serviceFor(registry, bookId, aiClient)
|
||||
const text = contextSummary ?? readContextText(registry, bookId, flowId)
|
||||
try {
|
||||
const flow = await svc.generateNext(
|
||||
flowId,
|
||||
text,
|
||||
(delta, done) => {
|
||||
win?.webContents.send(IPC.INTERACTIVE_STREAM_CHUNK, {
|
||||
flowId,
|
||||
flowMode: 'auto',
|
||||
phase: 'scene',
|
||||
delta,
|
||||
done
|
||||
})
|
||||
},
|
||||
controller.signal
|
||||
)
|
||||
return svc.enrichFlow(flow)
|
||||
} finally {
|
||||
activeAuto.delete(flowId)
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
IPC.AUTO_RESOLVE_NAMING,
|
||||
(
|
||||
event,
|
||||
{
|
||||
bookId,
|
||||
flowId,
|
||||
chosenName,
|
||||
contextSummary
|
||||
}: { bookId: string; flowId: string; chosenName: string; contextSummary?: string }
|
||||
) =>
|
||||
wrap(async () => {
|
||||
const controller = new AbortController()
|
||||
activeAuto.set(flowId, controller)
|
||||
const win = BrowserWindow.fromWebContents(event.sender)
|
||||
const svc = serviceFor(registry, bookId, aiClient)
|
||||
const text = contextSummary ?? readContextText(registry, bookId, flowId)
|
||||
try {
|
||||
const flow = await svc.resolveNaming(
|
||||
flowId,
|
||||
chosenName,
|
||||
text,
|
||||
(delta, done) => {
|
||||
win?.webContents.send(IPC.INTERACTIVE_STREAM_CHUNK, {
|
||||
flowId,
|
||||
flowMode: 'auto',
|
||||
phase: 'naming_resume',
|
||||
delta,
|
||||
done
|
||||
})
|
||||
},
|
||||
controller.signal
|
||||
)
|
||||
return svc.enrichFlow(flow)
|
||||
} finally {
|
||||
activeAuto.delete(flowId)
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
ipcMain.handle(IPC.AUTO_PAUSE, (_e, { bookId, flowId }: { bookId: string; flowId: string }) =>
|
||||
wrap(() => {
|
||||
activeAuto.get(flowId)?.abort()
|
||||
activeAuto.delete(flowId)
|
||||
const svc = serviceFor(registry, bookId, aiClient)
|
||||
return svc.enrichFlow(svc.pause(flowId))
|
||||
})
|
||||
)
|
||||
|
||||
ipcMain.handle(IPC.AUTO_RESUME, (_e, { bookId, flowId }: { bookId: string; flowId: string }) =>
|
||||
wrap(() => {
|
||||
const svc = serviceFor(registry, bookId, aiClient)
|
||||
return svc.enrichFlow(svc.resume(flowId))
|
||||
})
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
IPC.AUTO_HANDOFF_INTERACTIVE,
|
||||
(_e, { bookId, flowId }: { bookId: string; flowId: string }) =>
|
||||
wrap(() => {
|
||||
const svc = serviceFor(registry, bookId, aiClient)
|
||||
return svc.enrichFlow(svc.handoffToInteractive(flowId))
|
||||
})
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
IPC.AUTO_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.AUTO_ABORT, (_e, { flowId }: { flowId: string }) =>
|
||||
wrap(() => {
|
||||
activeAuto.get(flowId)?.abort()
|
||||
activeAuto.delete(flowId)
|
||||
})
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
IPC.AUTO_GET_SCENE_DRAFT,
|
||||
(_e, { bookId, flowId }: { bookId: string; flowId: string }) =>
|
||||
wrap(() => serviceFor(registry, bookId, aiClient).getSceneDraft(flowId))
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import { BrowserWindow, ipcMain } from 'electron'
|
||||
import { IPC } from '../../../shared/ipc-channels'
|
||||
import type { AiContextBinding, AutoRhythm, AutoWritingConfig, WizardWritingConfig } 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 { WizardWritingService } from '../../services/wizard-writing.service'
|
||||
import { InteractiveRepository } from '../../db/repositories/interactive.repo'
|
||||
import { wrap } from '../result'
|
||||
|
||||
const activeWizard = new Map<string, AbortController>()
|
||||
|
||||
function serviceFor(registry: BookRegistryService, bookId: string, aiClient: AiClientService) {
|
||||
return new WizardWritingService(registry.getDb(bookId), aiClient)
|
||||
}
|
||||
|
||||
function readContextText(registry: BookRegistryService, bookId: string, flowId: string): string {
|
||||
const row = registry
|
||||
.getDb(bookId)
|
||||
.prepare('SELECT context_json FROM interactive_flows WHERE id = ?')
|
||||
.get(flowId) as { context_json: string } | undefined
|
||||
try {
|
||||
const parsed = JSON.parse(row?.context_json ?? '{}') as { contextSummary?: string }
|
||||
return parsed.contextSummary ?? '(无上下文摘要)'
|
||||
} catch {
|
||||
return '(无上下文摘要)'
|
||||
}
|
||||
}
|
||||
|
||||
export function registerWizardHandlers(
|
||||
registry: BookRegistryService,
|
||||
settings: GlobalSettingsService,
|
||||
aiClient: AiClientService
|
||||
): void {
|
||||
ipcMain.handle(
|
||||
IPC.WIZARD_GET_FLOW,
|
||||
(_e, { bookId, sessionId }: { bookId: string; sessionId: string }) =>
|
||||
wrap(() => {
|
||||
const svc = serviceFor(registry, bookId, aiClient)
|
||||
const flow = svc.getFlow(sessionId)
|
||||
return flow ? svc.enrichFlow(flow) : null
|
||||
})
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
IPC.WIZARD_START,
|
||||
(_e, { bookId, sessionId }: { bookId: string; sessionId: string }) =>
|
||||
wrap(() => {
|
||||
const svc = serviceFor(registry, bookId, aiClient)
|
||||
const flow = svc.start(sessionId)
|
||||
return svc.enrichFlow(flow)
|
||||
})
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
IPC.WIZARD_SET_GOAL,
|
||||
(
|
||||
_e,
|
||||
{ bookId, flowId, config }: { bookId: string; flowId: string; config: Partial<AutoWritingConfig> }
|
||||
) =>
|
||||
wrap(() => {
|
||||
const svc = serviceFor(registry, bookId, aiClient)
|
||||
return svc.enrichFlow(svc.setGoal(flowId, config))
|
||||
})
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
IPC.WIZARD_SET_RHYTHM,
|
||||
(
|
||||
_e,
|
||||
{
|
||||
bookId,
|
||||
flowId,
|
||||
rhythm,
|
||||
styleNote
|
||||
}: { bookId: string; flowId: string; rhythm: AutoRhythm; styleNote?: string }
|
||||
) =>
|
||||
wrap(() => {
|
||||
const svc = serviceFor(registry, bookId, aiClient)
|
||||
return svc.enrichFlow(svc.setRhythm(flowId, rhythm, styleNote))
|
||||
})
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
IPC.WIZARD_SET_POV,
|
||||
(_e, { bookId, flowId, povSettingId }: { bookId: string; flowId: string; povSettingId: string }) =>
|
||||
wrap(() => {
|
||||
const svc = serviceFor(registry, bookId, aiClient)
|
||||
return svc.enrichFlow(svc.setPov(flowId, povSettingId))
|
||||
})
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
IPC.WIZARD_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 svc = serviceFor(registry, bookId, aiClient)
|
||||
return svc.enrichFlow(svc.confirmContext(flowId, binding, summary))
|
||||
})
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
IPC.WIZARD_BUILD_PREVIEW,
|
||||
(_e, { bookId, flowId }: { bookId: string; flowId: string }) =>
|
||||
wrap(async () => {
|
||||
const svc = serviceFor(registry, bookId, aiClient)
|
||||
const config = new InteractiveRepository(registry.getDb(bookId)).getModeConfig<WizardWritingConfig>(
|
||||
flowId
|
||||
)
|
||||
const text = readContextText(registry, bookId, flowId)
|
||||
const povName = svc.getPovName(config.povSettingId)
|
||||
const preview = await svc.buildPreview(flowId, text, povName)
|
||||
const flow = new InteractiveRepository(registry.getDb(bookId)).get(flowId)!
|
||||
return { preview, flow: svc.enrichFlow(flow) }
|
||||
})
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
IPC.WIZARD_START_GENERATE,
|
||||
(event, { bookId, flowId }: { bookId: string; flowId: string }) =>
|
||||
wrap(async () => {
|
||||
const controller = new AbortController()
|
||||
activeWizard.set(flowId, controller)
|
||||
const win = BrowserWindow.fromWebContents(event.sender)
|
||||
const svc = serviceFor(registry, bookId, aiClient)
|
||||
const text = readContextText(registry, bookId, flowId)
|
||||
try {
|
||||
const flow = await svc.startGenerate(
|
||||
flowId,
|
||||
text,
|
||||
(delta, done) => {
|
||||
win?.webContents.send(IPC.INTERACTIVE_STREAM_CHUNK, {
|
||||
flowId,
|
||||
flowMode: 'wizard',
|
||||
phase: 'scene',
|
||||
delta,
|
||||
done
|
||||
})
|
||||
},
|
||||
controller.signal
|
||||
)
|
||||
return svc.enrichFlow(flow)
|
||||
} finally {
|
||||
activeWizard.delete(flowId)
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
IPC.WIZARD_HANDOFF_INTERACTIVE,
|
||||
(_e, { bookId, flowId }: { bookId: string; flowId: string }) =>
|
||||
wrap(() => {
|
||||
const svc = serviceFor(registry, bookId, aiClient)
|
||||
return svc.enrichFlow(svc.handoffToInteractive(flowId))
|
||||
})
|
||||
)
|
||||
|
||||
ipcMain.handle(IPC.WIZARD_ABORT, (_e, { flowId }: { flowId: string }) =>
|
||||
wrap(() => {
|
||||
activeWizard.get(flowId)?.abort()
|
||||
activeWizard.delete(flowId)
|
||||
})
|
||||
)
|
||||
}
|
||||
@@ -16,6 +16,8 @@ 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 { registerAutoHandlers } from './handlers/auto.handler'
|
||||
import { registerWizardHandlers } from './handlers/wizard.handler'
|
||||
import { AiClientService } from '../services/ai-client.service'
|
||||
import { NetworkMonitorService } from '../services/network-monitor.service'
|
||||
|
||||
@@ -42,8 +44,11 @@ export function registerIpc(): { settings: GlobalSettingsService; books: BookReg
|
||||
registerSnapshotHandlers(books, snapshotService!)
|
||||
registerBookmarkHandlers(books)
|
||||
registerSearchHandlers(books, settings)
|
||||
registerAiHandlers(books, settings, new AiClientService(() => settings.get().aiConfig))
|
||||
registerInteractiveHandlers(books, settings, new AiClientService(() => settings.get().aiConfig))
|
||||
const aiClient = new AiClientService(() => settings.get().aiConfig)
|
||||
registerAiHandlers(books, settings, aiClient)
|
||||
registerInteractiveHandlers(books, settings, aiClient)
|
||||
registerAutoHandlers(books, settings, aiClient)
|
||||
registerWizardHandlers(books, settings, aiClient)
|
||||
|
||||
return { settings, books, shortcuts: shortcutManager }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { ScenePlanItem } from '../../shared/types'
|
||||
|
||||
export interface ScenePlanResult {
|
||||
scenes: ScenePlanItem[]
|
||||
estimatedWords: number
|
||||
}
|
||||
|
||||
export function parseScenePlanJson(response: string): ScenePlanResult {
|
||||
const trimmed = response.trim()
|
||||
const start = trimmed.indexOf('{')
|
||||
const end = trimmed.lastIndexOf('}')
|
||||
if (start < 0 || end <= start) throw new Error('scene plan JSON not found')
|
||||
const parsed = JSON.parse(trimmed.slice(start, end + 1)) as {
|
||||
scenes?: unknown
|
||||
estimatedWords?: unknown
|
||||
}
|
||||
if (!Array.isArray(parsed.scenes) || parsed.scenes.length < 2) {
|
||||
throw new Error('scenes array invalid')
|
||||
}
|
||||
const scenes = parsed.scenes.map((s, i) => {
|
||||
const row = s as Record<string, unknown>
|
||||
return {
|
||||
label: String(row.label ?? `场景${i + 1}`),
|
||||
summary: String(row.summary ?? '')
|
||||
}
|
||||
})
|
||||
return {
|
||||
scenes,
|
||||
estimatedWords: Number(parsed.estimatedWords ?? 3000)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { AutoWritingConfig, ScenePlanItem, WizardWritingConfig } from '../../shared/types'
|
||||
import type { ChatMessage } from './ai-client.service'
|
||||
|
||||
export function buildScenePlanMessages(
|
||||
chapterGoal: string,
|
||||
outlineSummaries: string[],
|
||||
contextText: string,
|
||||
wordMin: number,
|
||||
wordMax: number
|
||||
): ChatMessage[] {
|
||||
const outline =
|
||||
outlineSummaries.length > 0
|
||||
? `关联大纲:\n${outlineSummaries.map((s, i) => `${i + 1}. ${s}`).join('\n')}\n\n`
|
||||
: ''
|
||||
return [
|
||||
{
|
||||
role: 'system',
|
||||
content:
|
||||
'你是长篇小说章节规划助手。根据本章目标规划 3-6 个连续场景。仅返回 JSON:{"scenes":[{"label":"场景名","summary":"至少80字的场景要点"},...],"estimatedWords":数字}。estimatedWords 须在用户给定字数范围内。'
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: `${outline}本章目标:${chapterGoal}\n\n上下文:\n${contextText}\n\n目标字数:${wordMin}-${wordMax} 字。请规划场景顺序。`
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
export function buildAutoSceneMessages(
|
||||
config: AutoWritingConfig,
|
||||
planItem: ScenePlanItem,
|
||||
priorSceneSummaries: string[],
|
||||
contextText: string
|
||||
): ChatMessage[] {
|
||||
const prior =
|
||||
priorSceneSummaries.length > 0
|
||||
? `已写场景摘要:\n${priorSceneSummaries.map((s, i) => `${i + 1}. ${s}`).join('\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本章目标:${config.chapterGoal}\n\n当前场景:${planItem.label}\n场景要点:${planItem.summary}\n\n请生成本场景。`
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const RHYTHM_LABEL: Record<string, string> = {
|
||||
relaxed: '舒缓',
|
||||
tense: '紧张',
|
||||
mixed: '混合'
|
||||
}
|
||||
|
||||
export function buildWizardPreviewMessages(
|
||||
config: WizardWritingConfig,
|
||||
contextText: string,
|
||||
povName: string
|
||||
): ChatMessage[] {
|
||||
const rhythm = RHYTHM_LABEL[config.rhythm] ?? config.rhythm
|
||||
const style = config.styleNote ? `\n风格说明:${config.styleNote}` : ''
|
||||
return [
|
||||
{
|
||||
role: 'system',
|
||||
content:
|
||||
'你是写作策略助手。根据用户配置生成一段策略预览,使用 HTML <p> 包裹,中文,150-300 字,说明视角、节奏、目标与重点上下文。'
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: `视角人物:${povName}\n节奏:${rhythm}${style}\n本章目标:${config.chapterGoal}\n\n上下文摘要:\n${contextText}\n\n请生成策略预览。`
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
import type {
|
||||
AutoWritingConfig,
|
||||
InteractiveFlow,
|
||||
ScenePlanItem
|
||||
} from '../../shared/types'
|
||||
import type { SqliteDb } from '../db/types'
|
||||
import { InteractiveRepository } from '../db/repositories/interactive.repo'
|
||||
import { OutlineRepository } from '../db/repositories/outline.repo'
|
||||
import type { AiClientService } from './ai-client.service'
|
||||
import {
|
||||
buildAutoSceneMessages,
|
||||
buildScenePlanMessages
|
||||
} from './auto-prompt-builder.service'
|
||||
import { parseScenePlanJson, type ScenePlanResult } from './auto-parse.service'
|
||||
import { ChapterFinishService } from './chapter-finish.service'
|
||||
import { SceneGenerationCore } from './scene-generation-core.service'
|
||||
import { handoffToInteractive } from './writing-flow-handoff.service'
|
||||
import { stripHtml } from './word-count'
|
||||
|
||||
export class AutoWritingService {
|
||||
private repo: InteractiveRepository
|
||||
private core: SceneGenerationCore
|
||||
|
||||
constructor(
|
||||
private db: SqliteDb,
|
||||
private aiClient: AiClientService
|
||||
) {
|
||||
this.repo = new InteractiveRepository(db)
|
||||
this.core = new SceneGenerationCore(this.repo, aiClient)
|
||||
}
|
||||
|
||||
start(sessionId: string): InteractiveFlow {
|
||||
return this.repo.resetInProgress(sessionId, 'auto')
|
||||
}
|
||||
|
||||
getFlow(sessionId: string): InteractiveFlow | null {
|
||||
return this.repo.getBySession(sessionId)
|
||||
}
|
||||
|
||||
setGoal(flowId: string, partial: Partial<AutoWritingConfig>): InteractiveFlow {
|
||||
const existing = this.repo.getModeConfig<AutoWritingConfig>(flowId)
|
||||
const config: AutoWritingConfig = {
|
||||
chapterGoal: partial.chapterGoal ?? existing.chapterGoal ?? '',
|
||||
outlineItemIds: partial.outlineItemIds ?? existing.outlineItemIds,
|
||||
wordMin: partial.wordMin ?? existing.wordMin ?? 2000,
|
||||
wordMax: partial.wordMax ?? existing.wordMax ?? 4000,
|
||||
scenePlan: existing.scenePlan,
|
||||
currentPlanIndex: existing.currentPlanIndex ?? 0
|
||||
}
|
||||
this.repo.setModeConfig(flowId, config)
|
||||
return this.repo.updateStep(flowId, 'scene_plan')
|
||||
}
|
||||
|
||||
async planScenes(flowId: string, contextText: string): Promise<ScenePlanResult> {
|
||||
const config = this.repo.getModeConfig<AutoWritingConfig>(flowId)
|
||||
const outlines = new OutlineRepository(this.db)
|
||||
const outlineSummaries = (config.outlineItemIds ?? [])
|
||||
.map((id) => outlines.get(id))
|
||||
.filter(Boolean)
|
||||
.map((o) => `${o!.title}:${stripHtml(o!.description).slice(0, 120)}`)
|
||||
|
||||
const messages = buildScenePlanMessages(
|
||||
config.chapterGoal,
|
||||
outlineSummaries,
|
||||
contextText,
|
||||
config.wordMin,
|
||||
config.wordMax
|
||||
)
|
||||
|
||||
let response = await this.aiClient.chat(messages)
|
||||
let plan: ScenePlanResult
|
||||
try {
|
||||
plan = parseScenePlanJson(response)
|
||||
} catch {
|
||||
response = await this.aiClient.chat([
|
||||
...messages,
|
||||
{ role: 'user', content: '仅返回 JSON,不要 markdown 或其它说明。' }
|
||||
])
|
||||
plan = parseScenePlanJson(response)
|
||||
}
|
||||
|
||||
const updated: AutoWritingConfig = {
|
||||
...config,
|
||||
scenePlan: plan.scenes,
|
||||
currentPlanIndex: 0
|
||||
}
|
||||
this.repo.setModeConfig(flowId, updated)
|
||||
this.repo.updateStep(flowId, 'auto_generate')
|
||||
return plan
|
||||
}
|
||||
|
||||
async generateNext(
|
||||
flowId: string,
|
||||
contextText: 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')
|
||||
if (flow.step === 'naming_pause') throw new Error('naming pause active')
|
||||
|
||||
const config = this.repo.getModeConfig<AutoWritingConfig>(flowId)
|
||||
const index = config.currentPlanIndex ?? 0
|
||||
const plan = config.scenePlan ?? []
|
||||
if (index >= plan.length) return flow
|
||||
|
||||
const planItem = plan[index]!
|
||||
const prior = this.repo
|
||||
.listScenes(flowId)
|
||||
.map((s) => stripHtml(s.contentHtml).slice(0, 200))
|
||||
const messages = buildAutoSceneMessages(config, planItem, prior, contextText)
|
||||
|
||||
const { naming } = await this.core.generateStream(
|
||||
flowId,
|
||||
messages,
|
||||
onChunk,
|
||||
abortSignal,
|
||||
'auto_generate'
|
||||
)
|
||||
if (naming) return this.repo.get(flowId)!
|
||||
|
||||
await this.commitCurrentScene(flowId, planItem)
|
||||
return this.repo.get(flowId)!
|
||||
}
|
||||
|
||||
async resolveNaming(
|
||||
flowId: string,
|
||||
chosenName: string,
|
||||
contextText: 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')
|
||||
|
||||
await this.core.resolveNaming(
|
||||
flowId,
|
||||
chosenName,
|
||||
flow.namingPending,
|
||||
onChunk,
|
||||
abortSignal,
|
||||
'auto_generate'
|
||||
)
|
||||
|
||||
const config = this.repo.getModeConfig<AutoWritingConfig>(flowId)
|
||||
const index = config.currentPlanIndex ?? 0
|
||||
const planItem = config.scenePlan?.[index]
|
||||
if (planItem) await this.commitCurrentScene(flowId, planItem)
|
||||
return this.repo.get(flowId)!
|
||||
}
|
||||
|
||||
pause(flowId: string): InteractiveFlow {
|
||||
return this.repo.updateStep(flowId, 'paused')
|
||||
}
|
||||
|
||||
resume(flowId: string): InteractiveFlow {
|
||||
return this.repo.updateStep(flowId, 'auto_generate')
|
||||
}
|
||||
|
||||
handoffToInteractive(flowId: string): InteractiveFlow {
|
||||
return handoffToInteractive(this.repo, flowId)
|
||||
}
|
||||
|
||||
finishChapter(flowId: string, volumeId: string, title?: string): { chapterId: string } {
|
||||
return new ChapterFinishService(this.db).finishFromFlow(
|
||||
flowId,
|
||||
volumeId,
|
||||
title ?? '自动初稿',
|
||||
'auto',
|
||||
'自动写作成章'
|
||||
)
|
||||
}
|
||||
|
||||
getSceneDraft(flowId: string): string {
|
||||
return this.repo.getSceneDraft(flowId)
|
||||
}
|
||||
|
||||
totalWordCount(flowId: string): number {
|
||||
return this.repo
|
||||
.listScenes(flowId)
|
||||
.reduce((sum, s) => sum + stripHtml(s.contentHtml).length, 0)
|
||||
}
|
||||
|
||||
enrichFlow(flow: InteractiveFlow): InteractiveFlow {
|
||||
try {
|
||||
const parsed = JSON.parse(this.repo.getContextJson(flow.id)) as { contextSummary?: string }
|
||||
return { ...flow, contextSummary: parsed.contextSummary }
|
||||
} catch {
|
||||
return flow
|
||||
}
|
||||
}
|
||||
|
||||
setContextSummary(flowId: string, contextSummary: string): void {
|
||||
this.repo.updateStep(flowId, this.repo.get(flowId)!.step, {
|
||||
contextJson: JSON.stringify({ contextSummary })
|
||||
})
|
||||
}
|
||||
|
||||
private async commitCurrentScene(flowId: string, planItem: ScenePlanItem): Promise<void> {
|
||||
const draft = this.repo.getSceneDraft(flowId).trim()
|
||||
if (!draft) return
|
||||
this.repo.addScene(flowId, draft, planItem.label)
|
||||
this.repo.setSceneDraft(flowId, '')
|
||||
const config = this.repo.getModeConfig<AutoWritingConfig>(flowId)
|
||||
const nextIndex = (config.currentPlanIndex ?? 0) + 1
|
||||
this.repo.setModeConfig(flowId, { ...config, currentPlanIndex: nextIndex })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { ChapterOrigin } from '../../shared/types'
|
||||
import type { SqliteDb } from '../db/types'
|
||||
import { ChapterRepository } from '../db/repositories/chapter.repo'
|
||||
import { InteractiveRepository } from '../db/repositories/interactive.repo'
|
||||
import { SnapshotRepository } from '../db/repositories/snapshot.repo'
|
||||
|
||||
export class ChapterFinishService {
|
||||
constructor(private db: SqliteDb) {}
|
||||
|
||||
finishFromFlow(
|
||||
flowId: string,
|
||||
volumeId: string,
|
||||
title: string,
|
||||
origin: ChapterOrigin,
|
||||
snapshotLabel: string
|
||||
): { chapterId: string } {
|
||||
const repo = new InteractiveRepository(this.db)
|
||||
const draft = repo.getSceneDraft(flowId).trim()
|
||||
if (draft) repo.addScene(flowId, draft, 'draft')
|
||||
const scenes = 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, origin)
|
||||
new SnapshotRepository(this.db).create(ch.id, 'ai', merged, snapshotLabel)
|
||||
repo.updateStep(flowId, 'completed', {
|
||||
status: 'completed',
|
||||
sceneDraftHtml: '',
|
||||
namingPendingJson: null
|
||||
})
|
||||
return { chapterId: ch.id }
|
||||
}
|
||||
}
|
||||
@@ -5,26 +5,28 @@ import type {
|
||||
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 { ChapterFinishService } from './chapter-finish.service'
|
||||
import { SceneGenerationCore } from './scene-generation-core.service'
|
||||
import { parsePlotsJson } from './interactive-parse.service'
|
||||
import { stripHtml } from './word-count'
|
||||
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
|
||||
private core: SceneGenerationCore
|
||||
|
||||
constructor(
|
||||
private db: SqliteDb,
|
||||
private aiClient: AiClientService
|
||||
) {
|
||||
this.repo = new InteractiveRepository(db)
|
||||
this.core = new SceneGenerationCore(this.repo, aiClient)
|
||||
}
|
||||
|
||||
start(sessionId: string): InteractiveFlow {
|
||||
@@ -87,32 +89,7 @@ export class InteractiveWritingService {
|
||||
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)
|
||||
await this.core.generateStream(flowId, messages, onChunk, abortSignal, 'scene_review')
|
||||
return this.repo.get(flowId)!
|
||||
}
|
||||
|
||||
@@ -125,21 +102,14 @@ export class InteractiveWritingService {
|
||||
const flow = this.repo.get(flowId)
|
||||
if (!flow?.namingPending) throw new Error('no naming pause')
|
||||
|
||||
const messages = buildNamingResumeMessages(
|
||||
await this.core.resolveNaming(
|
||||
flowId,
|
||||
chosenName,
|
||||
this.repo.getSceneDraft(flowId),
|
||||
flow.namingPending.context
|
||||
flow.namingPending,
|
||||
onChunk,
|
||||
abortSignal,
|
||||
'scene_review'
|
||||
)
|
||||
|
||||
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)!
|
||||
}
|
||||
|
||||
@@ -160,7 +130,7 @@ export class InteractiveWritingService {
|
||||
},
|
||||
abortSignal
|
||||
)
|
||||
const html = this.normalizeSceneHtml(full || buffer)
|
||||
const html = this.core.normalizeSceneHtml(full || buffer)
|
||||
this.repo.setSceneDraft(flowId, html)
|
||||
onChunk('', true)
|
||||
return this.repo.get(flowId)!
|
||||
@@ -181,26 +151,13 @@ export class InteractiveWritingService {
|
||||
}
|
||||
|
||||
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 }
|
||||
return new ChapterFinishService(this.db).finishFromFlow(
|
||||
flowId,
|
||||
volumeId,
|
||||
title ?? '交互初稿',
|
||||
'interactive',
|
||||
'交互写作成章'
|
||||
)
|
||||
}
|
||||
|
||||
getSceneDraft(flowId: string): string {
|
||||
@@ -228,14 +185,4 @@ export class InteractiveWritingService {
|
||||
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('')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { InteractiveFlow, NamingPause } from '../../shared/types'
|
||||
import type { InteractiveRepository } from '../db/repositories/interactive.repo'
|
||||
import type { AiClientService, ChatMessage } from './ai-client.service'
|
||||
import { buildNamingResumeMessages } from './interactive-prompt-builder.service'
|
||||
import { tryParseNamingFromBuffer } from './interactive-parse.service'
|
||||
|
||||
export class SceneGenerationCore {
|
||||
constructor(
|
||||
private repo: InteractiveRepository,
|
||||
private aiClient: AiClientService
|
||||
) {}
|
||||
|
||||
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('')
|
||||
}
|
||||
|
||||
async generateStream(
|
||||
flowId: string,
|
||||
messages: ChatMessage[],
|
||||
onChunk: (delta: string, done: boolean) => void,
|
||||
abortSignal?: AbortSignal,
|
||||
reviewStep: 'scene_review' | 'auto_generate' = 'scene_review'
|
||||
): Promise<{ flow: InteractiveFlow; html: string; naming: NamingPause | null }> {
|
||||
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 { flow: this.repo.get(flowId)!, html: '', naming }
|
||||
}
|
||||
|
||||
const html = this.normalizeSceneHtml(full || buffer)
|
||||
this.repo.setSceneDraft(flowId, html)
|
||||
this.repo.updateStep(flowId, reviewStep, { namingPendingJson: null })
|
||||
onChunk('', true)
|
||||
return { flow: this.repo.get(flowId)!, html, naming: null }
|
||||
}
|
||||
|
||||
async resolveNaming(
|
||||
flowId: string,
|
||||
chosenName: string,
|
||||
namingPending: NamingPause,
|
||||
onChunk: (delta: string, done: boolean) => void,
|
||||
abortSignal?: AbortSignal,
|
||||
reviewStep: 'scene_review' | 'auto_generate' = 'scene_review'
|
||||
): Promise<InteractiveFlow> {
|
||||
const messages = buildNamingResumeMessages(
|
||||
chosenName,
|
||||
this.repo.getSceneDraft(flowId),
|
||||
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, reviewStep, { namingPendingJson: null })
|
||||
onChunk('', true)
|
||||
return this.repo.get(flowId)!
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import type {
|
||||
AiContextBinding,
|
||||
AutoRhythm,
|
||||
AutoWritingConfig,
|
||||
InteractiveFlow,
|
||||
WizardWritingConfig
|
||||
} from '../../shared/types'
|
||||
import type { SqliteDb } from '../db/types'
|
||||
import { InteractiveRepository } from '../db/repositories/interactive.repo'
|
||||
import { SettingRepository } from '../db/repositories/setting.repo'
|
||||
import type { AiClientService } from './ai-client.service'
|
||||
import { buildWizardPreviewMessages } from './auto-prompt-builder.service'
|
||||
import { AutoWritingService } from './auto-writing.service'
|
||||
import { handoffToInteractive } from './writing-flow-handoff.service'
|
||||
|
||||
export class WizardWritingService {
|
||||
private repo: InteractiveRepository
|
||||
private autoService: AutoWritingService
|
||||
|
||||
constructor(
|
||||
private db: SqliteDb,
|
||||
private aiClient: AiClientService
|
||||
) {
|
||||
this.repo = new InteractiveRepository(db)
|
||||
this.autoService = new AutoWritingService(db, aiClient)
|
||||
}
|
||||
|
||||
start(sessionId: string): InteractiveFlow {
|
||||
return this.repo.resetInProgress(sessionId, 'wizard')
|
||||
}
|
||||
|
||||
getFlow(sessionId: string): InteractiveFlow | null {
|
||||
return this.repo.getBySession(sessionId)
|
||||
}
|
||||
|
||||
setGoal(flowId: string, partial: Partial<AutoWritingConfig>): InteractiveFlow {
|
||||
const existing = this.repo.getModeConfig<WizardWritingConfig>(flowId)
|
||||
const config: WizardWritingConfig = {
|
||||
rhythm: existing.rhythm ?? 'mixed',
|
||||
chapterGoal: partial.chapterGoal ?? existing.chapterGoal ?? '',
|
||||
outlineItemIds: partial.outlineItemIds ?? existing.outlineItemIds,
|
||||
wordMin: partial.wordMin ?? existing.wordMin ?? 2000,
|
||||
wordMax: partial.wordMax ?? existing.wordMax ?? 4000
|
||||
}
|
||||
this.repo.setModeConfig(flowId, config)
|
||||
return this.repo.updateStep(flowId, 'wizard_rhythm')
|
||||
}
|
||||
|
||||
setRhythm(flowId: string, rhythm: AutoRhythm, styleNote?: string): InteractiveFlow {
|
||||
const config = this.repo.getModeConfig<WizardWritingConfig>(flowId)
|
||||
this.repo.setModeConfig(flowId, { ...config, rhythm, styleNote: styleNote?.trim() || undefined })
|
||||
return this.repo.updateStep(flowId, 'wizard_pov')
|
||||
}
|
||||
|
||||
setPov(flowId: string, povSettingId?: string): InteractiveFlow {
|
||||
const config = this.repo.getModeConfig<WizardWritingConfig>(flowId)
|
||||
this.repo.setModeConfig(flowId, { ...config, povSettingId: povSettingId || undefined })
|
||||
return this.repo.updateStep(flowId, 'wizard_context')
|
||||
}
|
||||
|
||||
confirmContext(
|
||||
flowId: string,
|
||||
binding: AiContextBinding,
|
||||
contextSummary: string
|
||||
): InteractiveFlow {
|
||||
const config = this.repo.getModeConfig<WizardWritingConfig>(flowId)
|
||||
this.repo.setModeConfig(flowId, { ...config, contextBinding: binding })
|
||||
this.repo.updateStep(flowId, 'wizard_preview', {
|
||||
contextJson: JSON.stringify({ binding, contextSummary })
|
||||
})
|
||||
return this.repo.get(flowId)!
|
||||
}
|
||||
|
||||
async buildPreview(flowId: string, contextText: string, povName: string): Promise<string> {
|
||||
const config = this.repo.getModeConfig<WizardWritingConfig>(flowId)
|
||||
const messages = buildWizardPreviewMessages(config, contextText, povName)
|
||||
const html = await this.aiClient.chat(messages)
|
||||
const preview = html.trim().includes('<p>') ? html.trim() : `<p>${html.trim()}</p>`
|
||||
this.repo.setModeConfig(flowId, { ...config, strategyPreview: preview })
|
||||
return preview
|
||||
}
|
||||
|
||||
async startGenerate(
|
||||
flowId: string,
|
||||
contextText: string,
|
||||
onChunk: (delta: string, done: boolean) => void,
|
||||
abortSignal?: AbortSignal
|
||||
): Promise<InteractiveFlow> {
|
||||
this.repo.updateStep(flowId, 'wizard_generating')
|
||||
const wizardConfig = this.repo.getModeConfig<WizardWritingConfig>(flowId)
|
||||
const autoConfig: AutoWritingConfig = {
|
||||
chapterGoal: wizardConfig.chapterGoal,
|
||||
outlineItemIds: wizardConfig.outlineItemIds,
|
||||
wordMin: wizardConfig.wordMin ?? 2000,
|
||||
wordMax: wizardConfig.wordMax ?? 4000,
|
||||
currentPlanIndex: 0
|
||||
}
|
||||
this.repo.setModeConfig(flowId, { ...wizardConfig, ...autoConfig })
|
||||
this.autoService.setContextSummary(flowId, contextText)
|
||||
|
||||
await this.autoService.planScenes(flowId, contextText)
|
||||
|
||||
let flow = this.repo.get(flowId)!
|
||||
let config = this.repo.getModeConfig<AutoWritingConfig>(flowId)
|
||||
const planLen = config.scenePlan?.length ?? 0
|
||||
|
||||
while ((config.currentPlanIndex ?? 0) < planLen) {
|
||||
if (abortSignal?.aborted) break
|
||||
flow = await this.autoService.generateNext(flowId, contextText, onChunk, abortSignal)
|
||||
if (flow.step === 'naming_pause') return flow
|
||||
config = this.repo.getModeConfig<AutoWritingConfig>(flowId)
|
||||
}
|
||||
|
||||
return handoffToInteractive(this.repo, flowId)
|
||||
}
|
||||
|
||||
handoffToInteractive(flowId: string): InteractiveFlow {
|
||||
return handoffToInteractive(this.repo, flowId)
|
||||
}
|
||||
|
||||
enrichFlow(flow: InteractiveFlow): InteractiveFlow {
|
||||
return this.autoService.enrichFlow(flow)
|
||||
}
|
||||
|
||||
getPovName(povSettingId?: string): string {
|
||||
if (!povSettingId) return '主角'
|
||||
const setting = new SettingRepository(this.db).get(povSettingId)
|
||||
return setting?.name ?? '主角'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { InteractiveFlow, WritingStep } from '../../shared/types'
|
||||
import type { InteractiveRepository } from '../db/repositories/interactive.repo'
|
||||
|
||||
export function handoffToInteractive(repo: InteractiveRepository, flowId: string): InteractiveFlow {
|
||||
const draft = repo.getSceneDraft(flowId)
|
||||
const scenes = repo.listScenes(flowId)
|
||||
let step: WritingStep = 'context_confirm'
|
||||
if (draft.trim()) step = 'scene_review'
|
||||
else if (scenes.length > 0) step = 'plot_suggest'
|
||||
return repo.updateStep(flowId, step, { flowMode: 'interactive' })
|
||||
}
|
||||
@@ -17,6 +17,8 @@ import type {
|
||||
NamingConflict,
|
||||
AiStreamChunkEvent,
|
||||
AiNetworkStatusEvent,
|
||||
AutoRhythm,
|
||||
AutoWritingConfig,
|
||||
InteractiveFlow,
|
||||
InteractiveGateResult,
|
||||
InteractiveStreamChunkEvent,
|
||||
@@ -318,6 +320,94 @@ const electronAPI = {
|
||||
getSceneDraft: (bookId: string, flowId: string): Promise<IpcResult<string>> =>
|
||||
ipcRenderer.invoke(IPC.INTERACTIVE_GET_SCENE_DRAFT, { bookId, flowId })
|
||||
},
|
||||
auto: {
|
||||
checkGate: (bookId: string): Promise<IpcResult<InteractiveGateResult>> =>
|
||||
ipcRenderer.invoke(IPC.AUTO_CHECK_GATE, { bookId }),
|
||||
getFlow: (bookId: string, sessionId: string): Promise<IpcResult<InteractiveFlow | null>> =>
|
||||
ipcRenderer.invoke(IPC.AUTO_GET_FLOW, { bookId, sessionId }),
|
||||
start: (bookId: string, sessionId: string): Promise<IpcResult<InteractiveFlow>> =>
|
||||
ipcRenderer.invoke(IPC.AUTO_START, { bookId, sessionId }),
|
||||
setGoal: (
|
||||
bookId: string,
|
||||
flowId: string,
|
||||
config: Partial<AutoWritingConfig>
|
||||
): Promise<IpcResult<InteractiveFlow>> =>
|
||||
ipcRenderer.invoke(IPC.AUTO_SET_GOAL, { bookId, flowId, config }),
|
||||
planScenes: (
|
||||
bookId: string,
|
||||
flowId: string,
|
||||
contextSummary?: string
|
||||
): Promise<IpcResult<InteractiveFlow>> =>
|
||||
ipcRenderer.invoke(IPC.AUTO_PLAN_SCENES, { bookId, flowId, contextSummary }),
|
||||
generate: (
|
||||
bookId: string,
|
||||
flowId: string,
|
||||
contextSummary?: string
|
||||
): Promise<IpcResult<InteractiveFlow>> =>
|
||||
ipcRenderer.invoke(IPC.AUTO_GENERATE, { bookId, flowId, contextSummary }),
|
||||
resolveNaming: (
|
||||
bookId: string,
|
||||
flowId: string,
|
||||
chosenName: string,
|
||||
contextSummary?: string
|
||||
): Promise<IpcResult<InteractiveFlow>> =>
|
||||
ipcRenderer.invoke(IPC.AUTO_RESOLVE_NAMING, { bookId, flowId, chosenName, contextSummary }),
|
||||
pause: (bookId: string, flowId: string): Promise<IpcResult<InteractiveFlow>> =>
|
||||
ipcRenderer.invoke(IPC.AUTO_PAUSE, { bookId, flowId }),
|
||||
resume: (bookId: string, flowId: string): Promise<IpcResult<InteractiveFlow>> =>
|
||||
ipcRenderer.invoke(IPC.AUTO_RESUME, { bookId, flowId }),
|
||||
handoffInteractive: (bookId: string, flowId: string): Promise<IpcResult<InteractiveFlow>> =>
|
||||
ipcRenderer.invoke(IPC.AUTO_HANDOFF_INTERACTIVE, { bookId, flowId }),
|
||||
finishChapter: (
|
||||
bookId: string,
|
||||
flowId: string,
|
||||
volumeId: string,
|
||||
title?: string
|
||||
): Promise<IpcResult<{ chapterId: string }>> =>
|
||||
ipcRenderer.invoke(IPC.AUTO_FINISH_CHAPTER, { bookId, flowId, volumeId, title }),
|
||||
abort: (flowId: string): Promise<IpcResult<void>> =>
|
||||
ipcRenderer.invoke(IPC.AUTO_ABORT, { flowId }),
|
||||
getSceneDraft: (bookId: string, flowId: string): Promise<IpcResult<string>> =>
|
||||
ipcRenderer.invoke(IPC.AUTO_GET_SCENE_DRAFT, { bookId, flowId })
|
||||
},
|
||||
wizard: {
|
||||
getFlow: (bookId: string, sessionId: string): Promise<IpcResult<InteractiveFlow | null>> =>
|
||||
ipcRenderer.invoke(IPC.WIZARD_GET_FLOW, { bookId, sessionId }),
|
||||
start: (bookId: string, sessionId: string): Promise<IpcResult<InteractiveFlow>> =>
|
||||
ipcRenderer.invoke(IPC.WIZARD_START, { bookId, sessionId }),
|
||||
setGoal: (
|
||||
bookId: string,
|
||||
flowId: string,
|
||||
config: Partial<AutoWritingConfig>
|
||||
): Promise<IpcResult<InteractiveFlow>> =>
|
||||
ipcRenderer.invoke(IPC.WIZARD_SET_GOAL, { bookId, flowId, config }),
|
||||
setRhythm: (
|
||||
bookId: string,
|
||||
flowId: string,
|
||||
rhythm: AutoRhythm,
|
||||
styleNote?: string
|
||||
): Promise<IpcResult<InteractiveFlow>> =>
|
||||
ipcRenderer.invoke(IPC.WIZARD_SET_RHYTHM, { bookId, flowId, rhythm, styleNote }),
|
||||
setPov: (bookId: string, flowId: string, povSettingId: string): Promise<IpcResult<InteractiveFlow>> =>
|
||||
ipcRenderer.invoke(IPC.WIZARD_SET_POV, { bookId, flowId, povSettingId }),
|
||||
confirmContext: (
|
||||
bookId: string,
|
||||
flowId: string,
|
||||
binding: AiContextBinding
|
||||
): Promise<IpcResult<InteractiveFlow>> =>
|
||||
ipcRenderer.invoke(IPC.WIZARD_CONFIRM_CONTEXT, { bookId, flowId, binding }),
|
||||
buildPreview: (
|
||||
bookId: string,
|
||||
flowId: string
|
||||
): Promise<IpcResult<{ preview: string; flow: InteractiveFlow }>> =>
|
||||
ipcRenderer.invoke(IPC.WIZARD_BUILD_PREVIEW, { bookId, flowId }),
|
||||
startGenerate: (bookId: string, flowId: string): Promise<IpcResult<InteractiveFlow>> =>
|
||||
ipcRenderer.invoke(IPC.WIZARD_START_GENERATE, { bookId, flowId }),
|
||||
handoffInteractive: (bookId: string, flowId: string): Promise<IpcResult<InteractiveFlow>> =>
|
||||
ipcRenderer.invoke(IPC.WIZARD_HANDOFF_INTERACTIVE, { bookId, flowId }),
|
||||
abort: (flowId: string): Promise<IpcResult<void>> =>
|
||||
ipcRenderer.invoke(IPC.WIZARD_ABORT, { flowId })
|
||||
},
|
||||
wordfreq: {
|
||||
analyze: (
|
||||
bookId: string,
|
||||
|
||||
@@ -10,7 +10,11 @@ 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 { AutoPanel } from '@renderer/components/ai/AutoPanel'
|
||||
import { WizardPanel } from '@renderer/components/ai/WizardPanel'
|
||||
import { useInteractiveStore } from '@renderer/stores/useInteractiveStore'
|
||||
import { useAutoStore } from '@renderer/stores/useAutoStore'
|
||||
import { useWizardStore } from '@renderer/stores/useWizardStore'
|
||||
|
||||
const MODES: { id: AiWritingMode; labelKey: string }[] = [
|
||||
{ id: 'chat', labelKey: 'ai.mode.chat' },
|
||||
@@ -42,8 +46,16 @@ export function AiPanel(): React.JSX.Element {
|
||||
const sendMessage = useAiStore((s) => s.sendMessage)
|
||||
const setWritingMode = useAiStore((s) => s.setWritingMode)
|
||||
const gateEligible = useInteractiveStore((s) => s.gateEligible)
|
||||
const autoGateEligible = useAutoStore((s) => s.gateEligible)
|
||||
const wizardGateEligible = useWizardStore((s) => s.gateEligible)
|
||||
const checkGate = useInteractiveStore((s) => s.checkGate)
|
||||
const checkAutoGate = useAutoStore((s) => s.checkGate)
|
||||
const checkWizardGate = useWizardStore((s) => s.checkGate)
|
||||
const startInteractive = useInteractiveStore((s) => s.start)
|
||||
const startAuto = useAutoStore((s) => s.start)
|
||||
const startWizard = useWizardStore((s) => s.start)
|
||||
const loadAutoFlow = useAutoStore((s) => s.loadFlow)
|
||||
const loadWizardFlow = useWizardStore((s) => s.loadFlow)
|
||||
const [input, setInput] = useState('')
|
||||
const [contextOpen, setContextOpen] = useState(false)
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||
@@ -65,8 +77,10 @@ export function AiPanel(): React.JSX.Element {
|
||||
if (bookId) {
|
||||
void loadSessions(bookId)
|
||||
void checkGate(bookId)
|
||||
void checkAutoGate(bookId)
|
||||
void checkWizardGate(bookId)
|
||||
}
|
||||
}, [bookId, loadSessions, checkGate])
|
||||
}, [bookId, loadSessions, checkGate, checkAutoGate, checkWizardGate])
|
||||
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
@@ -93,31 +107,62 @@ export function AiPanel(): React.JSX.Element {
|
||||
}
|
||||
}
|
||||
|
||||
const enterWritingMode = (mode: 'interactive' | 'auto' | 'wizard'): void => {
|
||||
void (async () => {
|
||||
const gateCheck =
|
||||
mode === 'interactive'
|
||||
? checkGate
|
||||
: mode === 'auto'
|
||||
? checkAutoGate
|
||||
: checkWizardGate
|
||||
const gateKey =
|
||||
mode === 'interactive'
|
||||
? 'interactive'
|
||||
: mode === 'auto'
|
||||
? 'auto'
|
||||
: 'wizard'
|
||||
if (bookId) await gateCheck(bookId)
|
||||
const eligible =
|
||||
mode === 'interactive'
|
||||
? useInteractiveStore.getState().gateEligible
|
||||
: mode === 'auto'
|
||||
? useAutoStore.getState().gateEligible
|
||||
: useWizardStore.getState().gateEligible
|
||||
if (!eligible) {
|
||||
showToast(t(`${gateKey}.gateBlocked`))
|
||||
return
|
||||
}
|
||||
setWritingMode(mode)
|
||||
if (!bookId) return
|
||||
if (!useAiStore.getState().activeSessionId) {
|
||||
await createSession(bookId)
|
||||
}
|
||||
const sessionId = useAiStore.getState().activeSessionId
|
||||
if (!sessionId) return
|
||||
if (mode === 'interactive') {
|
||||
await useInteractiveStore.getState().loadFlow(bookId, sessionId)
|
||||
if (!useInteractiveStore.getState().flow) await startInteractive(bookId, sessionId)
|
||||
} else if (mode === 'auto') {
|
||||
await loadAutoFlow(bookId, sessionId)
|
||||
if (!useAutoStore.getState().flow) await startAuto(bookId, sessionId)
|
||||
} else {
|
||||
await loadWizardFlow(bookId, sessionId)
|
||||
if (!useWizardStore.getState().flow) await startWizard(bookId, sessionId)
|
||||
}
|
||||
})()
|
||||
}
|
||||
|
||||
const handleModeClick = (mode: AiWritingMode): void => {
|
||||
if (mode === 'auto' || mode === 'wizard') {
|
||||
showToast(t('feature.comingSoon'))
|
||||
if (mode === 'interactive') {
|
||||
enterWritingMode('interactive')
|
||||
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)
|
||||
}
|
||||
})()
|
||||
if (mode === 'auto') {
|
||||
enterWritingMode('auto')
|
||||
return
|
||||
}
|
||||
if (mode === 'wizard') {
|
||||
enterWritingMode('wizard')
|
||||
return
|
||||
}
|
||||
setWritingMode(mode)
|
||||
@@ -159,15 +204,24 @@ export function AiPanel(): React.JSX.Element {
|
||||
|
||||
<div className="ai-mode-tabs" role="tablist">
|
||||
{MODES.map((mode) => {
|
||||
const interactiveDisabled = mode.id === 'interactive' && !gateEligible
|
||||
const modeGate =
|
||||
mode.id === 'interactive'
|
||||
? gateEligible
|
||||
: mode.id === 'auto'
|
||||
? autoGateEligible
|
||||
: mode.id === 'wizard'
|
||||
? wizardGateEligible
|
||||
: true
|
||||
const writingDisabled =
|
||||
(mode.id === 'interactive' || mode.id === 'auto' || mode.id === 'wizard') && !modeGate
|
||||
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}
|
||||
disabled={writingDisabled}
|
||||
title={writingDisabled ? t(`${mode.id}.gateTooltip`) : undefined}
|
||||
onClick={() => handleModeClick(mode.id)}
|
||||
>
|
||||
{t(mode.labelKey)}
|
||||
@@ -178,6 +232,10 @@ export function AiPanel(): React.JSX.Element {
|
||||
|
||||
{writingMode === 'interactive' ? (
|
||||
<InteractivePanel bookId={bookId} sessionId={activeSessionId} disabled={disabled} />
|
||||
) : writingMode === 'auto' ? (
|
||||
<AutoPanel bookId={bookId} sessionId={activeSessionId} disabled={disabled} />
|
||||
) : writingMode === 'wizard' ? (
|
||||
<WizardPanel bookId={bookId} sessionId={activeSessionId} disabled={disabled} />
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { AutoWritingConfig, OutlineItem } from '@shared/types'
|
||||
import { ipcCall } from '@renderer/lib/ipc-client'
|
||||
import { useAutoStore } from '@renderer/stores/useAutoStore'
|
||||
|
||||
interface AutoPanelProps {
|
||||
bookId: string
|
||||
sessionId: string | null
|
||||
disabled: boolean
|
||||
}
|
||||
|
||||
export function AutoPanel({ bookId, sessionId, disabled }: AutoPanelProps): React.JSX.Element {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
flow,
|
||||
streaming,
|
||||
streamBuffer,
|
||||
sceneDraft,
|
||||
loadFlow,
|
||||
start,
|
||||
setGoal,
|
||||
planScenes,
|
||||
generateNext,
|
||||
resolveNaming,
|
||||
pause,
|
||||
resume,
|
||||
handoffInteractive,
|
||||
finishChapter,
|
||||
mount,
|
||||
unmount
|
||||
} = useAutoStore()
|
||||
|
||||
const [goal, setGoalText] = useState('')
|
||||
const [wordMin, setWordMin] = useState(2000)
|
||||
const [wordMax, setWordMax] = useState(4000)
|
||||
const [outlineIds, setOutlineIds] = useState<string[]>([])
|
||||
const [outlines, setOutlines] = useState<OutlineItem[]>([])
|
||||
const [chapterTitle, setChapterTitle] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
mount()
|
||||
return () => unmount()
|
||||
}, [mount, unmount])
|
||||
|
||||
useEffect(() => {
|
||||
if (bookId && sessionId) void loadFlow(bookId, sessionId)
|
||||
}, [bookId, sessionId, loadFlow])
|
||||
|
||||
useEffect(() => {
|
||||
if (!bookId) return
|
||||
void ipcCall(() => window.electronAPI.outline.list(bookId)).then(setOutlines)
|
||||
}, [bookId])
|
||||
|
||||
if (!sessionId || !flow) {
|
||||
return (
|
||||
<div className="auto-panel" data-testid="auto-panel">
|
||||
<p className="placeholder-box">{t('ai.noSession')}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const step = flow.step
|
||||
const previewHtml = streaming ? streamBuffer : sceneDraft
|
||||
const config = flow.modeConfig as AutoWritingConfig
|
||||
|
||||
const submitGoal = (): void => {
|
||||
const payload: Partial<AutoWritingConfig> = {
|
||||
chapterGoal: goal.trim(),
|
||||
outlineItemIds: outlineIds,
|
||||
wordMin,
|
||||
wordMax
|
||||
}
|
||||
void setGoal(bookId, payload)
|
||||
}
|
||||
|
||||
const toggleOutline = (id: string): void => {
|
||||
setOutlineIds((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="auto-panel interactive-panel" data-testid="auto-panel">
|
||||
<div className="interactive-step-bar" data-testid="auto-step-indicator">
|
||||
{t('auto.step.goal')} → {t('auto.step.plan')} → {t('auto.step.generate')} → {t('auto.step.done')}
|
||||
</div>
|
||||
|
||||
{(step === 'goal_setup' || step === 'scene_plan') && (
|
||||
<div className="interactive-section">
|
||||
<textarea
|
||||
className="form-control"
|
||||
data-testid="auto-goal-input"
|
||||
placeholder={t('auto.goalPlaceholder')}
|
||||
value={goal}
|
||||
onChange={(e) => setGoalText(e.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
<div className="auto-outline-picker" data-testid="auto-outline-picker">
|
||||
{outlines.map((o) => (
|
||||
<label key={o.id} className="auto-outline-option">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={outlineIds.includes(o.id)}
|
||||
onChange={() => toggleOutline(o.id)}
|
||||
/>
|
||||
{o.title}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<div className="auto-word-range">
|
||||
<input
|
||||
type="number"
|
||||
data-testid="auto-word-min"
|
||||
value={wordMin}
|
||||
onChange={(e) => setWordMin(Number(e.target.value))}
|
||||
/>
|
||||
<span>–</span>
|
||||
<input
|
||||
type="number"
|
||||
data-testid="auto-word-max"
|
||||
value={wordMax}
|
||||
onChange={(e) => setWordMax(Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn primary"
|
||||
data-testid="auto-plan-scenes"
|
||||
disabled={disabled || streaming || !goal.trim()}
|
||||
onClick={() => {
|
||||
void (async () => {
|
||||
await setGoal(bookId, { chapterGoal: goal.trim(), outlineItemIds: outlineIds, wordMin, wordMax })
|
||||
await planScenes(bookId)
|
||||
})()
|
||||
}}
|
||||
>
|
||||
{t('auto.planScenes')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(step === 'auto_generate' || step === 'paused' || step === 'naming_pause') && (
|
||||
<div className="interactive-section">
|
||||
{config.scenePlan && (
|
||||
<p className="auto-plan-summary">
|
||||
{t('auto.sceneProgress', {
|
||||
current: config.currentPlanIndex ?? 0,
|
||||
total: config.scenePlan.length
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
<div
|
||||
className="interactive-scene-preview"
|
||||
data-testid="auto-scene-preview"
|
||||
dangerouslySetInnerHTML={{ __html: previewHtml || '<p>…</p>' }}
|
||||
/>
|
||||
{step === 'naming_pause' && flow.namingPending && (
|
||||
<div className="naming-options">
|
||||
{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>
|
||||
)}
|
||||
<div className="interactive-actions">
|
||||
{step === 'auto_generate' && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
data-testid="auto-pause"
|
||||
disabled={disabled || streaming}
|
||||
onClick={() => void pause(bookId)}
|
||||
>
|
||||
{t('auto.pause')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn primary"
|
||||
data-testid="auto-start-generate"
|
||||
disabled={disabled || streaming}
|
||||
onClick={() => void generateNext(bookId)}
|
||||
>
|
||||
{t('auto.startGenerate')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{step === 'paused' && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
disabled={disabled || streaming}
|
||||
onClick={() => void resume(bookId)}
|
||||
>
|
||||
{t('auto.resume')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
data-testid="auto-handoff-interactive"
|
||||
disabled={disabled}
|
||||
onClick={() => void handoffInteractive(bookId)}
|
||||
>
|
||||
{t('auto.handoffInteractive')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{flow.scenes.length > 0 && (
|
||||
<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="auto-finish-chapter"
|
||||
disabled={disabled || streaming}
|
||||
onClick={() => void finishChapter(bookId, chapterTitle.trim() || undefined)}
|
||||
>
|
||||
{t('auto.finishChapter')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { AutoRhythm, AutoWritingConfig, SettingEntry, WizardWritingConfig } from '@shared/types'
|
||||
import { ipcCall } from '@renderer/lib/ipc-client'
|
||||
import { useWizardStore } from '@renderer/stores/useWizardStore'
|
||||
import { ContextEditorModal } from '@renderer/components/ai/ContextEditorModal'
|
||||
import { useAiStore } from '@renderer/stores/useAiStore'
|
||||
|
||||
interface WizardPanelProps {
|
||||
bookId: string
|
||||
sessionId: string | null
|
||||
disabled: boolean
|
||||
}
|
||||
|
||||
const RHYTHMS: AutoRhythm[] = ['relaxed', 'tense', 'mixed']
|
||||
|
||||
export function WizardPanel({ bookId, sessionId, disabled }: WizardPanelProps): React.JSX.Element {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
flow,
|
||||
previewHtml,
|
||||
streaming,
|
||||
streamBuffer,
|
||||
loadFlow,
|
||||
start,
|
||||
setGoal,
|
||||
setRhythm,
|
||||
setPov,
|
||||
confirmContext,
|
||||
buildPreview,
|
||||
startGenerate,
|
||||
mount,
|
||||
unmount
|
||||
} = useWizardStore()
|
||||
|
||||
const [goal, setGoalText] = useState('')
|
||||
const [wordMin, setWordMin] = useState(2000)
|
||||
const [wordMax, setWordMax] = useState(4000)
|
||||
const [rhythm, setRhythmVal] = useState<AutoRhythm>('mixed')
|
||||
const [styleNote, setStyleNote] = useState('')
|
||||
const [characters, setCharacters] = useState<SettingEntry[]>([])
|
||||
const [povId, setPovId] = useState('')
|
||||
const [contextOpen, setContextOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
mount()
|
||||
return () => unmount()
|
||||
}, [mount, unmount])
|
||||
|
||||
useEffect(() => {
|
||||
if (bookId && sessionId) void loadFlow(bookId, sessionId)
|
||||
}, [bookId, sessionId, loadFlow])
|
||||
|
||||
useEffect(() => {
|
||||
if (!bookId) return
|
||||
void ipcCall(() => window.electronAPI.setting.list(bookId)).then((list) => {
|
||||
setCharacters(list.filter((s) => s.type === 'character'))
|
||||
})
|
||||
}, [bookId])
|
||||
|
||||
if (!sessionId || !flow) {
|
||||
return (
|
||||
<div className="wizard-panel" data-testid="wizard-panel">
|
||||
<p className="placeholder-box">{t('ai.noSession')}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const step = flow.step
|
||||
const config = flow.modeConfig as WizardWritingConfig
|
||||
|
||||
const handleContextClose = (): void => {
|
||||
setContextOpen(false)
|
||||
const session = useAiStore.getState().sessions.find(
|
||||
(s) => s.id === useAiStore.getState().activeSessionId
|
||||
)
|
||||
if (session) void confirmContext(bookId, session.context)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="wizard-panel interactive-panel" data-testid="wizard-panel">
|
||||
<div className="wizard-step-indicator" data-testid="wizard-step-indicator">
|
||||
{t('wizard.step.goal')} → {t('wizard.step.rhythm')} → {t('wizard.step.pov')} →{' '}
|
||||
{t('wizard.step.context')} → {t('wizard.step.preview')}
|
||||
</div>
|
||||
|
||||
{step === 'wizard_goal' && (
|
||||
<div className="interactive-section">
|
||||
<textarea
|
||||
className="form-control"
|
||||
data-testid="wizard-goal-input"
|
||||
placeholder={t('auto.goalPlaceholder')}
|
||||
value={goal}
|
||||
onChange={(e) => setGoalText(e.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
<div className="auto-word-range">
|
||||
<input type="number" value={wordMin} onChange={(e) => setWordMin(Number(e.target.value))} />
|
||||
<span>–</span>
|
||||
<input type="number" value={wordMax} onChange={(e) => setWordMax(Number(e.target.value))} />
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn primary"
|
||||
disabled={disabled || !goal.trim()}
|
||||
onClick={() =>
|
||||
void setGoal(bookId, {
|
||||
chapterGoal: goal.trim(),
|
||||
wordMin,
|
||||
wordMax
|
||||
} as Partial<AutoWritingConfig>)
|
||||
}
|
||||
>
|
||||
{t('wizard.next')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'wizard_rhythm' && (
|
||||
<div className="interactive-section">
|
||||
{RHYTHMS.map((r) => (
|
||||
<button
|
||||
key={r}
|
||||
type="button"
|
||||
className={`btn ${rhythm === r ? 'primary' : ''}`}
|
||||
data-testid={`wizard-rhythm-${r}`}
|
||||
disabled={disabled}
|
||||
onClick={() => setRhythmVal(r)}
|
||||
>
|
||||
{t(`wizard.rhythm.${r}`)}
|
||||
</button>
|
||||
))}
|
||||
<input
|
||||
className="form-control"
|
||||
data-testid="wizard-style-note"
|
||||
placeholder={t('wizard.styleNotePlaceholder')}
|
||||
value={styleNote}
|
||||
onChange={(e) => setStyleNote(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn primary"
|
||||
disabled={disabled}
|
||||
onClick={() => void setRhythm(bookId, rhythm, styleNote)}
|
||||
>
|
||||
{t('wizard.next')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'wizard_pov' && (
|
||||
<div className="interactive-section">
|
||||
<select
|
||||
className="form-control"
|
||||
data-testid="wizard-pov-select"
|
||||
value={povId}
|
||||
onChange={(e) => setPovId(e.target.value)}
|
||||
>
|
||||
<option value="">{t('wizard.povDefault')}</option>
|
||||
{characters.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
className="btn primary"
|
||||
disabled={disabled}
|
||||
onClick={() => void setPov(bookId, povId || characters[0]?.id || '')}
|
||||
>
|
||||
{t('wizard.next')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'wizard_context' && (
|
||||
<div className="interactive-section">
|
||||
<p>{t('wizard.contextHint')}</p>
|
||||
<button
|
||||
type="button"
|
||||
className="btn primary"
|
||||
data-testid="wizard-context-edit"
|
||||
disabled={disabled}
|
||||
onClick={() => setContextOpen(true)}
|
||||
>
|
||||
{t('ai.contextEditorTitle')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'wizard_preview' && (
|
||||
<div className="interactive-section">
|
||||
<h4>{t('wizard.previewTitle')}</h4>
|
||||
<div
|
||||
className="wizard-preview"
|
||||
data-testid="wizard-preview"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: previewHtml || config.strategyPreview || '<p>…</p>'
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
disabled={disabled || streaming}
|
||||
onClick={() => void buildPreview(bookId)}
|
||||
>
|
||||
{t('wizard.refreshPreview')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn primary"
|
||||
data-testid="wizard-start-generate"
|
||||
disabled={disabled || streaming}
|
||||
onClick={() => void startGenerate(bookId)}
|
||||
>
|
||||
{t('wizard.startGenerate')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'wizard_generating' && (
|
||||
<div className="interactive-section">
|
||||
<p>{streaming ? t('ai.sending') : t('wizard.generating')}</p>
|
||||
<div
|
||||
className="interactive-scene-preview"
|
||||
dangerouslySetInnerHTML={{ __html: streamBuffer || '<p>…</p>' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<ContextEditorModal open={contextOpen} onClose={handleContextClose} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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.4.0
|
||||
{t('app.name')} v0.5.0
|
||||
<br />
|
||||
{t('app.tagline')}
|
||||
</p>
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
import { create } from 'zustand'
|
||||
import type { AutoWritingConfig, InteractiveFlow } 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 AutoState {
|
||||
gateEligible: boolean
|
||||
flow: InteractiveFlow | null
|
||||
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>
|
||||
setGoal: (bookId: string, config: Partial<AutoWritingConfig>) => Promise<void>
|
||||
planScenes: (bookId: string) => Promise<void>
|
||||
generateNext: (bookId: string) => Promise<void>
|
||||
resolveNaming: (bookId: string, chosenName: string) => Promise<void>
|
||||
pause: (bookId: string) => Promise<void>
|
||||
resume: (bookId: string) => Promise<void>
|
||||
handoffInteractive: (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 useAutoStore = create<AutoState>((set, get) => ({
|
||||
gateEligible: false,
|
||||
flow: null,
|
||||
streamBuffer: '',
|
||||
streaming: false,
|
||||
sceneDraft: '',
|
||||
listenersAttached: false,
|
||||
|
||||
checkGate: async (bookId) => {
|
||||
const r = await ipcCall(() => window.electronAPI.auto.checkGate(bookId))
|
||||
set({ gateEligible: r.eligible })
|
||||
},
|
||||
|
||||
loadFlow: async (bookId, sessionId) => {
|
||||
const flow = await ipcCall(() => window.electronAPI.auto.getFlow(bookId, sessionId))
|
||||
set({ flow })
|
||||
if (flow) await get().refreshSceneDraft(bookId)
|
||||
},
|
||||
|
||||
start: async (bookId, sessionId) => {
|
||||
const flow = await ipcCall(() => window.electronAPI.auto.start(bookId, sessionId))
|
||||
set({ flow, streamBuffer: '', sceneDraft: '' })
|
||||
},
|
||||
|
||||
setGoal: async (bookId, config) => {
|
||||
const flowId = get().flow?.id
|
||||
if (!flowId) return
|
||||
const flow = await ipcCall(() => window.electronAPI.auto.setGoal(bookId, flowId, config))
|
||||
set({ flow })
|
||||
},
|
||||
|
||||
planScenes: async (bookId) => {
|
||||
const flowId = get().flow?.id
|
||||
if (!flowId) return
|
||||
set({ streaming: true })
|
||||
try {
|
||||
const summary = useAiStore.getState().contextSummary
|
||||
const flow = await ipcCall(() =>
|
||||
window.electronAPI.auto.planScenes(bookId, flowId, summary)
|
||||
)
|
||||
set({ flow })
|
||||
} finally {
|
||||
set({ streaming: false })
|
||||
}
|
||||
},
|
||||
|
||||
generateNext: async (bookId) => {
|
||||
const flowId = get().flow?.id
|
||||
if (!flowId) return
|
||||
set({ streaming: true, streamBuffer: '' })
|
||||
try {
|
||||
const summary = useAiStore.getState().contextSummary
|
||||
const flow = await ipcCall(() =>
|
||||
window.electronAPI.auto.generate(bookId, flowId, summary)
|
||||
)
|
||||
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 summary = useAiStore.getState().contextSummary
|
||||
const flow = await ipcCall(() =>
|
||||
window.electronAPI.auto.resolveNaming(bookId, flowId, chosenName, summary)
|
||||
)
|
||||
set({ flow })
|
||||
await get().refreshSceneDraft(bookId)
|
||||
} finally {
|
||||
set({ streaming: false, streamBuffer: '' })
|
||||
}
|
||||
},
|
||||
|
||||
pause: async (bookId) => {
|
||||
const flowId = get().flow?.id
|
||||
if (!flowId) return
|
||||
const flow = await ipcCall(() => window.electronAPI.auto.pause(bookId, flowId))
|
||||
set({ flow, streaming: false, streamBuffer: '' })
|
||||
},
|
||||
|
||||
resume: async (bookId) => {
|
||||
const flowId = get().flow?.id
|
||||
if (!flowId) return
|
||||
const flow = await ipcCall(() => window.electronAPI.auto.resume(bookId, flowId))
|
||||
set({ flow })
|
||||
},
|
||||
|
||||
handoffInteractive: async (bookId) => {
|
||||
const flowId = get().flow?.id
|
||||
if (!flowId) return
|
||||
const flow = await ipcCall(() => window.electronAPI.auto.handoffInteractive(bookId, flowId))
|
||||
set({ flow })
|
||||
useAiStore.getState().setWritingMode('interactive')
|
||||
useAppStore.getState().showToast(i18n.t('wizard.handoffToast'))
|
||||
},
|
||||
|
||||
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.auto.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('auto.chapterCreated'))
|
||||
},
|
||||
|
||||
refreshSceneDraft: async (bookId) => {
|
||||
const flowId = get().flow?.id
|
||||
if (!flowId) return
|
||||
const draft = await ipcCall(() => window.electronAPI.auto.getSceneDraft(bookId, flowId))
|
||||
set({ sceneDraft: draft })
|
||||
},
|
||||
|
||||
mount: () => {
|
||||
if (get().listenersAttached) return
|
||||
unsubStream = window.electronAPI.onInteractiveStreamChunk((payload) => {
|
||||
if (payload.flowMode !== 'auto' || payload.done) return
|
||||
set((s) => ({
|
||||
streaming: true,
|
||||
streamBuffer: s.streamBuffer + payload.delta
|
||||
}))
|
||||
})
|
||||
set({ listenersAttached: true })
|
||||
},
|
||||
|
||||
unmount: () => {
|
||||
unsubStream?.()
|
||||
unsubStream = null
|
||||
set({ listenersAttached: false })
|
||||
}
|
||||
}))
|
||||
@@ -0,0 +1,137 @@
|
||||
import { create } from 'zustand'
|
||||
import type {
|
||||
AiContextBinding,
|
||||
AutoRhythm,
|
||||
AutoWritingConfig,
|
||||
InteractiveFlow,
|
||||
WizardWritingConfig
|
||||
} from '@shared/types'
|
||||
import { ipcCall } from '@renderer/lib/ipc-client'
|
||||
import { useAiStore } from '@renderer/stores/useAiStore'
|
||||
import { useAppStore } from '@renderer/stores/useAppStore'
|
||||
import i18n from '@renderer/i18n'
|
||||
|
||||
interface WizardState {
|
||||
gateEligible: boolean
|
||||
flow: InteractiveFlow | null
|
||||
previewHtml: string
|
||||
streaming: boolean
|
||||
streamBuffer: string
|
||||
listenersAttached: boolean
|
||||
checkGate: (bookId: string) => Promise<void>
|
||||
loadFlow: (bookId: string, sessionId: string) => Promise<void>
|
||||
start: (bookId: string, sessionId: string) => Promise<void>
|
||||
setGoal: (bookId: string, config: Partial<AutoWritingConfig>) => Promise<void>
|
||||
setRhythm: (bookId: string, rhythm: AutoRhythm, styleNote?: string) => Promise<void>
|
||||
setPov: (bookId: string, povSettingId: string) => Promise<void>
|
||||
confirmContext: (bookId: string, binding: AiContextBinding) => Promise<void>
|
||||
buildPreview: (bookId: string) => Promise<void>
|
||||
startGenerate: (bookId: string) => Promise<void>
|
||||
mount: () => void
|
||||
unmount: () => void
|
||||
}
|
||||
|
||||
let unsubStream: (() => void) | null = null
|
||||
|
||||
export const useWizardStore = create<WizardState>((set, get) => ({
|
||||
gateEligible: false,
|
||||
flow: null,
|
||||
previewHtml: '',
|
||||
streaming: false,
|
||||
streamBuffer: '',
|
||||
listenersAttached: false,
|
||||
|
||||
checkGate: async (bookId) => {
|
||||
const r = await ipcCall(() => window.electronAPI.auto.checkGate(bookId))
|
||||
set({ gateEligible: r.eligible })
|
||||
},
|
||||
|
||||
loadFlow: async (bookId, sessionId) => {
|
||||
const flow = await ipcCall(() => window.electronAPI.wizard.getFlow(bookId, sessionId))
|
||||
const config = flow?.modeConfig as WizardWritingConfig | undefined
|
||||
set({
|
||||
flow,
|
||||
previewHtml: config?.strategyPreview ?? ''
|
||||
})
|
||||
},
|
||||
|
||||
start: async (bookId, sessionId) => {
|
||||
const flow = await ipcCall(() => window.electronAPI.wizard.start(bookId, sessionId))
|
||||
set({ flow, previewHtml: '', streamBuffer: '' })
|
||||
},
|
||||
|
||||
setGoal: async (bookId, config) => {
|
||||
const flowId = get().flow?.id
|
||||
if (!flowId) return
|
||||
const flow = await ipcCall(() => window.electronAPI.wizard.setGoal(bookId, flowId, config))
|
||||
set({ flow })
|
||||
},
|
||||
|
||||
setRhythm: async (bookId, rhythm, styleNote) => {
|
||||
const flowId = get().flow?.id
|
||||
if (!flowId) return
|
||||
const flow = await ipcCall(() =>
|
||||
window.electronAPI.wizard.setRhythm(bookId, flowId, rhythm, styleNote)
|
||||
)
|
||||
set({ flow })
|
||||
},
|
||||
|
||||
setPov: async (bookId, povSettingId) => {
|
||||
const flowId = get().flow?.id
|
||||
if (!flowId) return
|
||||
const flow = await ipcCall(() => window.electronAPI.wizard.setPov(bookId, flowId, povSettingId))
|
||||
set({ flow })
|
||||
},
|
||||
|
||||
confirmContext: async (bookId, binding) => {
|
||||
const flowId = get().flow?.id
|
||||
if (!flowId) return
|
||||
const flow = await ipcCall(() =>
|
||||
window.electronAPI.wizard.confirmContext(bookId, flowId, binding)
|
||||
)
|
||||
set({ flow })
|
||||
},
|
||||
|
||||
buildPreview: async (bookId) => {
|
||||
const flowId = get().flow?.id
|
||||
if (!flowId) return
|
||||
set({ streaming: true })
|
||||
try {
|
||||
const { preview, flow } = await ipcCall(() =>
|
||||
window.electronAPI.wizard.buildPreview(bookId, flowId)
|
||||
)
|
||||
set({ previewHtml: preview, flow })
|
||||
} finally {
|
||||
set({ streaming: false })
|
||||
}
|
||||
},
|
||||
|
||||
startGenerate: async (bookId) => {
|
||||
const flowId = get().flow?.id
|
||||
if (!flowId) return
|
||||
set({ streaming: true, streamBuffer: '' })
|
||||
try {
|
||||
const flow = await ipcCall(() => window.electronAPI.wizard.startGenerate(bookId, flowId))
|
||||
set({ flow })
|
||||
useAiStore.getState().setWritingMode('interactive')
|
||||
useAppStore.getState().showToast(i18n.t('wizard.handoffToast'))
|
||||
} finally {
|
||||
set({ streaming: false, streamBuffer: '' })
|
||||
}
|
||||
},
|
||||
|
||||
mount: () => {
|
||||
if (get().listenersAttached) return
|
||||
unsubStream = window.electronAPI.onInteractiveStreamChunk((payload) => {
|
||||
if (payload.flowMode !== 'wizard' || payload.done) return
|
||||
set((s) => ({ streaming: true, streamBuffer: s.streamBuffer + payload.delta }))
|
||||
})
|
||||
set({ listenersAttached: true })
|
||||
},
|
||||
|
||||
unmount: () => {
|
||||
unsubStream?.()
|
||||
unsubStream = null
|
||||
set({ listenersAttached: false })
|
||||
}
|
||||
}))
|
||||
@@ -1399,6 +1399,46 @@
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.auto-outline-picker {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
max-height: 120px;
|
||||
overflow-y: auto;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.auto-outline-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.auto-word-range {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.auto-word-range input {
|
||||
width: 80px;
|
||||
}
|
||||
|
||||
.wizard-step-indicator {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
padding-bottom: 6px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.wizard-preview {
|
||||
padding: 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.plot-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
Vendored
+67
@@ -18,6 +18,8 @@ import type {
|
||||
InteractiveFlow,
|
||||
InteractiveGateResult,
|
||||
InteractiveStreamChunkEvent,
|
||||
AutoRhythm,
|
||||
AutoWritingConfig,
|
||||
PlotOption,
|
||||
PlotSelection,
|
||||
IpcResult,
|
||||
@@ -240,6 +242,71 @@ export interface ElectronAPI {
|
||||
abort: (flowId: string) => Promise<IpcResult<void>>
|
||||
getSceneDraft: (bookId: string, flowId: string) => Promise<IpcResult<string>>
|
||||
}
|
||||
auto: {
|
||||
checkGate: (bookId: string) => Promise<IpcResult<InteractiveGateResult>>
|
||||
getFlow: (bookId: string, sessionId: string) => Promise<IpcResult<InteractiveFlow | null>>
|
||||
start: (bookId: string, sessionId: string) => Promise<IpcResult<InteractiveFlow>>
|
||||
setGoal: (
|
||||
bookId: string,
|
||||
flowId: string,
|
||||
config: Partial<AutoWritingConfig>
|
||||
) => Promise<IpcResult<InteractiveFlow>>
|
||||
planScenes: (
|
||||
bookId: string,
|
||||
flowId: string,
|
||||
contextSummary?: string
|
||||
) => Promise<IpcResult<InteractiveFlow>>
|
||||
generate: (
|
||||
bookId: string,
|
||||
flowId: string,
|
||||
contextSummary?: string
|
||||
) => Promise<IpcResult<InteractiveFlow>>
|
||||
resolveNaming: (
|
||||
bookId: string,
|
||||
flowId: string,
|
||||
chosenName: string,
|
||||
contextSummary?: string
|
||||
) => Promise<IpcResult<InteractiveFlow>>
|
||||
pause: (bookId: string, flowId: string) => Promise<IpcResult<InteractiveFlow>>
|
||||
resume: (bookId: string, flowId: string) => Promise<IpcResult<InteractiveFlow>>
|
||||
handoffInteractive: (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>>
|
||||
}
|
||||
wizard: {
|
||||
getFlow: (bookId: string, sessionId: string) => Promise<IpcResult<InteractiveFlow | null>>
|
||||
start: (bookId: string, sessionId: string) => Promise<IpcResult<InteractiveFlow>>
|
||||
setGoal: (
|
||||
bookId: string,
|
||||
flowId: string,
|
||||
config: Partial<AutoWritingConfig>
|
||||
) => Promise<IpcResult<InteractiveFlow>>
|
||||
setRhythm: (
|
||||
bookId: string,
|
||||
flowId: string,
|
||||
rhythm: AutoRhythm,
|
||||
styleNote?: string
|
||||
) => Promise<IpcResult<InteractiveFlow>>
|
||||
setPov: (bookId: string, flowId: string, povSettingId: string) => Promise<IpcResult<InteractiveFlow>>
|
||||
confirmContext: (
|
||||
bookId: string,
|
||||
flowId: string,
|
||||
binding: AiContextBinding
|
||||
) => Promise<IpcResult<InteractiveFlow>>
|
||||
buildPreview: (
|
||||
bookId: string,
|
||||
flowId: string
|
||||
) => Promise<IpcResult<{ preview: string; flow: InteractiveFlow }>>
|
||||
startGenerate: (bookId: string, flowId: string) => Promise<IpcResult<InteractiveFlow>>
|
||||
handoffInteractive: (bookId: string, flowId: string) => Promise<IpcResult<InteractiveFlow>>
|
||||
abort: (flowId: string) => Promise<IpcResult<void>>
|
||||
}
|
||||
wordfreq: {
|
||||
analyze: (
|
||||
bookId: string,
|
||||
|
||||
@@ -77,5 +77,28 @@ export const IPC = {
|
||||
INTERACTIVE_FINISH_CHAPTER: 'interactive:finishChapter',
|
||||
INTERACTIVE_ABORT: 'interactive:abort',
|
||||
INTERACTIVE_STREAM_CHUNK: 'interactive:streamChunk',
|
||||
INTERACTIVE_GET_SCENE_DRAFT: 'interactive:getSceneDraft'
|
||||
INTERACTIVE_GET_SCENE_DRAFT: 'interactive:getSceneDraft',
|
||||
AUTO_CHECK_GATE: 'auto:checkGate',
|
||||
AUTO_GET_FLOW: 'auto:getFlow',
|
||||
AUTO_START: 'auto:start',
|
||||
AUTO_SET_GOAL: 'auto:setGoal',
|
||||
AUTO_PLAN_SCENES: 'auto:planScenes',
|
||||
AUTO_GENERATE: 'auto:generate',
|
||||
AUTO_RESOLVE_NAMING: 'auto:resolveNaming',
|
||||
AUTO_PAUSE: 'auto:pause',
|
||||
AUTO_RESUME: 'auto:resume',
|
||||
AUTO_HANDOFF_INTERACTIVE: 'auto:handoffInteractive',
|
||||
AUTO_FINISH_CHAPTER: 'auto:finishChapter',
|
||||
AUTO_GET_SCENE_DRAFT: 'auto:getSceneDraft',
|
||||
AUTO_ABORT: 'auto:abort',
|
||||
WIZARD_GET_FLOW: 'wizard:getFlow',
|
||||
WIZARD_START: 'wizard:start',
|
||||
WIZARD_SET_GOAL: 'wizard:setGoal',
|
||||
WIZARD_SET_RHYTHM: 'wizard:setRhythm',
|
||||
WIZARD_SET_POV: 'wizard:setPov',
|
||||
WIZARD_CONFIRM_CONTEXT: 'wizard:confirmContext',
|
||||
WIZARD_BUILD_PREVIEW: 'wizard:buildPreview',
|
||||
WIZARD_START_GENERATE: 'wizard:startGenerate',
|
||||
WIZARD_HANDOFF_INTERACTIVE: 'wizard:handoffInteractive',
|
||||
WIZARD_ABORT: 'wizard:abort'
|
||||
} as const
|
||||
|
||||
+49
-3
@@ -9,7 +9,7 @@ export type ThemeId =
|
||||
|
||||
export type Language = 'zh-CN' | 'en'
|
||||
export type ChapterStatus = 'draft' | 'review' | 'done'
|
||||
export type ChapterOrigin = 'manual' | 'interactive'
|
||||
export type ChapterOrigin = 'manual' | 'interactive' | 'auto' | 'wizard'
|
||||
export type BookStatus = 'draft' | 'ongoing' | 'done'
|
||||
export type PublishStatus = 'draft' | 'ready' | 'published'
|
||||
export type OutlineStatus = 'pending' | 'writing' | 'done' | 'abandoned'
|
||||
@@ -285,6 +285,49 @@ export type InteractiveStep =
|
||||
| 'scene_review'
|
||||
| 'completed'
|
||||
|
||||
export type WritingFlowMode = 'interactive' | 'auto' | 'wizard'
|
||||
|
||||
export type AutoRhythm = 'relaxed' | 'tense' | 'mixed'
|
||||
|
||||
export type AutoStep =
|
||||
| 'goal_setup'
|
||||
| 'scene_plan'
|
||||
| 'auto_generate'
|
||||
| 'paused'
|
||||
| 'completed'
|
||||
|
||||
export type WizardStep =
|
||||
| 'wizard_goal'
|
||||
| 'wizard_rhythm'
|
||||
| 'wizard_pov'
|
||||
| 'wizard_context'
|
||||
| 'wizard_preview'
|
||||
| 'wizard_generating'
|
||||
|
||||
export type WritingStep = InteractiveStep | AutoStep | WizardStep
|
||||
|
||||
export interface ScenePlanItem {
|
||||
label: string
|
||||
summary: string
|
||||
}
|
||||
|
||||
export interface AutoWritingConfig {
|
||||
chapterGoal: string
|
||||
outlineItemIds?: string[]
|
||||
wordMin: number
|
||||
wordMax: number
|
||||
scenePlan?: ScenePlanItem[]
|
||||
currentPlanIndex?: number
|
||||
}
|
||||
|
||||
export interface WizardWritingConfig extends AutoWritingConfig {
|
||||
rhythm: AutoRhythm
|
||||
styleNote?: string
|
||||
povSettingId?: string
|
||||
strategyPreview?: string
|
||||
contextBinding?: AiContextBinding
|
||||
}
|
||||
|
||||
export type InteractiveFlowStatus = 'in_progress' | 'completed' | 'abandoned'
|
||||
|
||||
export interface PlotOption {
|
||||
@@ -315,8 +358,10 @@ export interface InteractiveScene {
|
||||
export interface InteractiveFlow {
|
||||
id: string
|
||||
sessionId: string
|
||||
step: InteractiveStep
|
||||
flowMode: WritingFlowMode
|
||||
step: WritingStep
|
||||
status: InteractiveFlowStatus
|
||||
modeConfig: AutoWritingConfig | WizardWritingConfig | Record<string, never>
|
||||
scenes: InteractiveScene[]
|
||||
namingPending?: NamingPause | null
|
||||
contextSummary?: string
|
||||
@@ -329,7 +374,8 @@ export interface InteractiveGateResult {
|
||||
|
||||
export interface InteractiveStreamChunkEvent {
|
||||
flowId: string
|
||||
phase: 'scene' | 'refine' | 'naming_resume'
|
||||
flowMode?: WritingFlowMode
|
||||
phase: 'scene' | 'refine' | 'naming_resume' | 'plan'
|
||||
delta: string
|
||||
done: boolean
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user