feat: ship v0.7.0 with AI knowledge context integration

Add semi-automatic knowledge suggestions and user-confirmed injection across chat, interactive, auto, and wizard modes; fix writing flows to persist full systemPrompt in contextJson.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-07 15:33:53 +08:00
parent 2afbb83c43
commit aa2c7dfed3
41 changed files with 1315 additions and 168 deletions
+60 -3
View File
@@ -7,6 +7,7 @@ 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 { KnowledgeRepository } from '../../src/main/db/repositories/knowledge.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'
@@ -38,7 +39,7 @@ describe('AiContextBuilderService', () => {
const chapter = chapters.create(volId, '长章节', 0, longHtml)
const result = aiContextBuilder.build(
{ chapterIds: [chapter.id], outlineIds: [], settingIds: [], inspirationIds: [] },
{ chapterIds: [chapter.id], outlineIds: [], settingIds: [], inspirationIds: [], knowledgeIds: [] },
registry,
bookId,
'作者'
@@ -58,7 +59,7 @@ describe('AiContextBuilderService', () => {
}
const result = aiContextBuilder.build(
{ chapterIds: ids, outlineIds: [], settingIds: [], inspirationIds: [] },
{ chapterIds: ids, outlineIds: [], settingIds: [], inspirationIds: [], knowledgeIds: [] },
registry,
bookId,
'作者'
@@ -74,7 +75,7 @@ describe('AiContextBuilderService', () => {
const setting = settings.create('character', '主角林远', '少年修士')
const result = aiContextBuilder.build(
{ chapterIds: [], outlineIds: [], settingIds: [setting.id], inspirationIds: [] },
{ chapterIds: [], outlineIds: [], settingIds: [setting.id], inspirationIds: [], knowledgeIds: [] },
registry,
bookId,
'作者'
@@ -83,4 +84,60 @@ describe('AiContextBuilderService', () => {
expect(result.preview[0].title).toBe('主角林远')
expect(result.systemPrompt).toContain('主角林远')
})
it('UT-CTX-02: build includes knowledge preview items', () => {
const know = new KnowledgeRepository(db)
const entry = know.create({
type: 'foreshadow',
title: '测灵石',
content: '石头发热',
status: 'approved'
})
const result = aiContextBuilder.build(
{
chapterIds: [],
outlineIds: [],
settingIds: [],
inspirationIds: [],
knowledgeIds: [entry.id]
},
registry,
bookId,
'作者'
)
expect(result.preview.some((p) => p.kind === 'knowledge')).toBe(true)
expect(result.systemPrompt).toContain('【知识·伏笔】')
expect(result.systemPrompt).toContain('测灵石')
})
it('UT-CTX-03: caps knowledge section at 4000 chars', () => {
const know = new KnowledgeRepository(db)
const ids: string[] = []
for (let i = 0; i < 20; i++) {
const e = know.create({
type: 'fact',
title: `条目${i}`,
content: '长'.repeat(400),
importance: 5,
status: 'approved'
})
ids.push(e.id)
}
const result = aiContextBuilder.build(
{
chapterIds: [],
outlineIds: [],
settingIds: [],
inspirationIds: [],
knowledgeIds: ids
},
registry,
bookId,
'作者'
)
const knowledgeChars = result.preview
.filter((p) => p.kind === 'knowledge')
.reduce((s, p) => s + p.charCount, 0)
expect(knowledgeChars).toBeLessThanOrEqual(4000)
})
})
+88
View File
@@ -0,0 +1,88 @@
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 { KnowledgeRepository } from '../../src/main/db/repositories/knowledge.repo'
import { KnowledgeRetrievalService } from '../../src/main/services/knowledge-retrieval.service'
import { InteractiveWritingService } from '../../src/main/services/interactive-writing.service'
import { AiClientService } from '../../src/main/services/ai-client.service'
import { BookRegistryService } from '../../src/main/services/book-registry'
import { aiContextBuilder } from '../../src/main/services/ai-context-builder.service'
import { DEFAULT_AI_CONFIG, type AiContextBinding } from '../../src/shared/types'
import { mkdtempSync, rmSync } from 'fs'
import { join } from 'path'
import { tmpdir } from 'os'
const AI_TEST_CONFIG = { ...DEFAULT_AI_CONFIG, timeoutMs: 240_000, maxTokens: 4096 }
const EMPTY_BINDING: AiContextBinding = {
chapterIds: [],
outlineIds: [],
settingIds: [],
inspirationIds: [],
knowledgeIds: []
}
describe('AI context integration', () => {
it('IT-CTX-01: suggestContext returns entries when approved exist', () => {
const db = new DatabaseSync(':memory:')
migrate(db)
new KnowledgeRepository(db).create({
type: 'fact',
title: '测灵石异常',
content: '石头发热',
status: 'approved',
importance: 4
})
const result = new KnowledgeRetrievalService(db).suggest({ topN: 5 })
expect(result.length).toBeGreaterThanOrEqual(1)
expect(result[0].entry.title).toBe('测灵石异常')
db.close()
})
it('IT-CTX-02: build with knowledgeIds includes knowledge block in systemPrompt', () => {
const userDir = mkdtempSync(join(tmpdir(), 'bilin-ctx-it-'))
const registry = new BookRegistryService(userDir)
const meta = registry.create({ name: '测试书', category: '玄幻' })
const bookDb = registry.getDb(meta.id)
const entry = new KnowledgeRepository(bookDb).create({
type: 'foreshadow',
title: '测灵石',
content: '石头发热',
status: 'approved'
})
const result = aiContextBuilder.build(
{ ...EMPTY_BINDING, knowledgeIds: [entry.id] },
registry,
meta.id,
'作者'
)
expect(result.preview.some((p) => p.kind === 'knowledge')).toBe(true)
expect(result.systemPrompt).toContain('【知识·')
bookDb.close()
rmSync(userDir, { recursive: true, force: true })
})
it('IT-CTX-03: interactive confirm then suggestPlots uses knowledge context', async () => {
const db = new DatabaseSync(':memory:')
migrate(db)
const session = new AiSessionRepository(db).create('t', AI_TEST_CONFIG.model)
const know = new KnowledgeRepository(db).create({
type: 'foreshadow',
title: '测灵石异常',
content: '入门考核时测灵石发热,暗示主角特殊灵根',
status: 'approved',
importance: 5
})
const service = new InteractiveWritingService(db, new AiClientService(() => AI_TEST_CONFIG))
const flow = service.start(session.id)
service.confirmContext(flow.id, {
binding: { ...EMPTY_BINDING, knowledgeIds: [know.id] },
systemPrompt: `【知识·伏笔·测灵石异常】入门考核时测灵石发热,暗示主角特殊灵根\n\n林远参加宗门入门考核,演武场测灵石前众人围观。`,
contextSummary: '0章·0纲·0设·0感·1知'
})
const plots = await service.suggestPlots(flow.id)
expect(plots).toHaveLength(3)
db.close()
}, 240_000)
})
+46
View File
@@ -0,0 +1,46 @@
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 { buildFlowContextPayload } from '../../src/main/services/flow-context.util'
import { KnowledgeRepository } from '../../src/main/db/repositories/knowledge.repo'
describe('flow-context.util', () => {
let userDir: string
let registry: BookRegistryService
let bookId: string
beforeEach(() => {
userDir = mkdtempSync(join(tmpdir(), 'bilin-flow-ctx-'))
registry = new BookRegistryService(userDir)
bookId = registry.create({ name: '书', category: '玄幻' }).id
})
afterEach(() => rmSync(userDir, { recursive: true, force: true }))
it('buildFlowContextPayload stores systemPrompt and summary', () => {
const db = registry.getDb(bookId)
const entry = new KnowledgeRepository(db).create({
type: 'fact',
title: '测试知识',
content: '内容',
status: 'approved'
})
const payload = buildFlowContextPayload(
{
chapterIds: [],
outlineIds: [],
settingIds: [],
inspirationIds: [],
knowledgeIds: [entry.id]
},
registry,
bookId,
'作者'
)
expect(payload.systemPrompt).toContain('测试知识')
expect(payload.contextSummary).toContain('1')
db.close()
})
})
+11 -5
View File
@@ -15,11 +15,17 @@ describe('interactive plots integration', () => {
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: [] },
'林远参加宗门入门考核,演武场测灵石前众人围观。'
)
service.confirmContext(flow.id, {
binding: {
chapterIds: [],
outlineIds: [],
settingIds: [],
inspirationIds: [],
knowledgeIds: []
},
systemPrompt: '林远参加宗门入门考核,演武场测灵石前众人围观。',
contextSummary: '0章·0纲·0设·0感·0知'
})
const plots = await service.suggestPlots(flow.id)
expect(plots).toHaveLength(3)
db.close()
+11 -5
View File
@@ -18,11 +18,17 @@ describe('interactive scene integration', () => {
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: [] },
'林远在演武场测试灵根,众人围观。'
)
svc.confirmContext(flow.id, {
binding: {
chapterIds: [],
outlineIds: [],
settingIds: [],
inspirationIds: [],
knowledgeIds: []
},
systemPrompt: '林远在演武场测试灵根,众人围观。',
contextSummary: '0章·0纲·0设·0感·0知'
})
const plots = await svc.suggestPlots(flow.id)
svc.selectPlot(flow.id, { optionIds: [plots[0].id] })
const afterGen = await svc.generateScene(flow.id, () => {})
+80
View File
@@ -0,0 +1,80 @@
import { describe, it, expect, 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 { ChapterRepository } from '../../src/main/db/repositories/chapter.repo'
import { KnowledgeRepository } from '../../src/main/db/repositories/knowledge.repo'
import { KnowledgeRetrievalService } from '../../src/main/services/knowledge-retrieval.service'
import type { SqliteDb } from '../../src/main/db/types'
describe('KnowledgeRetrievalService', () => {
let db: SqliteDb
afterEach(() => db?.close())
it('UT-CTX-01: ranks by importance + chapter proximity + goal match', () => {
db = new DatabaseSync(':memory:')
migrate(db)
const volId = new VolumeRepository(db).create('卷一', 0).id
const chapterRepo = new ChapterRepository(db)
const cur = chapterRepo.create(volId, '当前章', 5, '<p>x</p>')
const near = chapterRepo.create(volId, '近章', 4, '<p>x</p>')
chapterRepo.create(volId, '远章', 0, '<p>x</p>')
const repo = new KnowledgeRepository(db)
const low = repo.create({
type: 'fact',
title: '无关事实',
content: '普通内容',
importance: 2,
status: 'approved'
})
const high = repo.create({
type: 'foreshadow',
title: '测灵石异常',
content: '测灵石在入门考核中发热',
importance: 5,
status: 'approved',
foreshadowStatus: 'buried',
sourceChapterId: near.id
})
const service = new KnowledgeRetrievalService(db)
const result = service.suggest({
currentChapterId: cur.id,
goalText: '测灵石入门考核',
topN: 5
})
expect(result[0].entry.id).toBe(high.id)
expect(result[0].score).toBeGreaterThan(
result.find((r) => r.entry.id === low.id)!.score
)
expect(result[0].reasons.length).toBeGreaterThan(0)
})
it('UT-CTX-04: resolved foreshadow scores lower than buried', () => {
db = new DatabaseSync(':memory:')
migrate(db)
const volId = new VolumeRepository(db).create('卷一', 0).id
const chId = new ChapterRepository(db).create(volId, '章', 0).id
const repo = new KnowledgeRepository(db)
const buried = repo.create({
type: 'foreshadow',
title: '埋设伏笔',
importance: 3,
status: 'approved',
foreshadowStatus: 'buried',
sourceChapterId: chId
})
const resolved = repo.create({
type: 'foreshadow',
title: '已回收伏笔',
importance: 3,
status: 'approved',
foreshadowStatus: 'resolved',
sourceChapterId: chId
})
const service = new KnowledgeRetrievalService(db)
const scores = service.suggest({ currentChapterId: chId, topN: 10 })
const buriedScore = scores.find((s) => s.entry.id === buried.id)!.score
const resolvedScore = scores.find((s) => s.entry.id === resolved.id)!.score
expect(buriedScore).toBeGreaterThan(resolvedScore)
})
})
+11 -5
View File
@@ -26,11 +26,17 @@ describe('WizardWritingService.startGenerate', () => {
})
flow = svc.setRhythm(flow.id, 'tense')
flow = svc.setPov(flow.id)
flow = svc.confirmContext(
flow.id,
{ chapterIds: [], outlineIds: [], settingIds: [], inspirationIds: [] },
CONTEXT
)
flow = svc.confirmContext(flow.id, {
binding: {
chapterIds: [],
outlineIds: [],
settingIds: [],
inspirationIds: [],
knowledgeIds: []
},
systemPrompt: CONTEXT,
contextSummary: '0章·0纲·0设·0感·0知'
})
return flow.id
}
+11 -5
View File
@@ -26,11 +26,17 @@ describe('WizardWritingService.buildPreview', () => {
})
flow = svc.setRhythm(flow.id, 'mixed')
flow = svc.setPov(flow.id)
flow = svc.confirmContext(
flow.id,
{ chapterIds: [], outlineIds: [], settingIds: [], inspirationIds: [] },
CONTEXT
)
flow = svc.confirmContext(flow.id, {
binding: {
chapterIds: [],
outlineIds: [],
settingIds: [],
inspirationIds: [],
knowledgeIds: []
},
systemPrompt: CONTEXT,
contextSummary: '0章·0纲·0设·0感·0知'
})
const preview = await svc.buildPreview(flow.id, CONTEXT, '林远')
expect(preview).toMatch(/<p>/)