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);
|
||||
Reference in New Issue
Block a user