feat: ship v0.3.0 with P2.1 editor polish and P3 AI collaboration

Add chapter reorder, search replace, inspiration capture, and AI chat with context editing, settings, offline handling, and naming checks. Fix dev black screen by keeping process.env reads in the main process only.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-06 16:33:56 +08:00
parent 0b5aa146bd
commit 8ce35a1e8e
59 changed files with 3808 additions and 100 deletions
+12
View File
@@ -0,0 +1,12 @@
import { describe, it, expect } from 'vitest'
import { AiClientService } from '../../src/main/services/ai-client.service'
import { DEFAULT_AI_CONFIG } from '../../src/shared/types'
describe('AiClientService', () => {
const client = new AiClientService(() => DEFAULT_AI_CONFIG)
it('IT-AI-01: chat returns non-empty content from LM Studio', async () => {
const content = await client.chat([{ role: 'user', content: '回复一个字:好' }])
expect(content.trim().length).toBeGreaterThan(0)
}, 90_000)
})
+86
View File
@@ -0,0 +1,86 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { DatabaseSync } from 'node:sqlite'
import { mkdtempSync, rmSync } from 'fs'
import { join } from 'path'
import { tmpdir } from 'os'
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 { SettingRepository } from '../../src/main/db/repositories/setting.repo'
import { BookRegistryService } from '../../src/main/services/book-registry'
import { aiContextBuilder } from '../../src/main/services/ai-context-builder.service'
import type { SqliteDb } from '../../src/main/db/types'
describe('AiContextBuilderService', () => {
let userDir: string
let registry: BookRegistryService
let bookId: string
let volId: string
let db: SqliteDb
beforeEach(() => {
userDir = mkdtempSync(join(tmpdir(), 'bilin-ctx-'))
registry = new BookRegistryService(userDir)
const meta = registry.create({ name: '测试书', category: '玄幻' })
bookId = meta.id
db = registry.getDb(bookId)
volId = new VolumeRepository(db).create('卷一', 0).id
})
afterEach(() => {
db?.close()
rmSync(userDir, { recursive: true, force: true })
})
it('truncates long chapter content to 2000 chars per block', () => {
const chapters = new ChapterRepository(db)
const longHtml = `<p>${'章'.repeat(3000)}</p>`
const chapter = chapters.create(volId, '长章节', 0, longHtml)
const result = aiContextBuilder.build(
{ chapterIds: [chapter.id], outlineIds: [], settingIds: [], inspirationIds: [] },
registry,
bookId,
'作者'
)
expect(result.preview).toHaveLength(1)
expect(result.preview[0].excerpt.length).toBeLessThanOrEqual(2001)
expect(result.systemPrompt.length).toBeLessThanOrEqual(16 * 1024 + 500)
})
it('keeps total context within 16KB', () => {
const chapters = new ChapterRepository(db)
const ids: string[] = []
for (let i = 0; i < 12; i++) {
const ch = chapters.create(volId, `章节${i}`, i, `<p>${'内容'.repeat(900)}</p>`)
ids.push(ch.id)
}
const result = aiContextBuilder.build(
{ chapterIds: ids, outlineIds: [], settingIds: [], inspirationIds: [] },
registry,
bookId,
'作者'
)
const blocksText = result.preview.map((p) => p.excerpt).join('')
expect(blocksText.length).toBeLessThanOrEqual(16 * 1024)
expect(result.preview.length).toBeLessThan(ids.length)
})
it('includes setting in preview', () => {
const settings = new SettingRepository(db)
const setting = settings.create('character', '主角林远', '少年修士')
const result = aiContextBuilder.build(
{ chapterIds: [], outlineIds: [], settingIds: [setting.id], inspirationIds: [] },
registry,
bookId,
'作者'
)
expect(result.preview[0].title).toBe('主角林远')
expect(result.systemPrompt).toContain('主角林远')
})
})
+69
View File
@@ -0,0 +1,69 @@
import { describe, it, expect, beforeEach, afterEach } 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 type { SqliteDb } from '../../src/main/db/types'
describe('AiSessionRepository', () => {
let db: SqliteDb
let repo: AiSessionRepository
beforeEach(() => {
db = new DatabaseSync(':memory:')
migrate(db)
repo = new AiSessionRepository(db)
})
afterEach(() => db.close())
it('creates session with empty context', () => {
const session = repo.create('对话一', 'gemma-4-e4b-it')
expect(session.title).toBe('对话一')
expect(session.model).toBe('gemma-4-e4b-it')
expect(session.context.chapterIds).toEqual([])
expect(session.archived).toBe(false)
})
it('lists sessions excluding archived by default', () => {
const active = repo.create('活跃')
const archived = repo.create('归档')
repo.update(archived.id, { archived: true })
const list = repo.list()
expect(list.map((s) => s.id)).toEqual([active.id])
expect(repo.list(true).map((s) => s.id).sort()).toEqual([active.id, archived.id].sort())
})
it('persists messages in order', () => {
const session = repo.create()
repo.addMessage(session.id, 'user', '你好')
const assistant = repo.addMessage(session.id, 'assistant', '你好,有什么可以帮你?')
const messages = repo.listMessages(session.id)
expect(messages).toHaveLength(2)
expect(messages[0].role).toBe('user')
expect(messages[1].id).toBe(assistant.id)
})
it('updates context binding', () => {
const session = repo.create()
const updated = repo.update(session.id, {
context: {
chapterIds: ['ch-1'],
outlineIds: [],
settingIds: ['st-1'],
inspirationIds: []
}
})
expect(updated.context.chapterIds).toEqual(['ch-1'])
expect(updated.context.settingIds).toEqual(['st-1'])
})
it('deletes session and messages', () => {
const session = repo.create()
repo.addMessage(session.id, 'user', 'test')
repo.delete(session.id)
expect(repo.get(session.id)).toBeNull()
expect(repo.listMessages(session.id)).toEqual([])
})
})
+44
View File
@@ -0,0 +1,44 @@
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 type { SqliteDb } from '../../src/main/db/types'
describe('ChapterRepository.move', () => {
let db: SqliteDb
let chapters: ChapterRepository
let volId: string
beforeEach(() => {
db = new DatabaseSync(':memory:')
migrate(db)
const vols = new VolumeRepository(db)
volId = vols.create('卷一', 0).id
chapters = new ChapterRepository(db)
chapters.create(volId, 'A', 0)
chapters.create(volId, 'B', 1)
chapters.create(volId, 'C', 2)
})
afterEach(() => db.close())
it('reorders within volume', () => {
const list = chapters.listByVolume(volId)
const idA = list.find((x) => x.title === 'A')!.id
const idB = list.find((x) => x.title === 'B')!.id
const idC = list.find((x) => x.title === 'C')!.id
chapters.reorderVolume(volId, [idC, idA, idB])
const titles = chapters.listByVolume(volId).map((x) => x.title)
expect(titles).toEqual(['C', 'A', 'B'])
})
it('moves chapter to another volume at index', () => {
const vols = new VolumeRepository(db)
const vol2 = vols.create('卷二', 1).id
const idB = chapters.listByVolume(volId).find((x) => x.title === 'B')!.id
chapters.moveToVolume(idB, vol2, 0)
expect(chapters.listByVolume(volId).map((x) => x.title)).toEqual(['A', 'C'])
expect(chapters.listByVolume(vol2).map((x) => x.title)).toEqual(['B'])
})
})
+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(2)
expect(version.v).toBe(3)
})
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(2)
expect(version.v).toBe(3)
const cols = db.prepare('PRAGMA table_info(chapters)').all() as { name: string }[]
const colNames = cols.map((c) => c.name)
+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 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 v3', () => {
let db: SqliteDb
afterEach(() => {
db?.close()
})
it('applies v3 on fresh database', () => {
db = new DatabaseSync(':memory:')
migrate(db)
expect(tableExists(db, 'ai_sessions')).toBe(true)
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)
})
it('migrates existing v2 database to v3', () => {
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.prepare('INSERT INTO schema_version (version, description) VALUES (?, ?)').run(1, 'initial schema')
db.prepare('INSERT INTO schema_version (version, description) VALUES (?, ?)').run(2, 'P2 schema')
migrate(db)
const tables = db
.prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
.all() as { name: string }[]
expect(tables.map((t) => t.name)).toContain('ai_sessions')
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)
})
})
@@ -0,0 +1,37 @@
import { describe, it, expect } from 'vitest'
import { AiClientService } from '../../src/main/services/ai-client.service'
import {
buildNamingPrompt,
collectChaptersPlain,
parseNamingJson
} from '../../src/main/services/naming-check.service'
import { DEFAULT_AI_CONFIG } from '../../src/shared/types'
describe('naming check integration', () => {
const client = new AiClientService(() => DEFAULT_AI_CONFIG)
it('IT-NAME-01: detects 林远 vs 林悦 from LM Studio', async () => {
const prompt = buildNamingPrompt(
['林远'],
collectChaptersPlain([{ title: '第一章', content: '<p>林悦走进院子,环顾四周。</p>' }]),
[]
)
let response = await client.chat([{ role: 'user', content: prompt }])
let rows
try {
rows = parseNamingJson(response)
} catch {
response = await client.chat([
{ role: 'user', content: `${prompt}\n\n仅返回 JSON 数组,不要 markdown 或其它说明。` }
])
rows = parseNamingJson(response)
}
expect(rows.length).toBeGreaterThan(0)
const hit = rows.some(
(r) =>
(r.termA.includes('林远') && r.termB.includes('林悦')) ||
(r.termA.includes('林悦') && r.termB.includes('林远'))
)
expect(hit).toBe(true)
}, 180_000)
})
+50
View File
@@ -0,0 +1,50 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { mkdtempSync, rmSync } from 'fs'
import { join } from 'path'
import { tmpdir } from 'os'
import { BookRegistryService } from '../../src/main/services/book-registry'
import { ChapterRepository } from '../../src/main/db/repositories/chapter.repo'
import { VolumeRepository } from '../../src/main/db/repositories/volume.repo'
import { SettingRepository } from '../../src/main/db/repositories/setting.repo'
import {
collectChaptersPlain,
buildNamingCorpus,
findLocalNamingConflicts
} from '../../src/main/services/naming-check.service'
import type { SqliteDb } from '../../src/main/db/types'
describe('naming check data path', () => {
let userDir: string
let registry: BookRegistryService
let bookId: string
let volId: string
let db: SqliteDb
beforeEach(() => {
userDir = mkdtempSync(join(tmpdir(), 'bilin-name-repo-'))
registry = new BookRegistryService(userDir)
const meta = registry.create({ name: '测试书', category: '玄幻' })
bookId = meta.id
db = registry.getDb(bookId)
volId = new VolumeRepository(db).create('卷一', 0).id
})
afterEach(() => {
db?.close()
rmSync(userDir, { recursive: true, force: true })
})
it('finds 林远 vs 林悦 from setting description in corpus', () => {
new SettingRepository(db).create('character', '林远', '<p>正文中出现了林悦这一名字。</p>')
const settings = registry.getSettingRepo(bookId).list()
const corpus = buildNamingCorpus(settings, registry.getChapterRepo(bookId).list())
const local = findLocalNamingConflicts(
settings.map((s) => s.name),
corpus,
[]
)
expect(local.length).toBeGreaterThan(0)
})
})
+50
View File
@@ -0,0 +1,50 @@
import { describe, it, expect } from 'vitest'
import { collectChaptersPlain, findLocalNamingConflicts, parseNamingJson, buildNamingPrompt } from '../../src/main/services/naming-check.service'
describe('parseNamingJson', () => {
it('parses JSON array from plain response', () => {
const raw = `[{"termA":"林远","termB":"林悦","confidence":0.92,"reason":"同姓近音","suggestedCanonical":"林远"}]`
const rows = parseNamingJson(raw)
expect(rows).toHaveLength(1)
expect(rows[0].termA).toBe('林远')
expect(rows[0].termB).toBe('林悦')
expect(rows[0].confidence).toBeCloseTo(0.92)
})
it('extracts array from markdown-wrapped response', () => {
const raw = '说明如下:\n```json\n[{"termA":"玄火掌","termB":"焰火掌","confidence":0.8,"reason":"技能名不一致"}]\n```'
const rows = parseNamingJson(raw)
expect(rows).toHaveLength(1)
expect(rows[0].termA).toBe('玄火掌')
})
it('throws when no array present', () => {
expect(() => parseNamingJson('no json here')).toThrow()
})
})
describe('findLocalNamingConflicts', () => {
it('detects 林远 vs 林悦 in chapter text', () => {
const rows = findLocalNamingConflicts(['林远'], '林悦走进院子,环顾四周。', [])
expect(rows.length).toBeGreaterThan(0)
expect(rows[0].termA).toBe('林远')
expect(rows[0].termB).toBe('林悦')
})
it('detects conflict from chapter html content', () => {
const plain = collectChaptersPlain([
{ title: '新章', content: '<p>林悦走进院子,环顾四周。</p>' }
])
const rows = findLocalNamingConflicts(['林远'], plain, [])
expect(rows.length).toBeGreaterThan(0)
})
})
describe('buildNamingPrompt', () => {
it('includes setting names and ignore words', () => {
const prompt = buildNamingPrompt(['林远'], '林悦走进院子。', ['青云宗'])
expect(prompt).toContain('林远')
expect(prompt).toContain('林悦走进院子')
expect(prompt).toContain('青云宗')
})
})