From 781822befc5aad1f36331ba5c04b1d8ef8086312 Mon Sep 17 00:00:00 2001 From: gqw Date: Fri, 12 Jun 2026 13:41:18 +0800 Subject: [PATCH] fix: repair AI book memory and maps Fixes #4 Fixes #5 --- frontend/src/utils/aiBookGeneration.test.ts | 100 ++++++++++++++- frontend/src/utils/aiBookGeneration.ts | 128 ++++++++++++++++++-- frontend/src/views/AiBookView.vue | 63 ++++++---- frontend/src/views/ReaderView.vue | 1 - src/model/ai_book.rs | 8 ++ tests/ai_book_memory.rs | 47 ++++++- 6 files changed, 309 insertions(+), 38 deletions(-) diff --git a/frontend/src/utils/aiBookGeneration.test.ts b/frontend/src/utils/aiBookGeneration.test.ts index ee7f251..100eb7f 100644 --- a/frontend/src/utils/aiBookGeneration.test.ts +++ b/frontend/src/utils/aiBookGeneration.test.ts @@ -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('禁止把国家挂在城市下面') @@ -312,6 +318,13 @@ describe('aiBookGeneration', () => { confidence: '已知', importance: 'high', }, + { + category: '基础设定', + title: '梦境与巫师线索', + content: '卢米安坐在屋顶沉思,他一直渴望获得超凡力量但奥萝尔拒绝教他,称这条路危险痛苦。回到房间后看到奥萝尔在用香槟金色钢笔给笔友写信,奥萝尔解释笔友是通过报纸专栏等认识的书信朋友,其中有厉害的人,电池灯就是笔友送的。卢米安躺在床上担心奥萝尔的秘密带来危险。随后卢米安反复做灰色雾气的梦,无论往哪走都会回到自己的卧室,频率越来越高几乎每天都会做。清晨卢米安告诉奥萝尔又做那个梦了,奥萝尔说之前的方案没用,考虑给他找一个真正的催眠师。卢米安想成为巫师解开梦境秘密,奥萝尔拒绝并说这个世界变得越来越危险,催促他准备考试。', + confidence: '已知', + importance: 'high', + }, { category: '基础规则', title: '超凡领域', @@ -360,7 +373,7 @@ describe('aiBookGeneration', () => { message: { content: JSON.stringify({ memory: { - summary: '主角离开旧村,抵达北境。', + summary: '第十章「北境」:林舟离开旧村,抵达北境。', worldview: [ { category: '地理环境', title: '北境', content: '北境是寒冷边境区域,已出现新的线索。', confidence: '已知' }, ], @@ -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({ @@ -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, @@ -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') diff --git a/frontend/src/utils/aiBookGeneration.ts b/frontend/src/utils/aiBookGeneration.ts index f5e9273..69168f0 100644 --- a/frontend/src/utils/aiBookGeneration.ts +++ b/frontend/src/utils/aiBookGeneration.ts @@ -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' @@ -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 项。', '不要输出不重要、路人、一次性提及、无状态变化的角色;不要输出寒暄、同村、路过、单纯“认识”等低价值关系。', @@ -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', }], @@ -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 形成正确层级,父级尺度必须大于子级;无法确认父级时留空。', @@ -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) @@ -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, @@ -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, @@ -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 [ @@ -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('地图图片地址为空') diff --git a/frontend/src/views/AiBookView.vue b/frontend/src/views/AiBookView.vue index d5cf430..3b0a31a 100644 --- a/frontend/src/views/AiBookView.vue +++ b/frontend/src/views/AiBookView.vue @@ -52,29 +52,35 @@
-

世界观

-

{{ memory.summary || '暂无资料' }}

-
-
-
- - {{ group.items.length }} -
-
-
-
-

{{ note.title }}

- {{ note.confidence }} -
-

{{ note.content }}

-
-
-
-
- +

总览

+
+

剧情摘要

+

{{ memory.summary || '暂无资料' }}

+
+
+

世界观资料

+
+
+
+ + {{ group.items.length }} +
+
+
+
+

{{ note.title }}

+ {{ note.confidence }} +
+

{{ note.content }}

+
+
+
+
+ +