feat: ship v0.4.0 with interactive writing mode

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-06 17:46:16 +08:00
parent 0a054606fe
commit a8e0ba9ac9
39 changed files with 2113 additions and 89 deletions
@@ -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
}
}
}