From a9653c8c544f4594b4d5b3be59aab634a175372b Mon Sep 17 00:00:00 2001 From: honlnk Date: Tue, 9 Jun 2026 22:29:32 +0800 Subject: [PATCH 1/4] =?UTF-8?q?docs(docs):=20=E6=9B=B4=E6=96=B0=E6=96=87?= =?UTF-8?q?=E6=A1=A3=E5=AD=90=E6=A8=A1=E5=9D=97=E6=8C=87=E9=92=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs b/docs index 1677916..51fa884 160000 --- a/docs +++ b/docs @@ -1 +1 @@ -Subproject commit 1677916c61cdc25b9ec039bf8d76fa4e9967b804 +Subproject commit 51fa884d9fc8e2a54b5b69deb60fb5a6a026cd07 From db493b7b900741825ad0d6308b4068af1fff0ec5 Mon Sep 17 00:00:00 2001 From: honlnk Date: Tue, 9 Jun 2026 23:18:14 +0800 Subject: [PATCH 2/4] =?UTF-8?q?docs(docs):=20=E6=9B=B4=E6=96=B0=E6=96=87?= =?UTF-8?q?=E6=A1=A3=E5=AD=90=E6=A8=A1=E5=9D=97=E6=8C=87=E9=92=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs b/docs index 51fa884..e32af6c 160000 --- a/docs +++ b/docs @@ -1 +1 @@ -Subproject commit 51fa884d9fc8e2a54b5b69deb60fb5a6a026cd07 +Subproject commit e32af6c1ce6b888347f5f26565dcea0b9234cef6 From a062fab34c1799972fbe7e1e9633497d18cd1745 Mon Sep 17 00:00:00 2001 From: honlnk Date: Wed, 10 Jun 2026 00:49:00 +0800 Subject: [PATCH 3/4] =?UTF-8?q?docs(docs):=20=E6=9B=B4=E6=96=B0=E6=96=87?= =?UTF-8?q?=E6=A1=A3=E5=AD=90=E6=A8=A1=E5=9D=97=E6=8C=87=E9=92=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs b/docs index e32af6c..04768ae 160000 --- a/docs +++ b/docs @@ -1 +1 @@ -Subproject commit e32af6c1ce6b888347f5f26565dcea0b9234cef6 +Subproject commit 04768ae19ef26aab28221f23808ed91248a79ee6 From c5969c1c6906b0966205a5a542a57db9e4be90fc Mon Sep 17 00:00:00 2001 From: honlnk Date: Wed, 10 Jun 2026 01:21:42 +0800 Subject: [PATCH 4/4] =?UTF-8?q?fix(project):=20=E5=BC=BA=E5=88=B6=E7=AB=A0?= =?UTF-8?q?=E8=8A=82=E4=BD=BF=E7=94=A8txt=E6=A0=BC=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/layout/ContentPanel.vue | 4 +- packages/app/src/stores/project.ts | 2 +- packages/app/src/views/TestLabView.vue | 10 +-- packages/core/src/core/agent/prompt.ts | 5 +- packages/core/src/core/agent/tools.ts | 12 +-- packages/core/src/core/chat/session.ts | 4 +- .../core/src/core/elements/elements.test.ts | 12 +-- packages/core/src/core/elements/extractor.ts | 34 ++++---- packages/core/src/core/fs/project-fs.ts | 12 +-- packages/core/src/core/project/defaults.ts | 9 ++- .../core/src/core/tools/file-tools/common.ts | 12 ++- .../src/core/tools/file-tools/create-file.ts | 4 +- .../core/tools/file-tools/file-tools.test.ts | 78 +++++++++++++------ .../src/core/tools/file-tools/rename-file.ts | 8 +- packages/core/src/core/tools/path.ts | 8 ++ packages/core/src/services/element-service.ts | 2 +- packages/core/src/services/file-service.ts | 4 +- 17 files changed, 139 insertions(+), 81 deletions(-) diff --git a/packages/app/src/components/layout/ContentPanel.vue b/packages/app/src/components/layout/ContentPanel.vue index 1a57a06..69b4bb4 100644 --- a/packages/app/src/components/layout/ContentPanel.vue +++ b/packages/app/src/components/layout/ContentPanel.vue @@ -29,8 +29,8 @@ const isWritingElements = ref(false) const canExtractElements = computed(() => { return Boolean( props.file && - props.file.format === 'markdown' && props.file.path.startsWith('chapters/') && + props.file.name.endsWith('.txt') && props.file.content.trim(), ) }) @@ -88,7 +88,7 @@ async function handlePreviewElements() { try { extractionPreview.value = await previewElementExtraction({ - chapterMarkdown: props.file.content, + chapterContent: props.file.content, chapterPath: props.file.path, }) diff --git a/packages/app/src/stores/project.ts b/packages/app/src/stores/project.ts index 4e213ea..9011cf0 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('.md') + return node.path.startsWith('chapters/') && node.name.endsWith('.txt') ? total + 1 : total } diff --git a/packages/app/src/views/TestLabView.vue b/packages/app/src/views/TestLabView.vue index 3e54791..1d5ecff 100644 --- a/packages/app/src/views/TestLabView.vue +++ b/packages/app/src/views/TestLabView.vue @@ -27,9 +27,9 @@ const connectionStatus = ref('还没有测试连接。') const generationStatus = ref('还没有开始生成。') const isBusy = ref(false) const isStreaming = ref(false) -const systemPrompt = ref('你是一名长篇小说写作助手,输出中文 Markdown。') +const systemPrompt = ref('你是一名长篇小说写作助手,输出中文纯文本章节正文。') const instruction = ref('写一段测试内容:主角深夜走进废弃藏书楼,发现一封来自十年前的信。') -const chapterFileName = ref('chapter-test.md') +const chapterFileName = ref('chapter-test.txt') const result = ref('') const lastSavedChapterPath = ref('') const rerankStatus = ref('还没有测试 Rerank 连接。') @@ -290,16 +290,16 @@ async function onRunRagDemo() { } async function onPreviewElementExtraction() { - const chapterMarkdown = result.value.trim() || activeFile.value?.content || '' + const chapterContent = result.value.trim() || activeFile.value?.content || '' - if (!chapterMarkdown) { + if (!chapterContent) { extractionStatus.value = '请先生成章节结果,或先打开一个可预览文件' return } await runTask(async () => { extractionPreview.value = await previewElementExtraction({ - chapterMarkdown, + chapterContent, chapterPath: activeFile.value?.path || lastSavedChapterPath.value || '', systemPrompt: systemPrompt.value, }) diff --git a/packages/core/src/core/agent/prompt.ts b/packages/core/src/core/agent/prompt.ts index 90e3dd1..dfc103c 100644 --- a/packages/core/src/core/agent/prompt.ts +++ b/packages/core/src/core/agent/prompt.ts @@ -15,6 +15,8 @@ export function buildAgentSystemPrompt(customPrompt?: string) { '- 对已有文件动手前,先使用 ReadFile 读取相关内容。', '- 修改已有文件时,优先使用 EditFile 做精确替换;必须先用 ReadFile 读取原文,不要在没有读过原文时盲目改写。', '- 新建章节、设定或提示词文件时,使用 CreateFile;父目录会自动创建。', + '- 新建章节必须写入 chapters/*.txt,章节正文使用纯文本;第一行写章节标题,不要使用 Markdown 标题、列表、引用、分割线等格式符号。', + '- 要素文件和提示词文件继续使用 .md;不要把章节正文写成 .md 文件。', '- 重命名或移动已有文件时,使用 RenameFile,不要用 CreateFile + DeleteFile 模拟。', '- 删除文件时,必须确认用户意图明确,再使用 DeleteFile;它会把文件移入回收站,不会永久删除。', '- 不确定某个目录的直接结构时,先使用 ListDirectory 查看目录,不要为了探索目录而批量读取文件。', @@ -28,10 +30,11 @@ export function buildAgentSystemPrompt(customPrompt?: string) { '- ReadFile 用于读取 .md、.json、.txt 文件,返回带行号内容;默认最多读取 2000 行。长文件或已知目标位置时,使用 offset/limit 分段读取。', '- EditFile 用于精确替换已有文件中的片段。oldText 必须来自 ReadFile 结果,但不要包含行号前缀;保留原文缩进,尽量提供足够上下文避免误替换。重复文本只改一处时,直接用目标行加相邻上一行或下一行组成唯一 oldText。新增文件请用 CreateFile。', '- CreateFile 用于创建不存在的新文件;目标已存在时会失败。不要用它覆盖已有文件,已有文件请先 ReadFile 再 EditFile。', + '- CreateFile 创建章节时,path 必须形如 chapters/第001章-标题.txt,content 必须是纯文本正文,不能以 # 标题开头。', '- RenameFile 用于重命名或移动单个 .md、.json、.txt 文件;源文件必须存在,目标文件不能存在,父目录会自动创建。', '- DeleteFile 用于把单个 .md、.json、.txt 文件移入 .novel/trash 回收站。不要删除用户没有明确要求删除的文件。', '- ListDirectory 用于查看某个已存在目录的直接子项;不传 path 时查看项目根目录。它不会读取文件正文。目录不存在时,如果目标是新建文件,可以直接用 CreateFile。', - '- FindFiles 用于按 glob 模式递归查找文件路径,例如 **/*.md、chapters/*.md、**/*来信*.md。它不会读取文件正文。', + '- FindFiles 用于按 glob 模式递归查找文件路径,例如 **/*.md、chapters/*.txt、**/*来信*.md。它不会读取文件正文。', '- RagSearch 用于从项目要素索引中语义检索相关设定。写新章节、续写、改稿、回答设定相关问题时,先用自然语言 query 检索;返回的 sourcePath 可用于后续 ReadFile 精读。', '- 可以连续使用多个工具完成任务。完成工具调用后,继续根据工具结果判断是否还需要下一步。', '- 完成任务后,用简短自然语言总结变更,不要重复输出整个文件。', diff --git a/packages/core/src/core/agent/tools.ts b/packages/core/src/core/agent/tools.ts index f43cba6..54aeee9 100644 --- a/packages/core/src/core/agent/tools.ts +++ b/packages/core/src/core/agent/tools.ts @@ -56,7 +56,7 @@ export function createAgentTools(): AgentRunnableToolMap { properties: { path: { type: 'string', - description: '项目内相对路径,例如 chapters/第001章.md', + description: '项目内相对路径,例如 chapters/第001章.txt', }, offset: { type: 'integer', @@ -134,17 +134,17 @@ export function createAgentTools(): AgentRunnableToolMap { type: 'function', function: { name: 'CreateFile', - description: '在当前小说项目中新建文本文件;中间目录会自动创建,目标已存在时会失败。已有文件请用 EditFile 修改。', + description: '在当前小说项目中新建文本文件;中间目录会自动创建,目标已存在时会失败。章节必须创建为 chapters/*.txt,要素和提示词使用 .md。已有文件请用 EditFile 修改。', parameters: { type: 'object', properties: { path: { type: 'string', - description: '项目内相对路径;父目录不存在时会自动创建。', + description: '项目内相对路径;父目录不存在时会自动创建。新建章节必须使用 chapters/*.txt,不能使用 chapters/*.md。', }, content: { type: 'string', - description: '新文件完整内容;不要用于覆盖已有文件。', + description: '新文件完整内容;章节正文应为纯文本,不要以 Markdown 标题符号 # 开头。', }, }, required: ['path', 'content'], @@ -175,7 +175,7 @@ export function createAgentTools(): AgentRunnableToolMap { }, toPath: { type: 'string', - description: '新的项目内相对路径;父目录不存在时会自动创建,目标文件不能已存在。', + description: '新的项目内相对路径;父目录不存在时会自动创建,目标文件不能已存在。移动到 chapters/ 下时必须使用 .txt。', }, }, required: ['fromPath', 'toPath'], @@ -268,7 +268,7 @@ export function createAgentTools(): AgentRunnableToolMap { properties: { pattern: { type: 'string', - description: '必填,glob 文件匹配模式,例如 **/*.md、chapters/*.md、**/*来信*.md。', + description: '必填,glob 文件匹配模式,例如 **/*.md、chapters/*.txt、**/*来信*.md。', }, path: { type: 'string', diff --git a/packages/core/src/core/chat/session.ts b/packages/core/src/core/chat/session.ts index d1659e7..8a3f498 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('.md')) { + if (node.kind === 'file' && node.path.startsWith('chapters/') && node.name.endsWith('.txt')) { paths.push(node.path) continue } @@ -808,7 +808,7 @@ function buildNextChapterPath(project: ChatTurnInput['project']) { }, 0) const nextSequence = String(maxSequence + 1).padStart(3, '0') - return `chapters/第${nextSequence}章-未命名章节.md` + return `chapters/第${nextSequence}章-未命名章节.txt` } function summarizeTurnMode(mode: TurnMode) { diff --git a/packages/core/src/core/elements/elements.test.ts b/packages/core/src/core/elements/elements.test.ts index 03da246..c83df9b 100644 --- a/packages/core/src/core/elements/elements.test.ts +++ b/packages/core/src/core/elements/elements.test.ts @@ -4,11 +4,11 @@ import { extractElementsFromChapter } from './extractor' import { createElementDocument, writeElementDocuments } from './writer' describe('elements', () => { - it('extracts usable element candidates from chapter markdown', async () => { + it('extracts usable element candidates from chapter content', async () => { const result = await extractElementsFromChapter({ - chapterPath: 'chapters/第001章.md', - chapterMarkdown: [ - '# 第一章 藏书楼', + chapterPath: 'chapters/第001章.txt', + chapterContent: [ + '第一章 藏书楼', '', '主角林远在深夜走进废弃藏书楼,发现一封来自十年前的信。', '林远低声问自己,那封旧王朝密信为什么会藏在这里。', @@ -29,8 +29,8 @@ describe('elements', () => { name: '林远', summary: '年轻修士,正在调查藏书楼旧信。', tags: ['人物', '主角'], - lastUpdatedChapter: 'chapters/第001章.md', - relatedChapters: ['chapters/第001章.md'], + lastUpdatedChapter: 'chapters/第001章.txt', + relatedChapters: ['chapters/第001章.txt'], body: '## 人物线索\n\n- 林远发现旧信。', }) diff --git a/packages/core/src/core/elements/extractor.ts b/packages/core/src/core/elements/extractor.ts index 6e57bc0..2bad880 100644 --- a/packages/core/src/core/elements/extractor.ts +++ b/packages/core/src/core/elements/extractor.ts @@ -14,12 +14,12 @@ const TYPE_BUCKET_MAP: Record = { const LOCATION_SUFFIXES = '楼阁院城镇村谷山峰林河湖海洞宫殿寺庙塔桥巷街路关岛洲府国界域' export async function extractElementsFromChapter(input: { - chapterMarkdown: string + chapterContent: string chapterPath?: string systemPrompt?: string }): Promise { - const markdown = normalizeMarkdown(input.chapterMarkdown) - const title = extractTitle(markdown, input.chapterPath) + const content = normalizeChapterContent(input.chapterContent) + const title = extractTitle(content, input.chapterPath) const chapterRef = input.chapterPath || title const result: ElementExtractionResult = { characters: [], @@ -29,29 +29,29 @@ export async function extractElementsFromChapter(input: { worldbuilding: [], } - for (const name of extractCharacterNames(markdown)) { + for (const name of extractCharacterNames(content)) { pushItem(result, 'character', { name, summary: `${name} 在「${title}」中出现,需要记录其当前状态与关系变化。`, tags: ['自动提取', '人物'], lastUpdatedChapter: chapterRef, relatedChapters: [chapterRef], - body: buildBody(markdown, name, '人物线索'), + body: buildBody(content, name, '人物线索'), }) } - for (const name of extractLocationNames(markdown)) { + for (const name of extractLocationNames(content)) { pushItem(result, 'location', { name, summary: `${name} 是「${title}」中出现的地点或场景。`, tags: ['自动提取', '地点'], lastUpdatedChapter: chapterRef, relatedChapters: [chapterRef], - body: buildBody(markdown, name, '地点线索'), + body: buildBody(content, name, '地点线索'), }) } - const plotSummary = summarizeChapter(markdown) + const plotSummary = summarizeChapter(content) if (plotSummary) { pushItem(result, 'plot', { name: title, @@ -59,7 +59,7 @@ export async function extractElementsFromChapter(input: { tags: ['自动提取', '情节'], lastUpdatedChapter: chapterRef, relatedChapters: [chapterRef], - body: `## 情节摘要\n\n${plotSummary}\n\n## 原文线索\n\n${excerpt(markdown)}`, + body: `## 情节摘要\n\n${plotSummary}\n\n## 原文线索\n\n${excerpt(content)}`, }) } @@ -69,17 +69,17 @@ export async function extractElementsFromChapter(input: { tags: ['自动提取', '时间线'], lastUpdatedChapter: chapterRef, relatedChapters: [chapterRef], - body: buildTimelineBody(markdown), + body: buildTimelineBody(content), }) - for (const name of extractWorldbuildingNames(markdown)) { + for (const name of extractWorldbuildingNames(content)) { pushItem(result, 'worldbuilding', { name, summary: `${name} 是「${title}」中出现的设定、物件或组织线索。`, tags: ['自动提取', '设定'], lastUpdatedChapter: chapterRef, relatedChapters: [chapterRef], - body: buildBody(markdown, name, '设定线索'), + body: buildBody(content, name, '设定线索'), }) } @@ -103,18 +103,18 @@ function pushItem( }) } -function normalizeMarkdown(markdown: string) { - return markdown.replace(/\r\n/g, '\n').trim() +function normalizeChapterContent(content: string) { + return content.replace(/\r\n/g, '\n').trim() } -function extractTitle(markdown: string, chapterPath?: string) { - const heading = markdown.match(/^#\s+(.+)$/m)?.[1]?.trim() +function extractTitle(content: string, chapterPath?: string) { + const heading = content.match(/^#\s+(.+)$/m)?.[1]?.trim() if (heading) { return heading } - const fileName = chapterPath?.split('/').pop()?.replace(/\.md$/i, '').trim() + const fileName = chapterPath?.split('/').pop()?.replace(/\.(md|txt)$/i, '').trim() return fileName || '未命名章节' } diff --git a/packages/core/src/core/fs/project-fs.ts b/packages/core/src/core/fs/project-fs.ts index 8eafad6..4c5c006 100644 --- a/packages/core/src/core/fs/project-fs.ts +++ b/packages/core/src/core/fs/project-fs.ts @@ -334,17 +334,17 @@ export async function writeSystemPrompt(rootHandle: FileSystemDirectoryHandle, c } /** - * 将生成结果保存为章节 Markdown 文件,并返回最终采用的文件名。 + * 将生成结果保存为章节文件,并返回最终采用的文件名。 * 如果调用方没有提供合法名称,这里会自动生成一个可落盘的默认值。 */ export async function writeChapterFile( rootHandle: FileSystemDirectoryHandle, fileName: string, - markdown: string, + content: string, ) { - // 测试页允许直接输入文件名,这里统一兜底成稳定的 .md 文件名。 + // 测试页允许直接输入文件名,这里统一兜底成稳定的 .txt 文件名。 const normalizedName = normalizeChapterFileName(fileName) - await writeText(rootHandle, `chapters/${normalizedName}`, markdown) + await writeText(rootHandle, `chapters/${normalizedName}`, content) return normalizedName } @@ -399,10 +399,10 @@ function normalizeChapterFileName(fileName: string) { `${now.getMinutes()}`.padStart(2, '0'), `${now.getSeconds()}`.padStart(2, '0'), ].join('') - return `chapter-${stamp}.md` + return `chapter-${stamp}.txt` } - return trimmed.endsWith('.md') ? trimmed : `${trimmed}.md` + return trimmed.endsWith('.txt') ? trimmed : `${trimmed.replace(/\.md$/i, '')}.txt` } async function scanDirectory( diff --git a/packages/core/src/core/project/defaults.ts b/packages/core/src/core/project/defaults.ts index 4498829..30e8b23 100644 --- a/packages/core/src/core/project/defaults.ts +++ b/packages/core/src/core/project/defaults.ts @@ -1,6 +1,13 @@ export const DEFAULT_SYSTEM_PROMPT = `# SYSTEM Prompt -在这里定义整部小说的基调、叙事视角、文风偏好与创作约束。` +在这里定义整部小说的基调、叙事视角、文风偏好与创作约束。 + +## 文件格式约定 +- 章节正文写入 chapters/*.txt,正文使用纯文本。 +- 章节文件第一行写章节标题,例如:第001章 章节标题。 +- 章节正文不要使用 Markdown 标题、列表、引用、分割线等格式符号。 +- 人物、地点、实体、情节、时间线、世界观等要素写入 elements/**/*.md。 +- SYSTEM 与 SCENE 提示词写入 prompts/**/*.md。` export const DEFAULT_SCENE_PROMPT = `# Scene Prompt diff --git a/packages/core/src/core/tools/file-tools/common.ts b/packages/core/src/core/tools/file-tools/common.ts index 09ff2f9..56379f8 100644 --- a/packages/core/src/core/tools/file-tools/common.ts +++ b/packages/core/src/core/tools/file-tools/common.ts @@ -1,4 +1,4 @@ -import { assertTextFilePath, normalizeProjectPath } from '../path' +import { assertTextFilePath, assertWritableTextFilePath, normalizeProjectPath } from '../path' export function asRecord(input: unknown) { if (!input || typeof input !== 'object') { @@ -24,9 +24,17 @@ export function assertMutableDocumentPath(path: string, label: string) { } } +export function assertWritableDocumentPath(path: string, label: string) { + assertWritableTextFilePath(path) + + if (path === 'novel.config.json' || path.startsWith('.novel/')) { + throw new Error(`${label} 不能指向项目配置或 .novel 内部文件`) + } +} + export function normalizeTextFilePath(value: unknown, label: string) { const path = normalizeProjectPath(readString(value, label)) - assertTextFilePath(path) + assertWritableTextFilePath(path) return path } diff --git a/packages/core/src/core/tools/file-tools/create-file.ts b/packages/core/src/core/tools/file-tools/create-file.ts index cd2f758..e875643 100644 --- a/packages/core/src/core/tools/file-tools/create-file.ts +++ b/packages/core/src/core/tools/file-tools/create-file.ts @@ -1,5 +1,5 @@ import { readProjectTextFile, writeProjectTextFile } from '../../fs/project-fs' -import { isNotFoundError } from '../path' +import { assertWritableTextFilePath, isNotFoundError } from '../path' import type { CreateFileInput, CreateFileOutput, ToolDefinition } from '../types' import { asRecord, countLines, normalizeTextFilePath, readString } from './common' @@ -17,6 +17,8 @@ export const createFileTool: ToolDefinition<'CreateFile', CreateFileInput, Creat } }, async run(input, runtime) { + assertWritableTextFilePath(input.path) + try { await readProjectTextFile(runtime.project.handle, input.path) throw new Error(`文件已存在:${input.path};CreateFile 只用于新建文件。修改已有文件请先用 ReadFile 读取原文,再使用 EditFile 精确替换`) diff --git a/packages/core/src/core/tools/file-tools/file-tools.test.ts b/packages/core/src/core/tools/file-tools/file-tools.test.ts index 6e21c73..e61174d 100644 --- a/packages/core/src/core/tools/file-tools/file-tools.test.ts +++ b/packages/core/src/core/tools/file-tools/file-tools.test.ts @@ -12,39 +12,39 @@ import type { ToolRuntime } from '../types' describe('file tools', () => { it('requires ReadFile state before EditFile and edits a fresh file', async () => { const runtime = createRuntime({ - 'chapters/001.md': '第一段\n第二段', + 'chapters/001.txt': '第一段\n第二段', }) await expect(editFileTool.run({ - path: 'chapters/001.md', + path: 'chapters/001.txt', oldText: '第二段', newText: '第二段修改', }, runtime)).rejects.toThrow('必须先用 ReadFile') - const readOutput = await readFileTool.run({ path: 'chapters/001.md' }, runtime) + const readOutput = await readFileTool.run({ path: 'chapters/001.txt' }, runtime) expect(readOutput.numberedContent).toContain('1 | 第一段') const editOutput = await editFileTool.run({ - path: 'chapters/001.md', + path: 'chapters/001.txt', oldText: '第二段', newText: '第二段修改', }, runtime) expect(editOutput.occurrences).toBe(1) - await expect(readProjectText(runtime.project.handle, 'chapters/001.md')).resolves.toBe('第一段\n第二段修改') + await expect(readProjectText(runtime.project.handle, 'chapters/001.txt')).resolves.toBe('第一段\n第二段修改') }) it('rejects EditFile when the file changed after ReadFile', async () => { const runtime = createRuntime({ - 'chapters/001.md': '旧内容', + 'chapters/001.txt': '旧内容', }) - await readFileTool.run({ path: 'chapters/001.md' }, runtime) - await writeProjectText(runtime.project.handle, 'chapters/001.md', '外部修改') + await readFileTool.run({ path: 'chapters/001.txt' }, runtime) + await writeProjectText(runtime.project.handle, 'chapters/001.txt', '外部修改') await expect(editFileTool.run({ - path: 'chapters/001.md', + path: 'chapters/001.txt', oldText: '旧内容', newText: '新内容', }, runtime)).rejects.toThrow('已在 ReadFile 之后发生变化') @@ -52,51 +52,79 @@ describe('file tools', () => { it('creates new files but refuses to overwrite existing files', async () => { const runtime = createRuntime({ - 'chapters/existing.md': '已有内容', + 'chapters/existing.txt': '已有内容', }) await createFileTool.run({ - path: 'chapters/new.md', + path: 'chapters/new.txt', content: '新内容', }, runtime) - await expect(readProjectText(runtime.project.handle, 'chapters/new.md')).resolves.toBe('新内容') + await expect(readProjectText(runtime.project.handle, 'chapters/new.txt')).resolves.toBe('新内容') await expect(createFileTool.run({ - path: 'chapters/existing.md', + path: 'chapters/existing.txt', content: '覆盖内容', }, runtime)).rejects.toThrow('文件已存在') }) + it('requires new chapter files to use txt extension', async () => { + const runtime = createRuntime({}) + + await expect(createFileTool.run({ + path: 'chapters/new.md', + content: '新内容', + }, runtime)).rejects.toThrow('章节正文必须写入 chapters/*.txt') + }) + it('renames files and refuses to overwrite destination files', async () => { const runtime = createRuntime({ - 'chapters/source.md': '源内容', - 'chapters/existing.md': '已有内容', + 'chapters/source.txt': '源内容', + 'chapters/existing.txt': '已有内容', }) await expect(renameFileTool.run({ - fromPath: 'chapters/source.md', - toPath: 'chapters/existing.md', + fromPath: 'chapters/source.txt', + toPath: 'chapters/existing.txt', }, runtime)).rejects.toThrow('目标文件已存在') await renameFileTool.run({ - fromPath: 'chapters/source.md', - toPath: 'chapters/renamed.md', + fromPath: 'chapters/source.txt', + toPath: 'chapters/renamed.txt', + }, runtime) + + await expect(readProjectText(runtime.project.handle, 'chapters/renamed.txt')).resolves.toBe('源内容') + await expect(readProjectText(runtime.project.handle, 'chapters/source.txt')).rejects.toThrow('Not found') + }) + + it('allows migrating legacy chapter md files to txt but rejects new md targets', async () => { + const runtime = createRuntime({ + 'chapters/legacy.md': '旧章节', + }) + + await expect(renameFileTool.run({ + fromPath: 'chapters/legacy.md', + toPath: 'chapters/still-md.md', + }, runtime)).rejects.toThrow('章节正文必须写入 chapters/*.txt') + + await renameFileTool.run({ + fromPath: 'chapters/legacy.md', + toPath: 'chapters/legacy.txt', }, runtime) - await expect(readProjectText(runtime.project.handle, 'chapters/renamed.md')).resolves.toBe('源内容') - await expect(readProjectText(runtime.project.handle, 'chapters/source.md')).rejects.toThrow('Not found') + await expect(readProjectText(runtime.project.handle, 'chapters/legacy.txt')).resolves.toBe('旧章节') + await expect(readProjectText(runtime.project.handle, 'chapters/legacy.md')).rejects.toThrow('Not found') }) it('moves deleted files into the project trash', async () => { const runtime = createRuntime({ - 'chapters/old.md': '废稿', + 'chapters/old.txt': '废稿', }) - const output = await deleteFileTool.run({ path: 'chapters/old.md' }, runtime) + const output = await deleteFileTool.run({ path: 'chapters/old.txt' }, runtime) - expect(output.trashPath).toMatch(/^\.novel\/trash\/.+\/chapters\/old\.md$/) + expect(output.trashPath).toMatch(/^\.novel\/trash\/.+\/chapters\/old\.txt$/) await expect(readProjectText(runtime.project.handle, output.trashPath)).resolves.toBe('废稿') - await expect(readProjectText(runtime.project.handle, 'chapters/old.md')).rejects.toThrow('Not found') + await expect(readProjectText(runtime.project.handle, 'chapters/old.txt')).rejects.toThrow('Not found') }) }) diff --git a/packages/core/src/core/tools/file-tools/rename-file.ts b/packages/core/src/core/tools/file-tools/rename-file.ts index eb98d93..e3892e8 100644 --- a/packages/core/src/core/tools/file-tools/rename-file.ts +++ b/packages/core/src/core/tools/file-tools/rename-file.ts @@ -1,7 +1,7 @@ import { moveProjectTextFile, readProjectTextFile } from '../../fs/project-fs' -import { isNotFoundError, normalizeProjectPath } from '../path' +import { assertWritableTextFilePath, isNotFoundError, normalizeProjectPath } from '../path' import type { RenameFileInput, RenameFileOutput, ToolDefinition } from '../types' -import { asRecord, assertMutableDocumentPath, readString } from './common' +import { asRecord, assertMutableDocumentPath, assertWritableDocumentPath, readString } from './common' export const renameFileTool: ToolDefinition<'RenameFile', RenameFileInput, RenameFileOutput> = { name: 'RenameFile', @@ -12,7 +12,7 @@ export const renameFileTool: ToolDefinition<'RenameFile', RenameFileInput, Renam const toPath = normalizeProjectPath(readString(value.toPath, 'RenameFile.toPath')) assertMutableDocumentPath(fromPath, 'RenameFile.fromPath') - assertMutableDocumentPath(toPath, 'RenameFile.toPath') + assertWritableDocumentPath(toPath, 'RenameFile.toPath') if (fromPath === toPath) { throw new Error('RenameFile.fromPath 和 RenameFile.toPath 不能相同') @@ -24,6 +24,8 @@ export const renameFileTool: ToolDefinition<'RenameFile', RenameFileInput, Renam } }, async run(input, runtime) { + assertWritableTextFilePath(input.toPath) + const content = await readProjectTextFile(runtime.project.handle, input.fromPath) try { diff --git a/packages/core/src/core/tools/path.ts b/packages/core/src/core/tools/path.ts index ae03d62..fe2a38b 100644 --- a/packages/core/src/core/tools/path.ts +++ b/packages/core/src/core/tools/path.ts @@ -54,6 +54,14 @@ export function assertTextFilePath(path: string): void { } } +export function assertWritableTextFilePath(path: string): void { + assertTextFilePath(path) + + if (path.startsWith('chapters/') && !path.toLowerCase().endsWith('.txt')) { + throw new Error('章节正文必须写入 chapters/*.txt;要素和提示词才使用 .md') + } +} + export function isNotFoundError(error: unknown): boolean { return error instanceof DOMException && error.name === 'NotFoundError' } diff --git a/packages/core/src/services/element-service.ts b/packages/core/src/services/element-service.ts index 46ec49c..be71c0e 100644 --- a/packages/core/src/services/element-service.ts +++ b/packages/core/src/services/element-service.ts @@ -6,7 +6,7 @@ import { requireRuntimeProject } from './project-runtime' import type { ElementExtractionResultView, ElementWriteResultView } from './types' export async function previewElementExtraction(input: { - chapterMarkdown: string + chapterContent: string chapterPath?: string systemPrompt?: string }): Promise { diff --git a/packages/core/src/services/file-service.ts b/packages/core/src/services/file-service.ts index 8809ac9..e50ba01 100644 --- a/packages/core/src/services/file-service.ts +++ b/packages/core/src/services/file-service.ts @@ -43,9 +43,9 @@ export async function refreshFiles(projectId: string): Promise { const project = requireRuntimeProject(projectId) - const savedName = await writeChapterFile(project.handle, fileName, markdown) + const savedName = await writeChapterFile(project.handle, fileName, content) return toFileContentView(await readProjectFile(project, `chapters/${savedName}`)) }