From c7110518eb8db0d88989c429528f4f378d41505f Mon Sep 17 00:00:00 2001 From: honlnk Date: Wed, 10 Jun 2026 22:51:20 +0800 Subject: [PATCH 01/14] =?UTF-8?q?feat(project):=20=E5=AE=8C=E6=88=90?= =?UTF-8?q?=E5=AE=9E=E4=BD=93=E8=A6=81=E7=B4=A0=E4=B8=8E=E7=AB=A0=E8=8A=82?= =?UTF-8?q?=E6=A0=BC=E5=BC=8F=E6=94=B6=E6=95=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs | 2 +- .../src/components/layout/ContentPanel.vue | 9 ++- packages/app/src/stores/project.ts | 2 +- packages/app/src/views/TestLabView.vue | 1 + packages/core/src/core/agent/prompt.ts | 5 +- packages/core/src/core/agent/tools.ts | 2 +- packages/core/src/core/chat/session.ts | 2 +- .../core/src/core/elements/elements.test.ts | 22 ++++++- packages/core/src/core/elements/extractor.ts | 63 ++++++++++++++++++- packages/core/src/core/elements/parser.ts | 8 ++- packages/core/src/core/elements/writer.ts | 1 + packages/core/src/core/fs/project-fs.ts | 27 +++++--- packages/core/src/core/rag/indexer.ts | 4 ++ packages/core/src/core/rag/retrieval-text.ts | 1 + packages/core/src/core/tools/rag-search.ts | 2 +- packages/core/src/core/tools/types.ts | 4 +- packages/core/src/services/element-service.ts | 1 + packages/core/src/services/project-service.ts | 2 +- packages/core/src/services/types.ts | 3 +- packages/core/src/types/elements.ts | 1 + packages/core/src/types/rag.ts | 2 +- 21 files changed, 137 insertions(+), 27 deletions(-) diff --git a/docs b/docs index 04768ae..5e4b131 160000 --- a/docs +++ b/docs @@ -1 +1 @@ -Subproject commit 04768ae19ef26aab28221f23808ed91248a79ee6 +Subproject commit 5e4b13188397d6af5601e159c0c9b27424f9ca48 diff --git a/packages/app/src/components/layout/ContentPanel.vue b/packages/app/src/components/layout/ContentPanel.vue index 69b4bb4..c89c5b6 100644 --- a/packages/app/src/components/layout/ContentPanel.vue +++ b/packages/app/src/components/layout/ContentPanel.vue @@ -30,7 +30,7 @@ const canExtractElements = computed(() => { return Boolean( props.file && props.file.path.startsWith('chapters/') && - props.file.name.endsWith('.txt') && + /\.(txt|md)$/i.test(props.file.name) && props.file.content.trim(), ) }) @@ -139,6 +139,7 @@ function resetExtractionState() { function countExtractionItems(result: ElementExtractionResultView) { return result.characters.length + result.locations.length + + result.entities.length + result.timeline.length + result.plots.length + result.worldbuilding.length @@ -259,7 +260,7 @@ function countExtractionItems(result: ElementExtractionResultView) {
{{ extractionPreview.characters.length }}
@@ -269,6 +270,10 @@ function countExtractionItems(result: ElementExtractionResultView) {
{{ extractionPreview.locations.length }}
地点
+
+
{{ extractionPreview.entities.length }}
+
实体
+
{{ extractionPreview.plots.length }}
情节
diff --git a/packages/app/src/stores/project.ts b/packages/app/src/stores/project.ts index 9011cf0..b89e4a6 100644 --- a/packages/app/src/stores/project.ts +++ b/packages/app/src/stores/project.ts @@ -261,7 +261,7 @@ function toRecentProject(project: ProjectView): RecentProject { function countChapterFiles(nodes: ProjectFileNodeView[]): number { return nodes.reduce((total, node) => { if (node.kind === 'file') { - return node.path.startsWith('chapters/') && node.name.endsWith('.txt') + return node.path.startsWith('chapters/') && /\.(txt|md)$/i.test(node.name) ? total + 1 : total } diff --git a/packages/app/src/views/TestLabView.vue b/packages/app/src/views/TestLabView.vue index 1d5ecff..ba3f77f 100644 --- a/packages/app/src/views/TestLabView.vue +++ b/packages/app/src/views/TestLabView.vue @@ -307,6 +307,7 @@ async function onPreviewElementExtraction() { const total = extractionPreview.value.characters.length + extractionPreview.value.locations.length + + extractionPreview.value.entities.length + extractionPreview.value.timeline.length + extractionPreview.value.plots.length + extractionPreview.value.worldbuilding.length diff --git a/packages/core/src/core/agent/prompt.ts b/packages/core/src/core/agent/prompt.ts index dfc103c..306a8a2 100644 --- a/packages/core/src/core/agent/prompt.ts +++ b/packages/core/src/core/agent/prompt.ts @@ -21,7 +21,8 @@ export function buildAgentSystemPrompt(customPrompt?: string) { '- 删除文件时,必须确认用户意图明确,再使用 DeleteFile;它会把文件移入回收站,不会永久删除。', '- 不确定某个目录的直接结构时,先使用 ListDirectory 查看目录,不要为了探索目录而批量读取文件。', '- 需要按文件名、路径模式查找文件时,使用 FindFiles;找到候选文件后再用 ReadFile 精读。', - '- 需要保持人物、地点、剧情、时间线或世界观一致时,优先使用 RagSearch 召回相关要素;检索结果不足或需要完整原文时,再按 sourcePath 使用 ReadFile 精读。', + '- 要素分组规则:人物写入 elements/characters,地点写入 elements/locations,武功、武器、坐骑、丹药、信物、法器等具体实体写入 elements/entities,剧情事件写入 elements/plots,阶段时间线写入 elements/timeline,抽象规则和体系写入 elements/worldbuilding。', + '- 需要保持人物、地点、实体、剧情、时间线或世界观一致时,优先使用 RagSearch 召回相关要素;检索结果不足或需要完整原文时,再按 sourcePath 使用 ReadFile 精读。', '- 聊天回复用于说明你做了什么、为什么这么做、下一步建议是什么;不要把完整长篇正文当作唯一结果留在聊天里。', '- 如果缺少目标路径或上下文,先说明你需要什么,或先读取项目中最相关的文件。', '- 保持中文输出,除非用户明确要求其他语言。', @@ -35,7 +36,7 @@ export function buildAgentSystemPrompt(customPrompt?: string) { '- DeleteFile 用于把单个 .md、.json、.txt 文件移入 .novel/trash 回收站。不要删除用户没有明确要求删除的文件。', '- ListDirectory 用于查看某个已存在目录的直接子项;不传 path 时查看项目根目录。它不会读取文件正文。目录不存在时,如果目标是新建文件,可以直接用 CreateFile。', '- FindFiles 用于按 glob 模式递归查找文件路径,例如 **/*.md、chapters/*.txt、**/*来信*.md。它不会读取文件正文。', - '- RagSearch 用于从项目要素索引中语义检索相关设定。写新章节、续写、改稿、回答设定相关问题时,先用自然语言 query 检索;返回的 sourcePath 可用于后续 ReadFile 精读。', + '- RagSearch 用于从项目要素索引中语义检索相关设定,包括人物、地点、实体、剧情、时间线和世界观。写新章节、续写、改稿、回答设定相关问题时,先用自然语言 query 检索;返回的 sourcePath 可用于后续 ReadFile 精读。', '- 可以连续使用多个工具完成任务。完成工具调用后,继续根据工具结果判断是否还需要下一步。', '- 完成任务后,用简短自然语言总结变更,不要重复输出整个文件。', ].join('\n') diff --git a/packages/core/src/core/agent/tools.ts b/packages/core/src/core/agent/tools.ts index 54aeee9..bc5e303 100644 --- a/packages/core/src/core/agent/tools.ts +++ b/packages/core/src/core/agent/tools.ts @@ -333,7 +333,7 @@ export function createAgentTools(): AgentRunnableToolMap { type: 'array', items: { type: 'string', - enum: ['character', 'location', 'timeline', 'plot', 'worldbuilding'], + enum: ['character', 'location', 'entity', 'timeline', 'plot', 'worldbuilding'], }, description: '可选,只检索指定类型的要素。', }, diff --git a/packages/core/src/core/chat/session.ts b/packages/core/src/core/chat/session.ts index 8a3f498..44227d1 100644 --- a/packages/core/src/core/chat/session.ts +++ b/packages/core/src/core/chat/session.ts @@ -665,7 +665,7 @@ function flattenChapterPaths(tree: ChatTurnInput['project']['tree']) { continue } - if (node.kind === 'file' && node.path.startsWith('chapters/') && node.name.endsWith('.txt')) { + if (node.kind === 'file' && node.path.startsWith('chapters/') && /\.(txt|md)$/i.test(node.name)) { paths.push(node.path) continue } diff --git a/packages/core/src/core/elements/elements.test.ts b/packages/core/src/core/elements/elements.test.ts index c83df9b..adc15e8 100644 --- a/packages/core/src/core/elements/elements.test.ts +++ b/packages/core/src/core/elements/elements.test.ts @@ -12,14 +12,16 @@ describe('elements', () => { '', '主角林远在深夜走进废弃藏书楼,发现一封来自十年前的信。', '林远低声问自己,那封旧王朝密信为什么会藏在这里。', + '他取出青霜剑,又想起玄天剑法不能在人前施展。', ].join('\n'), }) expect(result.characters.some((item) => item.name === '林远')).toBe(true) expect(result.locations.some((item) => item.name.includes('藏书楼'))).toBe(true) + expect(result.entities.some((item) => item.name.includes('青霜剑'))).toBe(true) + expect(result.entities.some((item) => item.name.includes('玄天剑法'))).toBe(true) expect(result.plots).toHaveLength(1) expect(result.timeline).toHaveLength(1) - expect(result.worldbuilding.some((item) => item.name.includes('密信'))).toBe(true) }) it('writes element documents and skips unchanged writes', async () => { @@ -52,6 +54,24 @@ describe('elements', () => { expect(second.skipped).toEqual(['elements/characters/林远.md']) expect(second.staleIndex).toBe(false) }) + + it('writes entity documents into elements/entities', async () => { + const handle = createMemoryDirectory('novel') + const element = createElementDocument({ + type: 'entity', + name: '青霜剑', + summary: '林远当前持有的佩剑。', + tags: ['实体', '武器'], + lastUpdatedChapter: 'chapters/第001章.txt', + relatedChapters: ['chapters/第001章.txt'], + body: '## 实体线索\n\n- 林远取出青霜剑。', + }) + + const result = await writeElementDocuments(handle, [element]) + + expect(result.created).toEqual(['elements/entities/青霜剑.md']) + await expect(readProjectText(handle, 'elements/entities/青霜剑.md')).resolves.toContain('type: entity') + }) }) type MemoryFileEntry = { diff --git a/packages/core/src/core/elements/extractor.ts b/packages/core/src/core/elements/extractor.ts index 2bad880..89bedc5 100644 --- a/packages/core/src/core/elements/extractor.ts +++ b/packages/core/src/core/elements/extractor.ts @@ -6,12 +6,15 @@ type ExtractionBucket = keyof ElementExtractionResult const TYPE_BUCKET_MAP: Record = { character: 'characters', location: 'locations', + entity: 'entities', timeline: 'timeline', plot: 'plots', worldbuilding: 'worldbuilding', } const LOCATION_SUFFIXES = '楼阁院城镇村谷山峰林河湖海洞宫殿寺庙塔桥巷街路关岛洲府国界域' +const ENTITY_SUFFIX_PATTERN = + '令牌|玉佩|丹药|卷轴|图谱|秘籍|功法|剑法|刀法|拳法|掌法|身法|秘术|法器|灵器|宝物|信物|剑|刀|枪|戟|弓|弩|鞭|锤|鼎|炉|镜|珠|佩|钗|戒|袍|甲|冠|印|符|丸|散|膏|酒|茶|马|兽|鹰|书' export async function extractElementsFromChapter(input: { chapterContent: string @@ -24,6 +27,7 @@ export async function extractElementsFromChapter(input: { const result: ElementExtractionResult = { characters: [], locations: [], + entities: [], timeline: [], plots: [], worldbuilding: [], @@ -51,6 +55,17 @@ export async function extractElementsFromChapter(input: { }) } + for (const name of extractEntityNames(content)) { + pushItem(result, 'entity', { + name, + summary: `${name} 是「${title}」中出现的具体实体,需要记录来历、归属、能力或状态变化。`, + tags: ['自动提取', '实体'], + lastUpdatedChapter: chapterRef, + relatedChapters: [chapterRef], + body: buildEntityBody(content, name), + }) + } + const plotSummary = summarizeChapter(content) if (plotSummary) { pushItem(result, 'plot', { @@ -162,7 +177,7 @@ function extractLocationNames(markdown: string) { function extractWorldbuildingNames(markdown: string) { const names = new Set() - const pattern = /([一-龥]{2,10}(?:信|书|卷轴|玉佩|令牌|契约|阵法|禁制|传说|预言|组织|宗门|王朝|仪式|秘术|法器))/g + const pattern = /([一-龥]{2,10}(?:契约|阵法|禁制|传说|预言|组织|宗门|王朝|仪式|门派|规矩|境界|体系))/g for (const match of markdown.matchAll(pattern)) { const name = sanitizeName(match[1]) @@ -175,6 +190,26 @@ function extractWorldbuildingNames(markdown: string) { return Array.from(names).slice(0, 8) } +function extractEntityNames(markdown: string) { + const names = new Set() + const patterns = [ + new RegExp(`([一-龥]{2,12}(?:${ENTITY_SUFFIX_PATTERN}))`, 'g'), + /(?:持有|握住|拔出|取出|递给|服下|修炼|施展|得到|遗失|损毁)([一-龥]{2,12})/g, + ] + + for (const pattern of patterns) { + for (const match of markdown.matchAll(pattern)) { + const name = sanitizeName(match[1]) + + if (name.length >= 2 && name.length <= 12 && !isCommonPhrase(name)) { + names.add(name) + } + } + } + + return Array.from(names).slice(0, 8) +} + function summarizeChapter(markdown: string) { const paragraph = markdown .split(/\n{2,}/) @@ -197,6 +232,32 @@ function buildBody(markdown: string, name: string, heading: string) { ].join('\n') } +function buildEntityBody(markdown: string, name: string) { + return [ + '## 基本信息', + '', + '- 类型:待补充', + '- 当前状态:待补充', + '', + '## 实体线索', + '', + collectEvidence(markdown, name), + '', + '## 来历与归属', + '', + '- 来历:待补充。', + '- 当前归属:待补充。', + '', + '## 能力或用途', + '', + '- 待补充。', + '', + '## 状态变化', + '', + '- 待补充。', + ].join('\n') +} + function buildTimelineBody(markdown: string) { const sentences = splitSentences(markdown) .filter((sentence) => sentence.length >= 8) diff --git a/packages/core/src/core/elements/parser.ts b/packages/core/src/core/elements/parser.ts index 8b83ae4..780faf3 100644 --- a/packages/core/src/core/elements/parser.ts +++ b/packages/core/src/core/elements/parser.ts @@ -78,7 +78,13 @@ function normalizeFrontmatter(raw: string): ElementFrontmatter { } function normalizeType(value: string | undefined): ElementFrontmatter['type'] { - if (value === 'location' || value === 'timeline' || value === 'plot' || value === 'worldbuilding') { + if ( + value === 'location' || + value === 'entity' || + value === 'timeline' || + value === 'plot' || + value === 'worldbuilding' + ) { return value } diff --git a/packages/core/src/core/elements/writer.ts b/packages/core/src/core/elements/writer.ts index ff2d8eb..36e2227 100644 --- a/packages/core/src/core/elements/writer.ts +++ b/packages/core/src/core/elements/writer.ts @@ -6,6 +6,7 @@ import { readProjectTextFile, writeProjectTextFile } from '../fs/project-fs' const ELEMENT_DIRECTORY_MAP: Record = { character: 'elements/characters', location: 'elements/locations', + entity: 'elements/entities', timeline: 'elements/timeline', plot: 'elements/plots', worldbuilding: 'elements/worldbuilding', diff --git a/packages/core/src/core/fs/project-fs.ts b/packages/core/src/core/fs/project-fs.ts index 4c5c006..08ce9d7 100644 --- a/packages/core/src/core/fs/project-fs.ts +++ b/packages/core/src/core/fs/project-fs.ts @@ -17,6 +17,7 @@ const REQUIRED_DIRECTORY_PATHS = [ 'elements', 'elements/characters', 'elements/locations', + 'elements/entities', 'elements/timeline', 'elements/plots', 'elements/worldbuilding', @@ -43,15 +44,9 @@ export async function createProject(projectName: string): Promise path === 'elements' || path.startsWith('elements/')) + + for (const directoryPath of elementDirectories) { + if (!(await pathExists(rootHandle, directoryPath, 'directory'))) { + return false + } + } + + return true +} + /** * 按相对路径读取项目中的任意文本文件。 * 适合测试页或后续业务层读取 prompt、章节、要素原文。 diff --git a/packages/core/src/core/rag/indexer.ts b/packages/core/src/core/rag/indexer.ts index dba84ba..46cd643 100644 --- a/packages/core/src/core/rag/indexer.ts +++ b/packages/core/src/core/rag/indexer.ts @@ -201,6 +201,10 @@ function inferTypeFromPath(path: string) { return 'location' as const } + if (path.startsWith('elements/entities/')) { + return 'entity' as const + } + if (path.startsWith('elements/timeline/')) { return 'timeline' as const } diff --git a/packages/core/src/core/rag/retrieval-text.ts b/packages/core/src/core/rag/retrieval-text.ts index 1f0bb95..557a0d8 100644 --- a/packages/core/src/core/rag/retrieval-text.ts +++ b/packages/core/src/core/rag/retrieval-text.ts @@ -6,6 +6,7 @@ const MAX_RELATED_CHAPTERS = 20 const TYPE_LABELS = { character: '人物', location: '地点', + entity: '实体', timeline: '时间线', plot: '情节', worldbuilding: '世界观', diff --git a/packages/core/src/core/tools/rag-search.ts b/packages/core/src/core/tools/rag-search.ts index 4966f11..a54a9cf 100644 --- a/packages/core/src/core/tools/rag-search.ts +++ b/packages/core/src/core/tools/rag-search.ts @@ -8,7 +8,7 @@ import type { } from './types' import type { ElementType } from '../../types/rag' -const ELEMENT_TYPES: ElementType[] = ['character', 'location', 'timeline', 'plot', 'worldbuilding'] +const ELEMENT_TYPES: ElementType[] = ['character', 'location', 'entity', 'timeline', 'plot', 'worldbuilding'] export const ragSearchTool: ToolDefinition<'RagSearch', RagSearchInput, RagSearchOutput> = { name: 'RagSearch', diff --git a/packages/core/src/core/tools/types.ts b/packages/core/src/core/tools/types.ts index c04877c..9746339 100644 --- a/packages/core/src/core/tools/types.ts +++ b/packages/core/src/core/tools/types.ts @@ -161,7 +161,7 @@ export type RagSearchInput = { topK?: number finalLimit?: number filters?: { - type?: Array<'character' | 'location' | 'timeline' | 'plot' | 'worldbuilding'> + type?: Array<'character' | 'location' | 'entity' | 'timeline' | 'plot' | 'worldbuilding'> tags?: string[] lastUpdatedChapter?: string } @@ -175,7 +175,7 @@ export type RagSearchOutput = { candidates: Array<{ id: string sourcePath: string - type: 'character' | 'location' | 'timeline' | 'plot' | 'worldbuilding' + type: 'character' | 'location' | 'entity' | 'timeline' | 'plot' | 'worldbuilding' name: string summary: string retrievalText: string diff --git a/packages/core/src/services/element-service.ts b/packages/core/src/services/element-service.ts index be71c0e..b440fe6 100644 --- a/packages/core/src/services/element-service.ts +++ b/packages/core/src/services/element-service.ts @@ -21,6 +21,7 @@ export async function writeExtractedElements(input: { const items = [ ...input.extraction.characters, ...input.extraction.locations, + ...input.extraction.entities, ...input.extraction.timeline, ...input.extraction.plots, ...input.extraction.worldbuilding, diff --git a/packages/core/src/services/project-service.ts b/packages/core/src/services/project-service.ts index ca9e48d..1db7abd 100644 --- a/packages/core/src/services/project-service.ts +++ b/packages/core/src/services/project-service.ts @@ -78,7 +78,7 @@ export async function restoreLastProject(): Promise { return null } - const project = await loadProjectFromHandle(record.handle) + const project = await repairProject(record.handle) await activateProject(project, 'project_restored', `恢复上次项目「${project.name}」`, { rememberedAt: record.lastOpenedAt, }) diff --git a/packages/core/src/services/types.ts b/packages/core/src/services/types.ts index 528f51d..e91175f 100644 --- a/packages/core/src/services/types.ts +++ b/packages/core/src/services/types.ts @@ -94,7 +94,7 @@ export type LlmStreamInputView = { instruction: string } -export type ElementTypeView = 'character' | 'location' | 'timeline' | 'plot' | 'worldbuilding' +export type ElementTypeView = 'character' | 'location' | 'entity' | 'timeline' | 'plot' | 'worldbuilding' export type IndexStatusView = 'empty' | 'building' | 'ready' | 'stale' | 'rebuilding' | 'error' @@ -173,6 +173,7 @@ export type ElementExtractionItemView = { export type ElementExtractionResultView = { characters: ElementExtractionItemView[] locations: ElementExtractionItemView[] + entities: ElementExtractionItemView[] timeline: ElementExtractionItemView[] plots: ElementExtractionItemView[] worldbuilding: ElementExtractionItemView[] diff --git a/packages/core/src/types/elements.ts b/packages/core/src/types/elements.ts index 27b462e..babb980 100644 --- a/packages/core/src/types/elements.ts +++ b/packages/core/src/types/elements.ts @@ -30,6 +30,7 @@ export type ElementExtractionItem = { export type ElementExtractionResult = { characters: ElementExtractionItem[] locations: ElementExtractionItem[] + entities: ElementExtractionItem[] timeline: ElementExtractionItem[] plots: ElementExtractionItem[] worldbuilding: ElementExtractionItem[] diff --git a/packages/core/src/types/rag.ts b/packages/core/src/types/rag.ts index 9d118c7..38576b1 100644 --- a/packages/core/src/types/rag.ts +++ b/packages/core/src/types/rag.ts @@ -1,4 +1,4 @@ -export type ElementType = 'character' | 'location' | 'timeline' | 'plot' | 'worldbuilding' +export type ElementType = 'character' | 'location' | 'entity' | 'timeline' | 'plot' | 'worldbuilding' export type IndexStatus = 'empty' | 'building' | 'ready' | 'stale' | 'rebuilding' | 'error' From f3b53ec3fc20aaa6d12057266046028a47372fc4 Mon Sep 17 00:00:00 2001 From: honlnk Date: Thu, 11 Jun 2026 14:24:29 +0800 Subject: [PATCH 02/14] =?UTF-8?q?docs(project):=20=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E6=96=87=E6=A1=A3=E5=85=A5=E5=8F=A3=E7=BB=93=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 2 +- INTERFACE.md | 2 +- README.md | 18 +++++++++++------- docs | 2 +- 4 files changed, 14 insertions(+), 10 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index bb17f54..10eb611 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -57,4 +57,4 @@ packages/core @novai/core → 业务逻辑层(无框架依赖,纯 TypeScri - Node.js 18+, pnpm 10+ - 浏览器需支持 File System Access API(仅 Chromium) -- `docs/` 是 git submodule,指向 NovAI-document 仓库 +- `docs/` 是 git submodule,指向 NovAI-document 仓库;文档按 `product/`、`architecture/`、`decisions/`、`project/` 分层维护 diff --git a/INTERFACE.md b/INTERFACE.md index ad712be..6f1a21a 100644 --- a/INTERFACE.md +++ b/INTERFACE.md @@ -331,5 +331,5 @@ function resolvePreferredPath(changes: ChangedFileView[]) { 更详细的设计记录见: ```txt -docs/UI协作接口契约设计.md +docs/architecture/UI协作接口契约设计.md ``` diff --git a/README.md b/README.md index 2f1ba14..4fe2c66 100644 --- a/README.md +++ b/README.md @@ -185,15 +185,19 @@ pnpm typecheck ## 文档 -项目文档主要放在 [`docs/`](./docs): +项目文档主要放在 [`docs/`](./docs),当前按用途分为四类: + +- [`docs/product/`](./docs/product):产品愿景、AI 功能需求、UI 设计 +- [`docs/architecture/`](./docs/architecture):技术架构、Agent Loop、工具系统、RAG、UI 接口契约 +- [`docs/decisions/`](./docs/decisions):阶段性技术和产品决策记录 +- [`docs/project/`](./docs/project):路线图、MVP、当前进度、开发日志 + +常用入口: -- [`docs/NovAI产品规划.md`](./docs/NovAI产品规划.md):产品定位与核心思路 -- [`docs/AI功能需求说明书.md`](./docs/AI功能需求说明书.md):AI 功能需求 -- [`docs/UI设计文档.md`](./docs/UI设计文档.md):界面与交互设计 -- [`docs/技术架构设计.md`](./docs/技术架构设计.md):技术选型与架构说明 -- [`docs/project/项目总览.md`](./docs/project/项目总览.md):项目阶段与目标 -- [`docs/project/MVP清单.md`](./docs/project/MVP清单.md):MVP 范围与执行顺序 - [`docs/project/当前进度.md`](./docs/project/当前进度.md):开发推进情况 +- [`docs/project/项目总览.md`](./docs/project/项目总览.md):项目阶段与目标 +- [`docs/product/产品愿景.md`](./docs/product/产品愿景.md):产品定位与核心思路 +- [`docs/architecture/技术架构设计.md`](./docs/architecture/技术架构设计.md):技术选型与架构说明 - [`INTERFACE.md`](./INTERFACE.md):当前 UI 协作接口、stores/services 使用入口 ## 仓库目标 diff --git a/docs b/docs index 5e4b131..d36f7df 160000 --- a/docs +++ b/docs @@ -1 +1 @@ -Subproject commit 5e4b13188397d6af5601e159c0c9b27424f9ca48 +Subproject commit d36f7df78e6ceb7447735afb460d795c5583e118 From 346ed66233aac4cd26aa1e04931504fa9349d9ac Mon Sep 17 00:00:00 2001 From: honlnk Date: Fri, 12 Jun 2026 14:44:23 +0800 Subject: [PATCH 03/14] =?UTF-8?q?docs(project):=20=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E5=8D=8F=E4=BD=9C=E6=96=87=E6=A1=A3=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 11 ++++++----- INTERFACE.md | 28 +++++++++++++++------------- docs | 2 +- 3 files changed, 22 insertions(+), 19 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 10eb611..0d8741b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -31,11 +31,11 @@ packages/core @novai/core → 业务逻辑层(无框架依赖,纯 TypeScri **数据流:** `Vue Component → Pinia Store → Service → Core Domain` -- **app/src/stores/** — 三个 Pinia store:`project`(项目/文件状态)、`chat`(Agent 会话/事件流)、`settings`(配置/模型) -- **core/src/services/** — Service 层桥接 core 类型和 UI 视图类型,管理内存中的项目运行时注册表 -- **core/src/core/agent/** — Agent 循环:组装消息 → LLM 流式调用 → 解析 tool_calls → 执行工具 → 循环(最多 8 轮) -- **core/src/core/tools/** — 7 个文件工具(ReadFile/EditFile/CreateFile/RenameFile/DeleteFile/ListDirectory/FindFiles),全部基于浏览器 File System Access API -- **core/src/core/rag/** — RAG 管线(规划中/部分实现) +- **packages/app/src/stores/** — 三个 Pinia store:`project`(项目/文件状态)、`chat`(Agent 会话/事件流)、`settings`(配置/模型) +- **packages/core/src/services/** — Service 层桥接 core 类型和 UI 视图类型,管理内存中的项目运行时注册表 +- **packages/core/src/core/agent/** — Agent 循环:组装消息 → LLM 流式调用 → 解析 tool_calls → 执行工具 → 循环(最多 8 轮) +- **packages/core/src/core/tools/** — 7 个文件工具(ReadFile/EditFile/CreateFile/RenameFile/DeleteFile/ListDirectory/FindFiles),全部基于浏览器 File System Access API;Agent 额外暴露 `RagSearch` 作为只读检索工具 +- **packages/core/src/core/rag/** — RAG 管线(IndexedDB 记录 + 手写 cosineSimilarity 的过渡召回,已接入可选 Rerank;正式 Orama 召回待补齐) ## Key Patterns @@ -43,6 +43,7 @@ packages/core @novai/core → 业务逻辑层(无框架依赖,纯 TypeScri - **API 代理:** Vite dev server 内置 `/api-proxy` 中间件,通过 `x-target-base` header 转发请求到 LLM/Embedding API,解决浏览器 CORS 限制。 - **@novai/core 无构建步骤:** app 直接通过源码导入 core(见 core/package.json exports map),不需要先构建 core。 - **Agent 事件流:** AgentService 发出 `AgentUiEvent` 流供 UI 消费,实现流式响应展示。 +- **Agent LLM fallback:** 流式 tool_calls 解析异常或流式响应空闲超时时,会回退到非流式请求;相关行为已有 Vitest 覆盖。 ## Commit Convention diff --git a/INTERFACE.md b/INTERFACE.md index 6f1a21a..b74e035 100644 --- a/INTERFACE.md +++ b/INTERFACE.md @@ -1,6 +1,6 @@ # NovAI UI 接口说明 -最后更新:2026-05-01 +最后更新:2026-06-12 本文档说明当前 NovAI 代码中 UI 层应如何使用项目能力。它面向前端协作者,重点回答三个问题: @@ -14,16 +14,16 @@ 约定如下: -- UI 页面优先使用 `src/stores/*`。 -- Store 通过 `src/services/*` 调用 core 能力。 -- 页面和组件不要直接 import `src/core/*`。 +- UI 页面优先使用 `packages/app/src/stores/*`。 +- Store 通过 `packages/core/src/services/*` 调用 core 能力。 +- 页面和组件不要直接 import `packages/core/src/core/*`。 - UI 不直接持有 `FileSystemDirectoryHandle` 或 `ProjectSnapshot`。 -- 项目、文件、会话、Agent 事件都通过 `src/services/types.ts` 中的 view type 暴露。 +- 项目、文件、会话、Agent 事件都通过 `packages/core/src/services/types.ts` 中的 view type 暴露。 当前已经完成迁移验证: -- `src/views/SessionTestView.vue` -- `src/views/TestLabView.vue` +- `packages/app/src/views/SessionTestView.vue` +- `packages/app/src/views/TestLabView.vue` ## 推荐调用路径 @@ -148,7 +148,7 @@ const result = await chatStore.runServiceTurn({ ## Service 入口 -所有 service 都在 `src/services/` 下。 +所有 service 都在 `packages/core/src/services/` 下。 | 文件 | 职责 | |:-----|:-----| @@ -170,7 +170,7 @@ const result = await chatStore.runServiceTurn({ 核心接口类型统一放在: ```txt -src/services/types.ts +packages/core/src/services/types.ts ``` 常用类型: @@ -227,7 +227,7 @@ type AgentUiEvent = - 用户消息 - 上下文摘要 -- assistant 流式输出 +- assistant 文本消息 - 工具调用记录 - 工具结果记录 - 文件变更 @@ -275,6 +275,7 @@ chatStore.syncDefaultTarget(project.id, projectStore.activeFile?.path) ```txt chapters/chapter-001.md +chapters/第001章.txt prompts/system.md elements/characters/foo.md ``` @@ -315,15 +316,16 @@ function resolvePreferredPath(changes: ChangedFileView[]) { - 文件 diff 预览尚未实现。 - `changedFiles` 仍是 service 层推导,不是工具层结构化输出。 - Agent 会话还没有持久化。 -- RAG 调试接口已存在,但 `RagSearch` 尚未作为正式 Agent 工具接入。 -- 要素提取当前仍是预览/占位能力。 +- `RagSearch` 已作为正式 Agent 工具接入,但真实创作任务中的触发率和召回质量仍需验证。 +- 要素提取已能规则型提取并写入 `elements/`,但还不是 LLM 结构化提取,也缺少用户确认、去重合并和覆盖策略。 +- 旧的 `pendingFileChange / confirmPendingFileChange` 确认流程代码仍有残留,但当前主工作流没有接入。 ## 协作规则 为了减少多人协作冲突,建议遵守: - UI 页面只依赖 stores 和 services。 -- 新 UI 组件不要直接 import `src/core/*`。 +- 新 UI 组件不要直接 import `packages/core/src/core/*`。 - 新增 UI 需要的数据,优先扩展 `services/types.ts` 和 mapper。 - core 内部结构变化时,尽量只修改 service / mapper,不让页面跟着改。 - 如果需要新增业务动作,优先新增 service,再由 store 适配 UI 状态。 diff --git a/docs b/docs index d36f7df..406fc75 160000 --- a/docs +++ b/docs @@ -1 +1 @@ -Subproject commit d36f7df78e6ceb7447735afb460d795c5583e118 +Subproject commit 406fc75591fc6f5f24f2ee70f22545ec0cd54078 From 4b299264b2f39dbbf6898372fdcce269ca2232aa Mon Sep 17 00:00:00 2001 From: honlnk Date: Fri, 12 Jun 2026 15:00:56 +0800 Subject: [PATCH 04/14] =?UTF-8?q?docs(project):=20=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E5=8F=82=E8=80=83=E4=BB=93=E5=BA=93=E8=B7=AF=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 2 +- docs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ccd8220..108f7c6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,7 +28,7 @@ This means NovAI should gradually move away from a simple "single prompt -> sing For implementation reference and comparative study, keep this external repository available next to the NovAI repo: -- `/Users/honlnk/project/claude-code-sound` +- `/Users/honlnk/project/claude-code` This repository is intentionally cloned outside the NovAI git repository so that: diff --git a/docs b/docs index 406fc75..75051f5 160000 --- a/docs +++ b/docs @@ -1 +1 @@ -Subproject commit 406fc75591fc6f5f24f2ee70f22545ec0cd54078 +Subproject commit 75051f5fd9d02c555609b04dac9f5715a9e8d5c4 From 55aa13c338e59d7aa7f8c38f3aecb0146b896c71 Mon Sep 17 00:00:00 2001 From: honlnk Date: Fri, 12 Jun 2026 15:37:55 +0800 Subject: [PATCH 05/14] =?UTF-8?q?docs(project):=20=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E8=AE=A1=E5=88=92=E6=96=87=E6=A1=A3=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 3 ++- docs | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 4fe2c66..38aaa9d 100644 --- a/README.md +++ b/README.md @@ -185,11 +185,12 @@ pnpm typecheck ## 文档 -项目文档主要放在 [`docs/`](./docs),当前按用途分为四类: +项目文档主要放在 [`docs/`](./docs),当前按用途分为五类: - [`docs/product/`](./docs/product):产品愿景、AI 功能需求、UI 设计 - [`docs/architecture/`](./docs/architecture):技术架构、Agent Loop、工具系统、RAG、UI 接口契约 - [`docs/decisions/`](./docs/decisions):阶段性技术和产品决策记录 +- [`docs/plans/`](./docs/plans):阶段性功能计划和重构计划 - [`docs/project/`](./docs/project):路线图、MVP、当前进度、开发日志 常用入口: diff --git a/docs b/docs index 75051f5..28d0e1b 160000 --- a/docs +++ b/docs @@ -1 +1 @@ -Subproject commit 75051f5fd9d02c555609b04dac9f5715a9e8d5c4 +Subproject commit 28d0e1b6a2daeb6acfccf547132c2054a04b2a2d From 9fad9d6c7b3954bec5988e9a59336dccb815ee9c Mon Sep 17 00:00:00 2001 From: honlnk Date: Fri, 12 Jun 2026 15:57:26 +0800 Subject: [PATCH 06/14] =?UTF-8?q?docs(docs):=20=E5=90=8C=E6=AD=A5=E9=A1=B9?= =?UTF-8?q?=E7=9B=AE=E6=96=87=E6=A1=A3=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 3 +++ docs | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 38aaa9d..16751b5 100644 --- a/README.md +++ b/README.md @@ -53,10 +53,12 @@ NovAI 的思路是换一条路: 尚未完整落地的核心能力包括: - 写工具执行前确认 +- 文件 diff 预览、Agent 停止运行与工具调用边界控制 - LLM 结构化要素抽取、要素去重合并与覆盖确认 - RAG 在真实创作任务中的召回质量验证与使用策略调优 - 自动增量索引、索引过期提醒与更完整的降级策略 - 近期章节上下文拼装 +- Agent 会话持久化与上下文压缩 - Rerank 精排与更完整的创作工作流 ## MVP 目标 @@ -82,6 +84,7 @@ NovAI 采用“一个文件夹就是一个小说项目”的思路。当前默 ├── elements/ │ ├── characters/ │ ├── locations/ +│ ├── entities/ │ ├── timeline/ │ ├── plots/ │ └── worldbuilding/ diff --git a/docs b/docs index 28d0e1b..1e1b571 160000 --- a/docs +++ b/docs @@ -1 +1 @@ -Subproject commit 28d0e1b6a2daeb6acfccf547132c2054a04b2a2d +Subproject commit 1e1b571ed26967e46e2e4f0406c1e687d1141788 From 1a58f4c7b807104cef2aba3a0710a69334c44075 Mon Sep 17 00:00:00 2001 From: honlnk Date: Sat, 13 Jun 2026 14:32:00 +0800 Subject: [PATCH 07/14] =?UTF-8?q?feat(app):=20=E6=B3=A8=E5=85=A5=E5=9C=BA?= =?UTF-8?q?=E6=99=AF=E7=BA=A7=E6=8F=90=E7=A4=BA=E8=AF=8D=E5=88=B0=20Agent?= =?UTF-8?q?=20=E4=B8=8A=E4=B8=8B=E6=96=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ProjectConfig.settings 新增 activeScenePromptPath 字段,支持持久化激活场景 - 新增 readScenePrompt,路径为空或文件缺失时安全返回空字符串 - buildAgentSystemPrompt 扩展为接收 systemPrompt 与 scenePrompt,场景内容拼接到系统消息 - runTurn 读取配置并透传 scenePrompt 到 Agent 消息组装链路 --- .gitignore | 1 + packages/core/src/core/agent/prompt.ts | 22 ++++++++++++++++++--- packages/core/src/core/chat/session.ts | 7 ++++++- packages/core/src/core/fs/project-fs.ts | 21 ++++++++++++++++++++ packages/core/src/core/project/defaults.ts | 1 + packages/core/src/services/agent-service.ts | 7 ++++++- packages/core/src/services/types.ts | 6 ++++++ packages/core/src/types/chat.ts | 1 + packages/core/src/types/project.ts | 5 +++++ packages/core/tsconfig.tsbuildinfo | 1 - 10 files changed, 66 insertions(+), 6 deletions(-) delete mode 100644 packages/core/tsconfig.tsbuildinfo diff --git a/.gitignore b/.gitignore index 574e835..1352886 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ node_modules .pnpm-store dist .DS_Store +*.tsbuildinfo diff --git a/packages/core/src/core/agent/prompt.ts b/packages/core/src/core/agent/prompt.ts index 306a8a2..e03c647 100644 --- a/packages/core/src/core/agent/prompt.ts +++ b/packages/core/src/core/agent/prompt.ts @@ -1,11 +1,27 @@ import type { ChatTargetContext } from '../../types/chat' import type { ProjectSnapshot } from '../../types/project' -export function buildAgentSystemPrompt(customPrompt?: string) { - const userPrompt = customPrompt?.trim() +export function buildAgentSystemPrompt(input: { + systemPrompt?: string + scenePrompt?: string +} = {}) { + const userPrompt = input.systemPrompt?.trim() + const scenePrompt = input.scenePrompt?.trim() + + const head = scenePrompt + ? [ + userPrompt || '你是 NovAI,一个通过工具读写本地小说项目文件的写作 Agent。', + '', + '---', + '', + '【当前场景提示词】', + '', + scenePrompt, + ].join('\n') + : userPrompt || '你是 NovAI,一个通过工具读写本地小说项目文件的写作 Agent。' return [ - userPrompt || '你是 NovAI,一个通过工具读写本地小说项目文件的写作 Agent。', + head, '', '你是交互式小说创作 Agent。你的工作不是把所有正文都堆到聊天里,而是理解用户意图,然后使用工具读取、修改或创建项目文件。', '', diff --git a/packages/core/src/core/chat/session.ts b/packages/core/src/core/chat/session.ts index 44227d1..02769dc 100644 --- a/packages/core/src/core/chat/session.ts +++ b/packages/core/src/core/chat/session.ts @@ -98,6 +98,7 @@ export async function runChatTurn(options: RunChatTurnOptions): Promise { + const scenePath = path?.trim() + + if (!scenePath || !scenePath.startsWith('prompts/scenes/')) { + return '' + } + + try { + return await readText(rootHandle, scenePath) + } catch { + return '' + } +} + /** * 写回项目中的 `prompts/system.md`。 */ diff --git a/packages/core/src/core/project/defaults.ts b/packages/core/src/core/project/defaults.ts index 30e8b23..892d0c8 100644 --- a/packages/core/src/core/project/defaults.ts +++ b/packages/core/src/core/project/defaults.ts @@ -49,6 +49,7 @@ export const DEFAULT_CONFIG = { embeddingTextVersion: 1, enableBackgroundIndexing: true, enableDebugLogging: false, + activeScenePromptPath: null, }, } as const diff --git a/packages/core/src/services/agent-service.ts b/packages/core/src/services/agent-service.ts index f3df31c..bbaf9fb 100644 --- a/packages/core/src/services/agent-service.ts +++ b/packages/core/src/services/agent-service.ts @@ -3,7 +3,7 @@ import { runChatTurn, } from '../core/chat/session' import { deriveChatTargetFromPath } from '../core/chat/target' -import { readSystemPrompt } from '../core/fs/project-fs' +import { readScenePrompt, readSystemPrompt } from '../core/fs/project-fs' import type { AgentMessage, AgentToolCall } from '../core/agent/messages' import type { ChatMessage, ChatSessionState, ChatTargetContext } from '../types/chat' @@ -60,6 +60,10 @@ export async function runTurn(input: RunAgentTurnInput): Promise Date: Sun, 14 Jun 2026 10:15:00 +0800 Subject: [PATCH 08/14] =?UTF-8?q?refactor(view):=20=E7=A7=BB=E9=99=A4=20Te?= =?UTF-8?q?st=20Lab=20=E4=B8=8E=20Session=20Lab=20=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 删除 TestLabView 与 SessionTestView 两个调试视图 - router 同步移除 /test 与 /session-test 路由 - 正式工作台已覆盖生成、配置、RAG 等能力,测试页不再需要 --- packages/app/src/app/router.ts | 12 - packages/app/src/views/SessionTestView.vue | 398 ------------- packages/app/src/views/TestLabView.vue | 651 --------------------- 3 files changed, 1061 deletions(-) delete mode 100644 packages/app/src/views/SessionTestView.vue delete mode 100644 packages/app/src/views/TestLabView.vue diff --git a/packages/app/src/app/router.ts b/packages/app/src/app/router.ts index 77dfbae..24ee20a 100644 --- a/packages/app/src/app/router.ts +++ b/packages/app/src/app/router.ts @@ -3,8 +3,6 @@ import { createRouter, createWebHistory } from 'vue-router' import HomeView from '../views/HomeView.vue' import ProjectView from '../views/ProjectView.vue' import SettingsView from '../views/SettingsView.vue' -import SessionTestView from '../views/SessionTestView.vue' -import TestLabView from '../views/TestLabView.vue' export const router = createRouter({ history: createWebHistory(import.meta.env.BASE_URL), @@ -24,15 +22,5 @@ export const router = createRouter({ name: 'settings', component: SettingsView, }, - { - path: '/test', - name: 'test', - component: TestLabView, - }, - { - path: '/session-test', - name: 'session-test', - component: SessionTestView, - }, ], }) diff --git a/packages/app/src/views/SessionTestView.vue b/packages/app/src/views/SessionTestView.vue deleted file mode 100644 index 7062384..0000000 --- a/packages/app/src/views/SessionTestView.vue +++ /dev/null @@ -1,398 +0,0 @@ - - -