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
@@ -1,15 +1,24 @@
import { stripHtml } from './word-count'
import type { AiContextBinding, AiContextBuildResult, ContextPreviewItem } from '../../shared/types'
import type {
AiContextBinding,
AiContextBuildResult,
ContextPreviewItem,
KnowledgeType
} from '../../shared/types'
import type { BookRegistryService } from './book-registry'
import { KnowledgeRepository } from '../db/repositories/knowledge.repo'
const CHAPTER_CHAR_LIMIT = 2000
const OTHER_CHAR_LIMIT = 500
const KNOWLEDGE_CHAR_LIMIT = 500
const KNOWLEDGE_TOTAL_LIMIT = 4000
const TOTAL_CHAR_LIMIT = 16 * 1024
interface BlockCandidate {
sortKey: number
text: string
preview: ContextPreviewItem
isKnowledge?: boolean
}
function truncate(text: string, max: number): string {
@@ -17,12 +26,33 @@ function truncate(text: string, max: number): string {
return `${text.slice(0, max)}`
}
function knowledgeTypeLabel(type: KnowledgeType): string {
const map: Record<KnowledgeType, string> = {
character: '角色',
foreshadow: '伏笔',
location: '地点',
relationship: '关系',
fact: '事实'
}
return map[type]
}
function formatBlock(kind: ContextPreviewItem['kind'], title: string, body: string): string {
const label =
kind === 'chapter' ? '章节' : kind === 'outline' ? '大纲' : kind === 'setting' ? '设定' : '灵感'
kind === 'chapter'
? '章节'
: kind === 'outline'
? '大纲'
: kind === 'setting'
? '设定'
: '灵感'
return `${label}${title}\n${body}`
}
function formatKnowledgeBlock(type: KnowledgeType, title: string, body: string): string {
return `【知识·${knowledgeTypeLabel(type)}${title}\n${body}`
}
export class AiContextBuilderService {
build(
binding: AiContextBinding,
@@ -34,6 +64,7 @@ export class AiContextBuilderService {
if (!meta) throw new Error('Book not found')
const candidates: BlockCandidate[] = []
const knowledgeIds = binding.knowledgeIds ?? []
for (const id of binding.chapterIds) {
const chapter = registry.getChapterRepo(bookId).get(id)
@@ -104,6 +135,38 @@ export class AiContextBuilderService {
})
}
const knowledgeRepo = new KnowledgeRepository(registry.getDb(bookId))
const knowledgeCandidates: BlockCandidate[] = []
for (const id of knowledgeIds) {
const entry = knowledgeRepo.get(id)
if (!entry || entry.status !== 'approved') continue
const body = truncate(stripHtml(entry.content || entry.title), KNOWLEDGE_CHAR_LIMIT)
knowledgeCandidates.push({
sortKey: entry.importance * 1000,
isKnowledge: true,
text: formatKnowledgeBlock(entry.type, entry.title, body),
preview: {
kind: 'knowledge',
id,
title: entry.title,
excerpt: body,
charCount: body.length,
knowledgeType: entry.type
}
})
}
knowledgeCandidates.sort((a, b) => b.sortKey - a.sortKey)
let knowledgeChars = 0
const trimmedKnowledge: BlockCandidate[] = []
for (const k of knowledgeCandidates) {
const next = knowledgeChars + k.text.length + (trimmedKnowledge.length > 0 ? 2 : 0)
if (next > KNOWLEDGE_TOTAL_LIMIT) break
trimmedKnowledge.push(k)
knowledgeChars = next
}
candidates.push(...trimmedKnowledge)
candidates.sort((a, b) => b.sortKey - a.sortKey)
const selected: BlockCandidate[] = []