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
+3 -1
View File
@@ -2,8 +2,10 @@ import { describe, it, expect } from 'vitest'
import { AiClientService } from '../../src/main/services/ai-client.service'
import { DEFAULT_AI_CONFIG } from '../../src/shared/types'
const AI_TEST_CONFIG = { ...DEFAULT_AI_CONFIG, timeoutMs: 240_000, maxTokens: 4096 }
describe('AiClientService', () => {
const client = new AiClientService(() => DEFAULT_AI_CONFIG)
const client = new AiClientService(() => AI_TEST_CONFIG)
it('IT-AI-01: chat returns non-empty content from LM Studio', async () => {
const content = await client.chat([{ role: 'user', content: '回复一个字:好' }])
+41
View File
@@ -0,0 +1,41 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { DatabaseSync } from 'node:sqlite'
import { migrate } from '../../src/main/db/migrate'
import { VolumeRepository } from '../../src/main/db/repositories/volume.repo'
import { AiSessionRepository } from '../../src/main/db/repositories/ai-session.repo'
import { InteractiveRepository } from '../../src/main/db/repositories/interactive.repo'
import { ChapterRepository } from '../../src/main/db/repositories/chapter.repo'
import { InteractiveWritingService } from '../../src/main/services/interactive-writing.service'
import type { SqliteDb } from '../../src/main/db/types'
describe('InteractiveWritingService.finishChapter', () => {
let db: SqliteDb
let service: InteractiveWritingService
let volId: string
let flowId: string
beforeEach(() => {
db = new DatabaseSync(':memory:')
migrate(db)
volId = new VolumeRepository(db).create('卷一', 0).id
const session = new AiSessionRepository(db).create('测试', 'test-model')
const flow = new InteractiveRepository(db).create(session.id)
flowId = flow.id
const repo = new InteractiveRepository(db)
repo.addScene(flowId, '<p>场景一</p>', 'A')
repo.addScene(flowId, '<p>场景二</p>', 'B')
service = new InteractiveWritingService(db, {
chat: async () => ''
} as never)
})
afterEach(() => db.close())
it('IT-INT-04: merges scenes into new interactive chapter', () => {
const { chapterId } = service.finishChapter(flowId, volId, '交互初稿')
const ch = new ChapterRepository(db).get(chapterId)!
expect(ch.origin).toBe('interactive')
expect(ch.content).toContain('场景一')
expect(ch.content).toContain('场景二')
})
})
+34
View File
@@ -0,0 +1,34 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { DatabaseSync } from 'node:sqlite'
import { migrate } from '../../src/main/db/migrate'
import { ChapterRepository } from '../../src/main/db/repositories/chapter.repo'
import { VolumeRepository } from '../../src/main/db/repositories/volume.repo'
import { OutlineRepository } from '../../src/main/db/repositories/outline.repo'
import { checkInteractiveGate } from '../../src/main/services/interactive-gate.service'
import type { SqliteDb } from '../../src/main/db/types'
describe('checkInteractiveGate', () => {
let db: SqliteDb
beforeEach(() => {
db = new DatabaseSync(':memory:')
migrate(db)
})
afterEach(() => db.close())
it('IT-INT-05: rejects empty book', () => {
expect(checkInteractiveGate(db).eligible).toBe(false)
})
it('IT-INT-05: accepts book with chapter content', () => {
const volId = new VolumeRepository(db).create('卷一', 0).id
new ChapterRepository(db).create(volId, '第一章', 0, '<p>有正文</p>')
expect(checkInteractiveGate(db).eligible).toBe(true)
})
it('IT-INT-05: accepts book with outline only', () => {
new OutlineRepository(db).create(null, '节点', 0)
expect(checkInteractiveGate(db).eligible).toBe(true)
})
})
+17
View File
@@ -0,0 +1,17 @@
import { describe, it, expect } from 'vitest'
import { parsePlotsJson, parseNamingRequiredJson } from '../../src/main/services/interactive-parse.service'
describe('interactive-parse', () => {
it('parses three plot options', () => {
const json = `{"plots":[{"id":"A","label":"锋芒","summary":"林远展露天赋引起轰动,同门侧目,长老暗中关注其来历与隐患。"},{"id":"B","label":"藏锋","summary":"林远按长老暗示收敛灵力,被安排在外门修行,暗中调查测灵石异变。"},{"id":"C","label":"转折","summary":"测灵石碎裂引发异象,闭关太上长老出关,宗门上下震动。"}]}`
const plots = parsePlotsJson(json)
expect(plots).toHaveLength(3)
expect(plots[0].summary.length).toBeGreaterThanOrEqual(10)
})
it('parses naming_required payload', () => {
const json = `{"type":"naming_required","elementType":"角色","context":"需要为新同伴命名","options":["名1","名2","名3","名4","名5"]}`
const pause = parseNamingRequiredJson(json)
expect(pause.options).toHaveLength(5)
})
})
+27
View File
@@ -0,0 +1,27 @@
import { describe, it, expect } from 'vitest'
import { DatabaseSync } from 'node:sqlite'
import { migrate } from '../../src/main/db/migrate'
import { AiSessionRepository } from '../../src/main/db/repositories/ai-session.repo'
import { InteractiveWritingService } from '../../src/main/services/interactive-writing.service'
import { AiClientService } from '../../src/main/services/ai-client.service'
import { DEFAULT_AI_CONFIG } from '../../src/shared/types'
const AI_TEST_CONFIG = { ...DEFAULT_AI_CONFIG, timeoutMs: 240_000, maxTokens: 4096 }
describe('interactive plots integration', () => {
it('IT-INT-01: suggestPlots returns 3 options from LM Studio', async () => {
const db = new DatabaseSync(':memory:')
migrate(db)
const session = new AiSessionRepository(db).create('t', AI_TEST_CONFIG.model)
const service = new InteractiveWritingService(db, new AiClientService(() => AI_TEST_CONFIG))
const flow = service.start(session.id)
service.confirmContext(
flow.id,
{ chapterIds: [], outlineIds: [], settingIds: [], inspirationIds: [] },
'林远参加宗门入门考核,演武场测灵石前众人围观。'
)
const plots = await service.suggestPlots(flow.id)
expect(plots).toHaveLength(3)
db.close()
}, 180_000)
})
+36
View File
@@ -0,0 +1,36 @@
import { describe, it, expect } from 'vitest'
import { DatabaseSync } from 'node:sqlite'
import { migrate } from '../../src/main/db/migrate'
import { AiSessionRepository } from '../../src/main/db/repositories/ai-session.repo'
import { InteractiveWritingService } from '../../src/main/services/interactive-writing.service'
import { AiClientService } from '../../src/main/services/ai-client.service'
import { DEFAULT_AI_CONFIG } from '../../src/shared/types'
const AI_TEST_CONFIG = { ...DEFAULT_AI_CONFIG, timeoutMs: 240_000, maxTokens: 4096 }
describe('interactive scene integration', () => {
const service = (db: DatabaseSync) =>
new InteractiveWritingService(db, new AiClientService(() => AI_TEST_CONFIG))
it('IT-INT-02: generateScene returns non-empty draft', async () => {
const db = new DatabaseSync(':memory:')
migrate(db)
const session = new AiSessionRepository(db).create('t', DEFAULT_AI_CONFIG.model)
const svc = service(db)
const flow = svc.start(session.id)
svc.confirmContext(
flow.id,
{ chapterIds: [], outlineIds: [], settingIds: [], inspirationIds: [] },
'林远在演武场测试灵根,众人围观。'
)
const plots = await svc.suggestPlots(flow.id)
svc.selectPlot(flow.id, { optionIds: [plots[0].id] })
const afterGen = await svc.generateScene(flow.id, () => {})
if (afterGen.step === 'naming_pause' && afterGen.namingPending) {
await svc.resolveNaming(flow.id, afterGen.namingPending.options[0], () => {})
}
const draft = svc.getSceneDraft(flow.id)
expect(draft.trim().length).toBeGreaterThan(50)
db.close()
}, 240_000)
})
+2 -2
View File
@@ -30,7 +30,7 @@ describe('migrate v2', () => {
expect(tableExists(db, 'search_fts')).toBe(true)
const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number }
expect(version.v).toBe(3)
expect(version.v).toBe(4)
})
it('migrates existing v1 database to v2', () => {
@@ -47,7 +47,7 @@ describe('migrate v2', () => {
expect(tableExists(db, 'outline_items')).toBe(true)
const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number }
expect(version.v).toBe(3)
expect(version.v).toBe(4)
const cols = db.prepare('PRAGMA table_info(chapters)').all() as { name: string }[]
const colNames = cols.map((c) => c.name)
+2 -2
View File
@@ -25,7 +25,7 @@ describe('migrate v3', () => {
expect(tableExists(db, 'ai_messages')).toBe(true)
const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number }
expect(version.v).toBe(3)
expect(version.v).toBe(4)
})
it('migrates existing v2 database to v3', () => {
@@ -49,6 +49,6 @@ describe('migrate v3', () => {
expect(tables.map((t) => t.name)).toContain('ai_messages')
const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number }
expect(version.v).toBe(3)
expect(version.v).toBe(4)
})
})
+54
View File
@@ -0,0 +1,54 @@
import { describe, it, expect, afterEach } from 'vitest'
import { DatabaseSync } from 'node:sqlite'
import { migrate } from '../../src/main/db/migrate'
import schemaV1 from '../../src/main/db/schema-v1.sql?raw'
import schemaV2 from '../../src/main/db/schema-v2.sql?raw'
import schemaV3 from '../../src/main/db/schema-v3.sql?raw'
import type { SqliteDb } from '../../src/main/db/types'
function tableExists(db: SqliteDb, name: string): boolean {
const row = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name=?").get(name)
return row != null
}
describe('migrate v4', () => {
let db: SqliteDb
afterEach(() => {
db?.close()
})
it('IT-MIG-04: applies v4 on fresh database', () => {
db = new DatabaseSync(':memory:')
migrate(db)
const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number }
expect(version.v).toBe(4)
expect(tableExists(db, 'interactive_flows')).toBe(true)
expect(tableExists(db, 'interactive_scenes')).toBe(true)
const cols = db.prepare('PRAGMA table_info(chapters)').all() as { name: string }[]
expect(cols.map((c) => c.name)).toContain('origin')
})
it('IT-MIG-04: migrates v3 database to v4', () => {
db = new DatabaseSync(':memory:')
db.exec(`CREATE TABLE IF NOT EXISTS schema_version (
version INTEGER PRIMARY KEY,
applied_at TEXT NOT NULL DEFAULT (datetime('now')),
description TEXT
)`)
db.exec(schemaV1)
db.exec(schemaV2)
db.exec(schemaV3)
db.prepare('INSERT INTO schema_version (version, description) VALUES (?, ?)').run(1, 'initial')
db.prepare('INSERT INTO schema_version (version, description) VALUES (?, ?)').run(2, 'P2')
db.prepare('INSERT INTO schema_version (version, description) VALUES (?, ?)').run(3, 'P3')
migrate(db)
const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number }
expect(version.v).toBe(4)
expect(tableExists(db, 'interactive_flows')).toBe(true)
})
})
+3 -1
View File
@@ -7,8 +7,10 @@ import {
} from '../../src/main/services/naming-check.service'
import { DEFAULT_AI_CONFIG } from '../../src/shared/types'
const AI_TEST_CONFIG = { ...DEFAULT_AI_CONFIG, timeoutMs: 240_000 }
describe('naming check integration', () => {
const client = new AiClientService(() => DEFAULT_AI_CONFIG)
const client = new AiClientService(() => AI_TEST_CONFIG)
it('IT-NAME-01: detects 林远 vs 林悦 from LM Studio', async () => {
const prompt = buildNamingPrompt(