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
4 changes: 2 additions & 2 deletions packages/app/src/components/layout/ContentPanel.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
)
})
Expand Down Expand Up @@ -88,7 +88,7 @@ async function handlePreviewElements() {

try {
extractionPreview.value = await previewElementExtraction({
chapterMarkdown: props.file.content,
chapterContent: props.file.content,
chapterPath: props.file.path,
})

Expand Down
2 changes: 1 addition & 1 deletion packages/app/src/stores/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
10 changes: 5 additions & 5 deletions packages/app/src/views/TestLabView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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 连接。')
Expand Down Expand Up @@ -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,
})
Expand Down
5 changes: 4 additions & 1 deletion packages/core/src/core/agent/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ export function buildAgentSystemPrompt(customPrompt?: string) {
'- 对已有文件动手前,先使用 ReadFile 读取相关内容。',
'- 修改已有文件时,优先使用 EditFile 做精确替换;必须先用 ReadFile 读取原文,不要在没有读过原文时盲目改写。',
'- 新建章节、设定或提示词文件时,使用 CreateFile;父目录会自动创建。',
'- 新建章节必须写入 chapters/*.txt,章节正文使用纯文本;第一行写章节标题,不要使用 Markdown 标题、列表、引用、分割线等格式符号。',
'- 要素文件和提示词文件继续使用 .md;不要把章节正文写成 .md 文件。',
'- 重命名或移动已有文件时,使用 RenameFile,不要用 CreateFile + DeleteFile 模拟。',
'- 删除文件时,必须确认用户意图明确,再使用 DeleteFile;它会把文件移入回收站,不会永久删除。',
'- 不确定某个目录的直接结构时,先使用 ListDirectory 查看目录,不要为了探索目录而批量读取文件。',
Expand All @@ -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 精读。',
'- 可以连续使用多个工具完成任务。完成工具调用后,继续根据工具结果判断是否还需要下一步。',
'- 完成任务后,用简短自然语言总结变更,不要重复输出整个文件。',
Expand Down
12 changes: 6 additions & 6 deletions packages/core/src/core/agent/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ export function createAgentTools(): AgentRunnableToolMap {
properties: {
path: {
type: 'string',
description: '项目内相对路径,例如 chapters/第001章.md',
description: '项目内相对路径,例如 chapters/第001章.txt',
},
offset: {
type: 'integer',
Expand Down Expand Up @@ -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'],
Expand Down Expand Up @@ -175,7 +175,7 @@ export function createAgentTools(): AgentRunnableToolMap {
},
toPath: {
type: 'string',
description: '新的项目内相对路径;父目录不存在时会自动创建,目标文件不能已存在。',
description: '新的项目内相对路径;父目录不存在时会自动创建,目标文件不能已存在。移动到 chapters/ 下时必须使用 .txt。',
},
},
required: ['fromPath', 'toPath'],
Expand Down Expand Up @@ -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',
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/core/chat/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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) {
Expand Down
12 changes: 6 additions & 6 deletions packages/core/src/core/elements/elements.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
'第一章 藏书楼',
'',
'主角林远在深夜走进废弃藏书楼,发现一封来自十年前的信。',
'林远低声问自己,那封旧王朝密信为什么会藏在这里。',
Expand All @@ -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- 林远发现旧信。',
})

Expand Down
34 changes: 17 additions & 17 deletions packages/core/src/core/elements/extractor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,12 @@ const TYPE_BUCKET_MAP: Record<ElementType, ExtractionBucket> = {
const LOCATION_SUFFIXES = '楼阁院城镇村谷山峰林河湖海洞宫殿寺庙塔桥巷街路关岛洲府国界域'

export async function extractElementsFromChapter(input: {
chapterMarkdown: string
chapterContent: string
chapterPath?: string
systemPrompt?: string
}): Promise<ElementExtractionResult> {
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: [],
Expand All @@ -29,37 +29,37 @@ 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,
summary: plotSummary,
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)}`,
})
}

Expand All @@ -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, '设定线索'),
})
}

Expand All @@ -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 || '未命名章节'
}

Expand Down
12 changes: 6 additions & 6 deletions packages/core/src/core/fs/project-fs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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(
Expand Down
9 changes: 8 additions & 1 deletion packages/core/src/core/project/defaults.ts
Original file line number Diff line number Diff line change
@@ -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

Expand Down
12 changes: 10 additions & 2 deletions packages/core/src/core/tools/file-tools/common.ts
Original file line number Diff line number Diff line change
@@ -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') {
Expand All @@ -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
}

Expand Down
4 changes: 3 additions & 1 deletion packages/core/src/core/tools/file-tools/create-file.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand All @@ -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 精确替换`)
Expand Down
Loading
Loading