Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 99 additions & 1 deletion frontend/src/utils/aiBookGeneration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,11 +68,17 @@ describe('aiBookGeneration', () => {
expect(serialized).toContain('第八章 北境')
expect(serialized).not.toContain('主角抵达北境,只知道旧神传说真假未明。')
expect(serialized).not.toContain('previousMemory')
expect(serialized).toContain('累计剧情摘要')
expect(serialized).toContain('300-800 字')
expect(serialized).toContain('不要逐章累加')
expect(serialized).toContain('禁止以“本章”“第X章”“章节名:”开头')
expect(serialized).toContain('mapPrompt')
expect(serialized).toContain('俯视地图')
expect(serialized).toContain('不要写成场景照片')
expect(serialized).toContain('category')
expect(serialized).toContain('worldview 不是章节简介')
expect(serialized).toContain('worldview 必须输出 []')
expect(serialized).toContain('宁可为空')
expect(serialized).toContain('parentName')
expect(serialized).toContain('国家 > 区域/郡 > 城市')
expect(serialized).toContain('禁止把国家挂在城市下面')
Expand Down Expand Up @@ -312,6 +318,13 @@ describe('aiBookGeneration', () => {
confidence: '已知',
importance: 'high',
},
{
category: '基础设定',
title: '梦境与巫师线索',
content: '卢米安坐在屋顶沉思,他一直渴望获得超凡力量但奥萝尔拒绝教他,称这条路危险痛苦。回到房间后看到奥萝尔在用香槟金色钢笔给笔友写信,奥萝尔解释笔友是通过报纸专栏等认识的书信朋友,其中有厉害的人,电池灯就是笔友送的。卢米安躺在床上担心奥萝尔的秘密带来危险。随后卢米安反复做灰色雾气的梦,无论往哪走都会回到自己的卧室,频率越来越高几乎每天都会做。清晨卢米安告诉奥萝尔又做那个梦了,奥萝尔说之前的方案没用,考虑给他找一个真正的催眠师。卢米安想成为巫师解开梦境秘密,奥萝尔拒绝并说这个世界变得越来越危险,催促他准备考试。',
confidence: '已知',
importance: 'high',
},
{
category: '基础规则',
title: '超凡领域',
Expand Down Expand Up @@ -360,7 +373,7 @@ describe('aiBookGeneration', () => {
message: {
content: JSON.stringify({
memory: {
summary: '主角离开旧村,抵达北境。',
summary: '第十章「北境」:林舟离开旧村,抵达北境。',
worldview: [
{ category: '地理环境', title: '北境', content: '北境是寒冷边境区域,已出现新的线索。', confidence: '已知' },
],
Expand Down Expand Up @@ -408,6 +421,7 @@ describe('aiBookGeneration', () => {
fetchImpl: fetchMock as unknown as typeof fetch,
})

expect(update.memory.summary).toBe('主角仍在旧村。')
expect(update.memory.worldview.map((item) => item.title)).toEqual(['灵脉', '北境'])
expect(update.memory.characters.map((item) => item.name)).toEqual(['林舟', '沈月'])
expect(update.memory.characters.find((item) => item.name === '林舟')).toMatchObject({
Expand All @@ -421,6 +435,52 @@ describe('aiBookGeneration', () => {
expect(update.memory.locations.map((item) => item.name)).toEqual(['旧村', '北境'])
})

it('keeps cumulative summaries bounded instead of growing by chapter', async () => {
const longSummary = `主线开端。${'主角持续调查线索,局势逐步升级。'.repeat(120)}当前进展仍集中在北境。`
const fetchMock = vi.fn(async (_url: RequestInfo | URL, _init?: RequestInit) => ({
ok: true,
json: async () => ({
choices: [{
message: {
content: JSON.stringify({
memory: {
summary: longSummary,
worldview: [],
characters: [],
relationships: [],
locations: [],
},
shouldRegenerateMap: false,
}),
},
}],
}),
}))

const update = await requestAiBookMemoryUpdate({
config: readyConfig,
book: { name: '山海旧事', author: '佚名', bookUrl: 'book-1', origin: 'source-1' },
chapter: { title: '第一千章', url: 'chapter-1000', index: 999 },
chapterContent: '主角继续调查。',
memory: {
bookUrl: 'book-1',
enabled: true,
updatedAt: 0,
summary: '旧累计摘要。',
worldview: [],
characters: [],
relationships: [],
locations: [],
},
fetchImpl: fetchMock as unknown as typeof fetch,
})

expect([...update.memory.summary || '']).toHaveLength(1200)
expect(update.memory.summary).toContain('……')
expect(update.memory.summary).toContain('主线开端')
expect(update.memory.summary).toContain('当前进展仍集中在北境')
})

it('does not regenerate the world map when requested without location changes', async () => {
const fetchMock = vi.fn(async (_url: RequestInfo | URL, _init?: RequestInit) => ({
ok: true,
Expand Down Expand Up @@ -600,6 +660,44 @@ describe('aiBookGeneration', () => {
)
})

it('uploads generated data image URLs without backend proxy download', async () => {
installLocalStorage()
localStorage.setItem('accessToken', 'alice-token')
const fetchMock = vi.fn(async (url: RequestInfo | URL) => {
if (url === '/reader3/aiProxyImage') {
throw new Error('data image URLs should not be proxied')
}
return {
ok: true,
json: async () => ({
isSuccess: true,
data: ['/assets/alice/ai-maps/map.png'],
}),
}
}) as unknown as typeof fetch

const url = await uploadGeneratedMap({
imageUrl: `data:image/png;base64,${btoa('fake-png')}`,
filename: 'map.png',
useBackendProxy: true,
fetchImpl: fetchMock,
})

expect(url).toBe('/assets/alice/ai-maps/map.png')
expect(fetchMock).toHaveBeenCalledTimes(1)
expect(fetchMock).toHaveBeenCalledWith(
'/reader3/uploadFile?type=ai-maps',
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({
Authorization: 'alice-token',
}),
body: expect.any(FormData),
}),
)
expect(fetchMock).not.toHaveBeenCalledWith('/reader3/aiProxyImage', expect.anything())
})

it('routes text model calls through the backend proxy when enabled', async () => {
installLocalStorage()
localStorage.setItem('accessToken', 'alice-token')
Expand Down
128 changes: 119 additions & 9 deletions frontend/src/utils/aiBookGeneration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ type AiBookToolResult = {
}

const MAX_AI_BOOK_AGENT_STEPS = 6
const MAX_AI_BOOK_SUMMARY_CHARS = 1200
const AI_BOOK_TOOL_GET_MEMORY = 'get_current_memory'
const AI_BOOK_TOOL_GET_CHAPTER = 'get_completed_chapter'
const AI_BOOK_TOOL_SAVE_PATCH = 'save_memory_patch'
Expand Down Expand Up @@ -201,9 +202,13 @@ export function buildAiBookPromptMessages({
`必须先调用 ${AI_BOOK_TOOL_GET_MEMORY} 和 ${AI_BOOK_TOOL_GET_CHAPTER},最后调用 ${AI_BOOK_TOOL_SAVE_PATCH} 完成更新。`,
'不要在普通文本中输出最终 JSON;最终结果必须放在 save_memory_patch 工具参数里。',
'无法确认的信息必须标记为“推断”或“未知”。',
'summary 用于记录已读剧情进展;worldview 不是章节简介。',
'summary 是截至当前已处理章节的累计剧情摘要,不是单章摘要;必须保留并压缩已有 summary,再融入当前章节新增进展。',
'summary 必须持续压缩,建议 300-800 字;章节很多时只保留主线、重大转折、核心谜团和当前状态,不要逐章累加。',
'summary 禁止以“本章”“第X章”“章节名:”开头;不要只复述当前章节,也不要丢弃前面章节的关键进展。',
'summary 是唯一可以记录章节剧情进展的位置;worldview 不是章节简介。',
'worldview 必须是跨章节可复用的设定集条目,只记录规则、制度、势力、历史、技术/魔法、社会文化、地理环境、组织体系、未确认设定。',
'worldview 禁止写成本章剧情复述、人物行动流水账、案件经过、章节摘要;不要使用“本章”“这一章”“第X章”作为设定标题或内容主体。',
'worldview 禁止写成本章剧情复述、人物行动流水账、案件经过、章节摘要;不要使用“本章”“这一章”“第X章”“第三章《标题》”作为设定标题或内容主体。',
'如果当前章节没有新增稳定设定,worldview 必须输出 [];不要为了凑条目把章节内容改写成长段概述。',
'世界观必须按 category 分类,例如:基础规则、势力制度、历史传说、技术/魔法、社会文化、地理环境、组织体系、未确认信息。',
'角色和关系必须填写 importance: high|medium|low;只保留推动剧情、反复出现或明确影响主角行动的 high/medium 项。',
'不要输出不重要、路人、一次性提及、无状态变化的角色;不要输出寒暄、同村、路过、单纯“认识”等低价值关系。',
Expand All @@ -221,11 +226,11 @@ export function buildAiBookPromptMessages({
task: 'tool-calling-ai-book-memory-update',
finalTool: AI_BOOK_TOOL_SAVE_PATCH,
patchSchema: {
summary: 'string,已读剧情进展摘要,允许写章节事件',
summary: 'string,300-800 字累计已读剧情摘要,必须压缩旧 summary + 当前章节新增进展;禁止写成单章摘要或逐章流水账',
worldview: [{
category: '基础规则|势力制度|历史传说|技术/魔法|社会文化|地理环境|组织体系|未确认信息',
title: 'string,设定名,不要写“本章/第X章/剧情”',
content: 'string,稳定设定说明,不要写章节流水账',
title: 'string,设定名,只能是概念/规则/组织/地点体系名,不要写“本章/第X章/剧情/章节名”',
content: 'string,稳定设定说明;禁止以章节号、章节名、时间顺序或角色行动复述开头',
confidence: '已知|推断|未知',
importance: 'high|medium|low',
}],
Expand Down Expand Up @@ -262,7 +267,9 @@ export function buildAiBookPromptMessages({
},
qualityRules: [
'worldview 必须有 category;同一 category 下不要重复 title;只写设定,不写本章简介。',
'summary 必须是累计压缩摘要;如果已有 summary,先保留旧摘要中的主线,再合并当前章节新增变化,全篇控制在 300-800 字。',
'剧情经过、角色行动、调查过程、战斗过程写入 summary 或角色状态,不要写入 worldview。',
'worldview 宁可为空,也不要输出“第X章《标题》:角色先做A、随后做B”的单章总结。',
'characters 只输出重要角色;背景人物、一次性称呼、无独立状态者不要输出。',
'relationships 只输出重要关系;同一 source/target/relation 只保留一条,不要反向重复。',
'locations 必须尽量给 parentName 形成正确层级,父级尺度必须大于子级;无法确认父级时留空。',
Expand Down Expand Up @@ -528,9 +535,15 @@ export async function uploadGeneratedMap({
useBackendProxy = false,
fetchImpl = fetch,
}: UploadGeneratedMapParams) {
const blob = b64Json
? base64ToBlob(b64Json, 'image/png')
: await fetchImageBlob(imageUrl || '', fetchImpl, useBackendProxy)
const imageSource = imageUrl || ''
let blob: Blob
if (b64Json) {
blob = base64ToBlob(b64Json, 'image/png')
} else if (isDataImageUrl(imageSource)) {
blob = dataUrlToBlob(imageSource)
} else {
blob = await fetchImageBlob(imageSource, fetchImpl, useBackendProxy)
}

const formData = new FormData()
formData.append('file', blob, filename)
Expand Down Expand Up @@ -645,7 +658,7 @@ function coerceModelUpdate(raw: AiBookRawModelUpdate, previous: AiBookMemory, bo
processedChapterIndex: chapter.index,
processedChapterTitle: chapter.title,
updatedAt: Date.now(),
summary: typeof rawMemory.summary === 'string' ? rawMemory.summary : previous.summary || '',
summary: normalizeSummary(rawMemory.summary, previous.summary),
worldview,
characters,
relationships,
Expand All @@ -669,6 +682,39 @@ function mergeIncrementalItems(previousItems: unknown[] | undefined, nextItems:
return Array.isArray(nextItems) ? [...previousArray, ...nextItems] : previousArray
}

function normalizeSummary(nextSummary: unknown, previousSummary: string | undefined) {
const previous = (previousSummary || '').trim()
if (typeof nextSummary !== 'string') return previous

const next = nextSummary.trim()
if (!next) return limitSummaryLength(previous)
if (!previous) return limitSummaryLength(stripSingleChapterSummaryHeading(next))
if (startsWithSingleChapterSummary(next)) return limitSummaryLength(previous)
return limitSummaryLength(next)
}

function startsWithSingleChapterSummary(value: string) {
return /^(?:本章|本节|这一章)[::,,]/.test(value.trim())
|| /^第\s*(?:\d+|[零〇一二两三四五六七八九十百千万]+)\s*[章节回话卷篇][^。!?;]{0,40}[::]/.test(value.trim())
}

function stripSingleChapterSummaryHeading(value: string) {
return value
.trim()
.replace(/^(?:本章|本节|这一章)[::,,]\s*/, '')
.replace(/^第\s*(?:\d+|[零〇一二两三四五六七八九十百千万]+)\s*[章节回话卷篇][^。!?;]{0,40}[::]\s*/, '')
.trim()
}

function limitSummaryLength(value: string) {
const chars = [...value.trim()]
if (chars.length <= MAX_AI_BOOK_SUMMARY_CHARS) return value.trim()

const headLength = Math.floor((MAX_AI_BOOK_SUMMARY_CHARS - 2) * 0.55)
const tailLength = MAX_AI_BOOK_SUMMARY_CHARS - 2 - headLength
return `${chars.slice(0, headLength).join('')}……${chars.slice(-tailLength).join('')}`
}

function shouldAcceptMapRegeneration({
requested,
mapPrompt,
Expand Down Expand Up @@ -733,11 +779,51 @@ function isChapterSummaryWorldview(title: string, content: string, category: str
if (/^(本章|本节|这一章|此章|第.+章)/.test(content.trim())) {
return true
}
if (isNarrativeRecapText(title, content)) {
return true
}
const plotVerbs = ['搜查', '担心', '登上', '指出', '加入', '透露', '引出', '随后']
const plotHits = plotVerbs.filter((term) => contentKey.includes(normalizeKey(term))).length
return content.length > 80 && plotHits >= 3 && !isSettingCategory(category)
}

function isNarrativeRecapText(title: string, content: string) {
const trimmed = content.trim()
const combined = `${title} ${trimmed}`
const normalized = normalizeKey(combined)
const sentenceCount = trimmed
.split(/[。!?;]/)
.filter((part) => part.trim().length > 0)
.length
const chapterReference = /第\s*(?:\d+|[零〇一二两三四五六七八九十百千万]+)\s*[章节回话卷篇]/.test(combined)
|| ['本章', '这一章', '当前章节', '章节内容'].some((term) => normalized.includes(normalizeKey(term)))
const narrativeTerms = [
'随后',
'然后',
'接着',
'回到',
'看到',
'告诉',
'解释',
'拒绝',
'催促',
'等待',
'躺在',
'坐在',
'担心',
'考虑',
'讲述',
'寻找',
'清晨',
'下午',
'晚上',
'第二天',
]
const narrativeHits = narrativeTerms.filter((term) => normalized.includes(normalizeKey(term))).length
return (chapterReference && trimmed.length > 60 && narrativeHits >= 2)
|| (trimmed.length > 140 && sentenceCount >= 4 && narrativeHits >= 4)
}

function isSettingCategory(category: string) {
const key = normalizeKey(category)
return [
Expand Down Expand Up @@ -1124,6 +1210,30 @@ function base64ToBlob(value: string, contentType: string) {
return new Blob([bytes], { type: contentType })
}

function isDataImageUrl(value: string) {
return value.trim().toLowerCase().startsWith('data:image/')
}

function dataUrlToBlob(value: string) {
const dataUrl = value.trim()
const commaIndex = dataUrl.indexOf(',')
if (commaIndex < 0 || !dataUrl.toLowerCase().startsWith('data:')) {
throw new Error('地图图片 data URL 无效')
}

const metadata = dataUrl.slice(5, commaIndex)
const data = dataUrl.slice(commaIndex + 1)
const parts = metadata.split(';').filter(Boolean)
const contentType = parts[0] || 'text/plain'
if (!contentType.toLowerCase().startsWith('image/')) {
throw new Error('地图图片 data URL 不是图片')
}
if (parts.some((part) => part.toLowerCase() === 'base64')) {
return base64ToBlob(data.trim(), contentType)
}
return new Blob([decodeURIComponent(data)], { type: contentType })
}

async function fetchImageBlob(imageUrl: string, fetchImpl: typeof fetch, useBackendProxy: boolean) {
if (!imageUrl) {
throw new Error('地图图片地址为空')
Expand Down
Loading