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