import { stripHtml } from './word-count'
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 {
if (text.length <= max) return text
return `${text.slice(0, max)}…`
}
function knowledgeTypeLabel(type: KnowledgeType): string {
const map: Record = {
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'
? '设定'
: '灵感'
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,
registry: BookRegistryService,
bookId: string,
penName: string
): AiContextBuildResult {
const meta = registry.getMeta(bookId)
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)
if (!chapter) continue
const plain = stripHtml(chapter.content)
const body = truncate(plain, CHAPTER_CHAR_LIMIT)
candidates.push({
sortKey: chapter.sortOrder,
text: formatBlock('chapter', chapter.title, body),
preview: {
kind: 'chapter',
id,
title: chapter.title,
excerpt: body,
charCount: body.length
}
})
}
for (const id of binding.outlineIds) {
const item = registry.getOutlineRepo(bookId).get(id)
if (!item) continue
const body = truncate(stripHtml(item.description), OTHER_CHAR_LIMIT)
candidates.push({
sortKey: item.sortOrder,
text: formatBlock('outline', item.title, body),
preview: {
kind: 'outline',
id,
title: item.title,
excerpt: body,
charCount: body.length
}
})
}
for (const id of binding.settingIds) {
const item = registry.getSettingRepo(bookId).get(id)
if (!item) continue
const body = truncate(stripHtml(item.description), OTHER_CHAR_LIMIT)
candidates.push({
sortKey: 0,
text: formatBlock('setting', item.name, body),
preview: {
kind: 'setting',
id,
title: item.name,
excerpt: body,
charCount: body.length
}
})
}
for (const id of binding.inspirationIds) {
const item = registry.getInspirationRepo(bookId).get(id)
if (!item) continue
const body = truncate(stripHtml(item.content), OTHER_CHAR_LIMIT)
candidates.push({
sortKey: Date.parse(item.updatedAt) || 0,
text: formatBlock('inspiration', item.title, body),
preview: {
kind: 'inspiration',
id,
title: item.title,
excerpt: body,
charCount: body.length
}
})
}
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[] = []
let totalChars = 0
for (const candidate of candidates) {
const nextTotal = totalChars + candidate.text.length + (selected.length > 0 ? 2 : 0)
if (nextTotal > TOTAL_CHAR_LIMIT) break
selected.push(candidate)
totalChars = nextTotal
}
const contextBlocks = selected.map((x) => x.text).join('\n\n')
const systemPrompt = `你是笔临 AI 写作助手。作者笔名:${penName}。书籍:${meta.name}。
以下是与本书相关的上下文(由作者勾选):
${contextBlocks || '(无)'}
请用简体中文回答,尊重作者的创作主权。`
return {
systemPrompt,
preview: selected.map((x) => x.preview)
}
}
}
export const aiContextBuilder = new AiContextBuilderService()