diff --git a/packages/core/src/__tests__/architect.test.ts b/packages/core/src/__tests__/architect.test.ts index 666d9c7f..45d181ba 100644 --- a/packages/core/src/__tests__/architect.test.ts +++ b/packages/core/src/__tests__/architect.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { ArchitectAgent } from "../agents/architect.js"; import type { BookConfig } from "../models/book.js"; +import type { LLMClient } from "../llm/provider.js"; const ZERO_USAGE = { promptTokens: 0, @@ -705,4 +706,151 @@ describe("ArchitectAgent", () => { expect.objectContaining({ temperature: 0.7, maxTokens: 16384 }), ); }); + + // ---- Phase 5 段落式架构稿专项 ---- + + // 测试 stub:chat 会被 vi.spyOn 拦截,client.defaults 运行时不会被读取。 + // 故意不填 temperature / maxTokens 等数字——避免在测试里留下"推荐配置"的 + // 错误示范(maxTokens 填错会误导后续抄到生产,触发 CLAUDE.md 禁止的 + // maxTokens 回归)。只保留类型要求的身份字段。 + const buildPhase5Agent = (): ArchitectAgent => + new ArchitectAgent({ + client: { + provider: "openai", + apiFormat: "chat", + stream: false, + } as unknown as LLMClient, + model: "test-model", + projectRoot: process.cwd(), + }); + + const phase5Book = (): BookConfig => ({ + id: "phase5-book", + title: "测试书", + platform: "qidian", + genre: "xuanhuan", + status: "active", + targetChapters: 50, + chapterWordCount: 3000, + language: "zh", + createdAt: "2026-04-19T00:00:00.000Z", + updatedAt: "2026-04-19T00:00:00.000Z", + }); + + it("generateFoundation parses story_frame / volume_map / roles sections", async () => { + const agent = buildPhase5Agent(); + const book = phase5Book(); + + vi.spyOn(agent as unknown as { chat: (...args: unknown[]) => Promise }, "chat") + .mockResolvedValue({ + content: [ + "=== SECTION: story_frame ===", + "## 主题与基调", + "段落 1 主题段落。", + "", + "## 核心冲突", + "段落 2 冲突段落。", + "", + "=== SECTION: volume_map ===", + "## 段 1", + "卷一段落。", + "", + "=== SECTION: roles ===", + "---ROLE---", + "tier: major", + "name: 林辞", + "---CONTENT---", + "## 核心标签", + "冷静、执着", + "", + "---ROLE---", + "tier: minor", + "name: 配角A", + "---CONTENT---", + "次要角色描写", + "", + "=== SECTION: book_rules ===", + "---", + "version: \"1.0\"", + "protagonist:", + " name: 林辞", + "---", + "", + "=== SECTION: pending_hooks ===", + "| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 回收节奏 | 备注 |", + "|---|---|---|---|---|---|---|---|", + "| H001 | 1 | 主线 | open | 0 | 3 | 近期 | 初始线索 |", + ].join("\n"), + usage: ZERO_USAGE, + }); + + const output = await agent.generateFoundation(book); + + expect(output.storyFrame).toContain("主题与基调"); + expect(output.volumeMap).toContain("段 1"); + expect(output.roles).toBeDefined(); + expect(output.roles!.length).toBe(2); + expect(output.roles![0]).toMatchObject({ tier: "major", name: "林辞" }); + expect(output.roles![1]).toMatchObject({ tier: "minor", name: "配角A" }); + }); + + it("writeFoundationFiles writes outline/ and roles/ when Phase 5 fields present", async () => { + const { mkdtemp, rm, access } = await import("node:fs/promises"); + const { tmpdir } = await import("node:os"); + const { join } = await import("node:path"); + + const agent = buildPhase5Agent(); + const tmpDir = await mkdtemp(join(tmpdir(), "inkos-arch-test-")); + try { + await agent.writeFoundationFiles(tmpDir, { + storyBible: "legacy shim body", + volumeOutline: "legacy outline", + bookRules: "---\nversion: \"1.0\"\n---\n", + currentState: "", + pendingHooks: "| hook_id |", + storyFrame: "## 主题\n\n段落内容", + volumeMap: "## 卷一\n\n卷一段落", + roles: [ + { tier: "major", name: "林辞", content: "主角描写" }, + { tier: "minor", name: "配角A", content: "配角描写" }, + ], + }, false, "zh"); + + await expect(access(join(tmpDir, "story", "outline", "story_frame.md"))).resolves.not.toThrow(); + await expect(access(join(tmpDir, "story", "outline", "volume_map.md"))).resolves.not.toThrow(); + await expect(access(join(tmpDir, "story", "roles", "主要角色", "林辞.md"))).resolves.not.toThrow(); + await expect(access(join(tmpDir, "story", "roles", "次要角色", "配角A.md"))).resolves.not.toThrow(); + // Shim 文件也要在(向后兼容读取点用) + await expect(access(join(tmpDir, "story", "story_bible.md"))).resolves.not.toThrow(); + await expect(access(join(tmpDir, "story", "character_matrix.md"))).resolves.not.toThrow(); + await expect(access(join(tmpDir, "story", "book_rules.md"))).resolves.not.toThrow(); + } finally { + await rm(tmpDir, { recursive: true, force: true }); + } + }); + + it("writeFoundationFiles falls back to legacy layout when storyFrame is empty", async () => { + const { mkdtemp, rm, access, readFile } = await import("node:fs/promises"); + const { tmpdir } = await import("node:os"); + const { join } = await import("node:path"); + + const agent = buildPhase5Agent(); + const tmpDir = await mkdtemp(join(tmpdir(), "inkos-arch-legacy-test-")); + try { + await agent.writeFoundationFiles(tmpDir, { + storyBible: "# Legacy Story Bible\n", + volumeOutline: "# Legacy Volume Outline\n", + bookRules: "# Legacy Book Rules\n", + currentState: "# Current State\n", + pendingHooks: "| hook_id |\n", + }, false, "zh"); + + const storyBible = await readFile(join(tmpDir, "story", "story_bible.md"), "utf-8"); + expect(storyBible).toContain("Legacy Story Bible"); + // outline/ 目录是创建的但里面没 story_frame.md + await expect(access(join(tmpDir, "story", "outline", "story_frame.md"))).rejects.toThrow(); + } finally { + await rm(tmpDir, { recursive: true, force: true }); + } + }); }); diff --git a/packages/core/src/__tests__/context-transform.test.ts b/packages/core/src/__tests__/context-transform.test.ts index 1a9211f7..9e92a017 100644 --- a/packages/core/src/__tests__/context-transform.test.ts +++ b/packages/core/src/__tests__/context-transform.test.ts @@ -79,4 +79,30 @@ describe("createBookContextTransform", () => { const result = await transform(original); expect(result).toBe(original); }); + + it("injects upgrade hint when book is legacy layout (no outline/story_frame.md)", async () => { + const transform = createBookContextTransform(bookId, projectRoot); + const result = await transform([ + { role: "user" as const, content: "写下一章", timestamp: Date.now() }, + ]); + + const injected = result[0] as { role: string; content: string }; + expect(injected.content).toContain("旧的条目式格式"); + expect(injected.content).toContain("sub_agent(architect, { revise: true"); + }); + + it("does NOT inject upgrade hint when book is Phase 5 layout", async () => { + const outlineDir = join(projectRoot, "books", bookId, "story", "outline"); + await mkdir(outlineDir, { recursive: true }); + await writeFile(join(outlineDir, "story_frame.md"), "## 主题\n段落式内容"); + + const transform = createBookContextTransform(bookId, projectRoot); + const result = await transform([ + { role: "user" as const, content: "写下一章", timestamp: Date.now() }, + ]); + + const injected = result[0] as { role: string; content: string }; + expect(injected.content).not.toContain("旧的条目式格式"); + expect(injected.content).not.toContain("revise: true"); + }); }); diff --git a/packages/core/src/__tests__/outline-paths.test.ts b/packages/core/src/__tests__/outline-paths.test.ts new file mode 100644 index 00000000..c6d99254 --- /dev/null +++ b/packages/core/src/__tests__/outline-paths.test.ts @@ -0,0 +1,151 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtemp, writeFile, mkdir, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + readStoryFrame, + readVolumeMap, + readRoleCards, + readCharacterContext, + readCurrentStateWithFallback, + isNewLayoutBook, + isCurrentStateSeedPlaceholder, +} from "../utils/outline-paths.js"; + +describe("outline-paths", () => { + let bookDir: string; + + beforeEach(async () => { + bookDir = await mkdtemp(join(tmpdir(), "inkos-test-book-")); + await mkdir(join(bookDir, "story"), { recursive: true }); + }); + + afterEach(async () => { + await rm(bookDir, { recursive: true, force: true }); + }); + + // --- isNewLayoutBook --- + + it("isNewLayoutBook returns false when outline/story_frame.md is missing", async () => { + expect(await isNewLayoutBook(bookDir)).toBe(false); + }); + + it("isNewLayoutBook returns true when outline/story_frame.md exists", async () => { + await mkdir(join(bookDir, "story", "outline"), { recursive: true }); + await writeFile(join(bookDir, "story", "outline", "story_frame.md"), "# frame", "utf-8"); + expect(await isNewLayoutBook(bookDir)).toBe(true); + }); + + // --- readStoryFrame --- + + it("readStoryFrame prefers new path when present", async () => { + await mkdir(join(bookDir, "story", "outline"), { recursive: true }); + await writeFile(join(bookDir, "story", "outline", "story_frame.md"), "段落式内容", "utf-8"); + await writeFile(join(bookDir, "story", "story_bible.md"), "legacy content", "utf-8"); + expect(await readStoryFrame(bookDir)).toBe("段落式内容"); + }); + + it("readStoryFrame falls back to legacy story_bible.md when new path missing", async () => { + await writeFile(join(bookDir, "story", "story_bible.md"), "legacy 条目式", "utf-8"); + expect(await readStoryFrame(bookDir)).toBe("legacy 条目式"); + }); + + it("readStoryFrame returns fallback placeholder when both paths missing", async () => { + expect(await readStoryFrame(bookDir, "(未创建)")).toBe("(未创建)"); + }); + + // --- readVolumeMap --- + + it("readVolumeMap falls back to legacy volume_outline.md", async () => { + await writeFile(join(bookDir, "story", "volume_outline.md"), "legacy 卷大纲", "utf-8"); + expect(await readVolumeMap(bookDir)).toBe("legacy 卷大纲"); + }); + + it("readVolumeMap prefers new path", async () => { + await mkdir(join(bookDir, "story", "outline"), { recursive: true }); + await writeFile(join(bookDir, "story", "outline", "volume_map.md"), "段落式卷大纲", "utf-8"); + await writeFile(join(bookDir, "story", "volume_outline.md"), "legacy", "utf-8"); + expect(await readVolumeMap(bookDir)).toBe("段落式卷大纲"); + }); + + // --- readRoleCards --- + + it("readRoleCards returns empty when roles/ dir missing", async () => { + expect(await readRoleCards(bookDir)).toEqual([]); + }); + + it("readRoleCards reads major/minor role files", async () => { + const majorDir = join(bookDir, "story", "roles", "主要角色"); + const minorDir = join(bookDir, "story", "roles", "次要角色"); + await mkdir(majorDir, { recursive: true }); + await mkdir(minorDir, { recursive: true }); + await writeFile(join(majorDir, "林辞.md"), "主角内容", "utf-8"); + await writeFile(join(minorDir, "配角A.md"), "配角内容", "utf-8"); + const cards = await readRoleCards(bookDir); + expect(cards.length).toBe(2); + const major = cards.find((c) => c.name === "林辞"); + const minor = cards.find((c) => c.name === "配角A"); + expect(major).toMatchObject({ tier: "major", name: "林辞", content: "主角内容" }); + expect(minor).toMatchObject({ tier: "minor", name: "配角A", content: "配角内容" }); + }); + + // --- readCharacterContext --- + + it("readCharacterContext renders role cards when present", async () => { + const majorDir = join(bookDir, "story", "roles", "主要角色"); + await mkdir(majorDir, { recursive: true }); + await writeFile(join(majorDir, "林辞.md"), "林辞的描写", "utf-8"); + const result = await readCharacterContext(bookDir); + expect(result).toContain("林辞"); + expect(result).toContain("主要角色"); + }); + + it("readCharacterContext falls back to legacy character_matrix.md", async () => { + await writeFile(join(bookDir, "story", "character_matrix.md"), "legacy 角色矩阵", "utf-8"); + const result = await readCharacterContext(bookDir); + expect(result).toBe("legacy 角色矩阵"); + }); + + // --- readCurrentStateWithFallback --- + + it("readCurrentStateWithFallback returns real content when not a seed", async () => { + await writeFile( + join(bookDir, "story", "current_state.md"), + "# 当前状态\n\n林辞已经到达京城,正在调查失踪案。", + "utf-8", + ); + const result = await readCurrentStateWithFallback(bookDir); + expect(result).toContain("林辞已经到达京城"); + }); + + it("readCurrentStateWithFallback derives initial state from roles when seed placeholder", async () => { + await writeFile( + join(bookDir, "story", "current_state.md"), + "建书时占位,运行时由 consolidator 每章追加。", + "utf-8", + ); + const majorDir = join(bookDir, "story", "roles", "主要角色"); + await mkdir(majorDir, { recursive: true }); + await writeFile( + join(majorDir, "林辞.md"), + "## 当前现状\n\n刚从边关回到京城,尚未与旧友重逢。", + "utf-8", + ); + const result = await readCurrentStateWithFallback(bookDir); + expect(result).toContain("林辞"); + expect(result).toContain("初始状态"); + }); + + // --- isCurrentStateSeedPlaceholder --- + + it("isCurrentStateSeedPlaceholder returns true for empty or seed text", () => { + expect(isCurrentStateSeedPlaceholder("")).toBe(true); + expect(isCurrentStateSeedPlaceholder(" ")).toBe(true); + expect(isCurrentStateSeedPlaceholder("建书时占位,运行时追加")).toBe(true); + expect(isCurrentStateSeedPlaceholder("Seeded at book creation")).toBe(true); + }); + + it("isCurrentStateSeedPlaceholder returns false for real content", () => { + expect(isCurrentStateSeedPlaceholder("# 状态\n\n林辞已经出发,目标是西域。这是一段很长的内容。".repeat(10))).toBe(false); + }); +}); diff --git a/packages/core/src/__tests__/provider.test.ts b/packages/core/src/__tests__/provider.test.ts index 1c385638..144612dd 100644 --- a/packages/core/src/__tests__/provider.test.ts +++ b/packages/core/src/__tests__/provider.test.ts @@ -484,3 +484,109 @@ describe("chatCompletion fixed-temperature clamp (thinking models)", () => { warn.mockRestore(); }); }); + +// ── 回归测试:per-call maxTokens 不能被 config.maxTokens 误封顶 ───────────── +// +// 背景 / bug 成因:LLMConfigSchema.maxTokens 有 zod default 8192,曾经 +// createLLMClient 里 `maxTokensCap: config.maxTokens ?? null` 的实现会让 cap +// 永远等于 config.maxTokens。architect 的 per-call 16384 会被 Math.min(16384, +// 8192) 裁到 8192,基础设定输出被截断——这是 CLAUDE.md 禁止的 maxTokens 回归。 +// +// 修复后 maxTokens 和 maxTokensCap 是两个独立字段: +// - maxTokens: agent 没传 per-call 时的 fallback +// - maxTokensCap: per-call 的硬上限,默认 null(不封顶) +// +// 如果后续有人把两个字段的语义合回去、或者把 cap 默认改成非 null,这组回归 +// 测试会立刻挂掉。 + +describe("createLLMClient maxTokensCap regression", () => { + it("setting config.maxTokens alone leaves defaults.maxTokensCap null (no cap)", async () => { + const { createLLMClient } = await import("../llm/provider.js"); + const { LLMConfigSchema } = await import("../models/project.js"); + + const client = createLLMClient(LLMConfigSchema.parse({ + provider: "openai", + baseUrl: "http://localhost:0", + model: "test-model", + maxTokens: 8192, // 用户配了 fallback,但没有显式要求封顶 + })); + + expect(client.defaults.maxTokens).toBe(8192); + expect(client.defaults.maxTokensCap).toBeNull(); + }); + + it("setting config.maxTokensCap flips cap on", async () => { + const { createLLMClient } = await import("../llm/provider.js"); + const { LLMConfigSchema } = await import("../models/project.js"); + + const client = createLLMClient(LLMConfigSchema.parse({ + provider: "openai", + baseUrl: "http://localhost:0", + model: "test-model", + maxTokens: 8192, + maxTokensCap: 4096, // 显式要求封顶 + })); + + expect(client.defaults.maxTokens).toBe(8192); + expect(client.defaults.maxTokensCap).toBe(4096); + }); + + it("defaults (no config keys) leave cap null", async () => { + const { createLLMClient } = await import("../llm/provider.js"); + const { LLMConfigSchema } = await import("../models/project.js"); + + const client = createLLMClient(LLMConfigSchema.parse({ + provider: "openai", + baseUrl: "http://localhost:0", + model: "test-model", + })); + + expect(client.defaults.maxTokensCap).toBeNull(); + }); + + it("per-call maxTokens 16384 reaches the API when config.maxTokens is 8192", async () => { + const { createLLMClient } = await import("../llm/provider.js"); + const { LLMConfigSchema } = await import("../models/project.js"); + + const client = createLLMClient(LLMConfigSchema.parse({ + provider: "openai", + baseUrl: "http://localhost:0", + model: "test-model", + maxTokens: 8192, + })); + + mockStreamSimple.mockReset(); + mockStreamSimple.mockReturnValue(makeTextStream("ok")); + + await chatCompletion(client, "test-model", [ + { role: "user", content: "architect" }, + ], { maxTokens: 16384 }); + + const opts = mockStreamSimple.mock.calls[0]?.[2] as Record; + // 16384 必须原样传到下游,不能被 config.maxTokens=8192 裁成 8192 + expect(opts.maxTokens).toBe(16384); + }); + + it("per-call maxTokens is capped when config.maxTokensCap is set explicitly", async () => { + const { createLLMClient } = await import("../llm/provider.js"); + const { LLMConfigSchema } = await import("../models/project.js"); + + const client = createLLMClient(LLMConfigSchema.parse({ + provider: "openai", + baseUrl: "http://localhost:0", + model: "test-model", + maxTokens: 8192, + maxTokensCap: 4096, // 用户确实要硬上限 + })); + + mockStreamSimple.mockReset(); + mockStreamSimple.mockReturnValue(makeTextStream("ok")); + + await chatCompletion(client, "test-model", [ + { role: "user", content: "test" }, + ], { maxTokens: 16384 }); + + const opts = mockStreamSimple.mock.calls[0]?.[2] as Record; + expect(opts.maxTokens).toBe(4096); + }); +}); diff --git a/packages/core/src/__tests__/revise-foundation.test.ts b/packages/core/src/__tests__/revise-foundation.test.ts new file mode 100644 index 00000000..7747aff4 --- /dev/null +++ b/packages/core/src/__tests__/revise-foundation.test.ts @@ -0,0 +1,445 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { ArchitectAgent } from "../agents/architect.js"; +import { FoundationReviewerAgent } from "../agents/foundation-reviewer.js"; +import type { ArchitectOutput } from "../agents/architect.js"; +import type { BookConfig } from "../models/book.js"; +import type { LLMClient } from "../llm/provider.js"; + +// 测试 stub:chat 会被 vi.spyOn 拦截,client.defaults 运行时根本不会被读取。 +// 故意不填 temperature / maxTokens 等数字,避免在测试里留下"推荐配置"的错误 +// 示范(尤其 maxTokens —— 填错会误导后续抄到生产,触发 CLAUDE.md 禁止的 +// maxTokens 回归)。只保留类型要求的身份字段。 +const TEST_CLIENT: LLMClient = { + provider: "openai", + apiFormat: "chat", + stream: false, +} as unknown as LLMClient; + +const buildArchitect = (): ArchitectAgent => + new ArchitectAgent({ + client: TEST_CLIENT, + model: "test-model", + projectRoot: process.cwd(), + }); + +const testBook = (): BookConfig => ({ + id: "test-book", title: "测试书", platform: "qidian", genre: "xuanhuan", + status: "active", targetChapters: 50, chapterWordCount: 3000, language: "zh", + createdAt: "2026-04-19T00:00:00.000Z", updatedAt: "2026-04-19T00:00:00.000Z", +}); + +describe("architect generateFoundation with reviseFrom option", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("injects legacy content into the system prompt when reviseFrom is supplied", async () => { + const agent = buildArchitect(); + const chatSpy = vi.spyOn(agent as unknown as { chat: (...args: unknown[]) => Promise }, "chat") + .mockResolvedValue({ + content: [ + "=== SECTION: story_frame ===", + "## 主题", + "新段落主题", + "", + "=== SECTION: volume_map ===", + "## 段 1", + "新卷一", + "", + "=== SECTION: roles ===", + "---ROLE---", + "tier: major", + "name: 林辞", + "---CONTENT---", + "主角", + "", + "=== SECTION: book_rules ===", + "---", + "version: \"1.0\"", + "---", + "", + "=== SECTION: pending_hooks ===", + "| hook_id |", + ].join("\n"), + usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 }, + }); + + await agent.generateFoundation(testBook(), undefined, undefined, { + reviseFrom: { + storyBible: "- 旧世界观:架空唐代\n- 旧主角:林辞", + volumeOutline: "## 第一卷\n- 1. 主角登场", + bookRules: "## 规则\n- 禁现代词", + characterMatrix: "林辞 - 主角", + userFeedback: "升级到段落式架构稿", + }, + }); + + const systemMsg = (chatSpy.mock.calls[0]?.[0] as Array<{ role: string; content: string }>)[0]!; + expect(systemMsg.content).toContain("把一本已有书的架构稿从条目式升级"); + expect(systemMsg.content).toContain("旧世界观:架空唐代"); + expect(systemMsg.content).toContain("升级到段落式架构稿"); + }); + + it("does not inject revisePrompt when reviseFrom is absent", async () => { + const agent = buildArchitect(); + const chatSpy = vi.spyOn(agent as unknown as { chat: (...args: unknown[]) => Promise }, "chat") + .mockResolvedValue({ + content: [ + "=== SECTION: story_frame ===", "## 主题", "段落", + "=== SECTION: volume_map ===", "## 段 1", "卷一", + "=== SECTION: roles ===", "---ROLE---", "tier: major", "name: X", "---CONTENT---", "主角", + "=== SECTION: book_rules ===", "---", "version: \"1.0\"", "---", + "=== SECTION: pending_hooks ===", "| hook_id |", + ].join("\n"), + usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 }, + }); + + await agent.generateFoundation(testBook()); + + const systemMsg = (chatSpy.mock.calls[0]?.[0] as Array<{ role: string; content: string }>)[0]!; + expect(systemMsg.content).not.toContain("把一本已有书的架构稿从条目式升级"); + }); +}); + +describe("pipeline.reviseFoundation", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("backs up legacy files and writes Phase 5 output", async () => { + const { mkdtemp, writeFile, mkdir, rm, access, readdir } = await import("node:fs/promises"); + const { tmpdir } = await import("node:os"); + const { join } = await import("node:path"); + const { PipelineRunner } = await import("../pipeline/runner.js"); + const { StateManager } = await import("../state/manager.js"); + + const root = await mkdtemp(join(tmpdir(), "inkos-revise-e2e-")); + const bookDir = join(root, "books", "legacy-book"); + + try { + // Construct a 旧书 on disk with 4 legacy files + await mkdir(join(bookDir, "story"), { recursive: true }); + await writeFile(join(bookDir, "story", "story_bible.md"), "# 旧书架构稿\n\n- 架空唐代\n- 主角林辞", "utf-8"); + await writeFile(join(bookDir, "story", "volume_outline.md"), "## 第一卷\n- 主角登场", "utf-8"); + await writeFile(join(bookDir, "story", "book_rules.md"), "## 规则\n- 禁现代词", "utf-8"); + await writeFile(join(bookDir, "story", "character_matrix.md"), "## 角色\n林辞 - 主角", "utf-8"); + await writeFile(join(bookDir, "book.json"), JSON.stringify({ + id: "legacy-book", title: "旧书", platform: "qidian", genre: "xuanhuan", + status: "active", targetChapters: 50, chapterWordCount: 3000, language: "zh", + createdAt: "2026-04-01T00:00:00.000Z", updatedAt: "2026-04-01T00:00:00.000Z", + }), "utf-8"); + + // Stub architect.generateFoundation → Phase 5 output + const mockFoundation: ArchitectOutput = { + storyBible: "(shim)", + volumeOutline: "(shim)", + bookRules: "---\nversion: \"1.0\"\n---\n", + currentState: "", + pendingHooks: "| hook_id |", + storyFrame: "## 主题\n\n段落式主题", + volumeMap: "## 段 1\n\n卷一段落", + roles: [{ tier: "major", name: "林辞", content: "主角段落描写" }], + }; + vi.spyOn(ArchitectAgent.prototype, "generateFoundation").mockResolvedValue(mockFoundation); + vi.spyOn(FoundationReviewerAgent.prototype, "review").mockResolvedValue({ + passed: true, totalScore: 90, dimensions: [], overallFeedback: "ok", + } as unknown as Awaited>); + + // Minimal config for PipelineRunner — 共用 TEST_CLIENT 避免重复。 + const state = new StateManager(root); + const runner = new PipelineRunner({ + state, + projectRoot: root, + client: TEST_CLIENT, + model: "test-model", + } as unknown as ConstructorParameters[0]); + + await runner.reviseFoundation("legacy-book", "升级到段落式"); + + // New files created + await expect(access(join(bookDir, "story", "outline", "story_frame.md"))).resolves.not.toThrow(); + await expect(access(join(bookDir, "story", "outline", "volume_map.md"))).resolves.not.toThrow(); + await expect(access(join(bookDir, "story", "roles", "主要角色", "林辞.md"))).resolves.not.toThrow(); + // Backup exists + const storyEntries = await readdir(join(bookDir, "story")); + const backupDir = storyEntries.find((e) => e.startsWith(".backup-phase4-")); + expect(backupDir).toBeDefined(); + await expect(access(join(bookDir, "story", backupDir!, "story_bible.md"))).resolves.not.toThrow(); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + // ---- Bug fix regression suite ---- + + it("revise 不重置运行时状态文件(current_state / pending_hooks / particle_ledger / subplot_board / emotional_arcs 保留章节累积)", async () => { + const { mkdtemp, writeFile, mkdir, rm, readFile } = await import("node:fs/promises"); + const { tmpdir } = await import("node:os"); + const { join } = await import("node:path"); + const { PipelineRunner } = await import("../pipeline/runner.js"); + const { StateManager } = await import("../state/manager.js"); + + const root = await mkdtemp(join(tmpdir(), "inkos-revise-runtime-")); + const bookDir = join(root, "books", "live-book"); + + try { + // 构造一本已经写过 N 章的 legacy 旧书——架构稿文件 + 运行时状态文件 + // 全都有"第 N 章累积后"的真实内容 + await mkdir(join(bookDir, "story"), { recursive: true }); + await writeFile(join(bookDir, "story", "story_bible.md"), "# 架构稿\n- 世界观", "utf-8"); + await writeFile(join(bookDir, "story", "volume_outline.md"), "## 卷一", "utf-8"); + await writeFile(join(bookDir, "story", "book_rules.md"), "## 规则", "utf-8"); + await writeFile(join(bookDir, "story", "character_matrix.md"), "## 角色", "utf-8"); + // 运行时状态(模拟 consolidator 累积了 20 章后的内容) + await writeFile(join(bookDir, "story", "current_state.md"), "# 当前状态\n\n第 20 章结束:主角在京城查失踪案。", "utf-8"); + await writeFile(join(bookDir, "story", "pending_hooks.md"), "| H001 | 1 | 主线 | open | 15 | ... 推进到 15 章 ... |", "utf-8"); + await writeFile(join(bookDir, "story", "particle_ledger.md"), "# 资源账本\n\n| 20 | 500 | 积累 | - | 10 | 510 | 第 20 章 |", "utf-8"); + await writeFile(join(bookDir, "story", "subplot_board.md"), "# 支线\n\n| S1 | 宫廷阴谋 | ... | 5 | 18 | 13 | active | 已推到 18 章 |", "utf-8"); + await writeFile(join(bookDir, "story", "emotional_arcs.md"), "# 情感弧线\n\n| 林辞 | 15 | 愤怒 | 发现背叛 | 8 | 上升 |", "utf-8"); + await writeFile(join(bookDir, "book.json"), JSON.stringify({ + id: "live-book", title: "写了 20 章的书", platform: "qidian", genre: "xuanhuan", + status: "active", targetChapters: 50, chapterWordCount: 3000, language: "zh", + createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-04-01T00:00:00.000Z", + }), "utf-8"); + + const mockFoundation: ArchitectOutput = { + storyBible: "(shim)", volumeOutline: "(shim)", + bookRules: "---\nversion: \"1.0\"\n---\n", + currentState: "", pendingHooks: "| hook_id | ...(新生成,不该被写)|", + storyFrame: "## 主题\n段落式", + volumeMap: "## 段 1\n卷一", + roles: [{ tier: "major", name: "林辞", content: "新卡" }], + }; + vi.spyOn(ArchitectAgent.prototype, "generateFoundation").mockResolvedValue(mockFoundation); + vi.spyOn(FoundationReviewerAgent.prototype, "review").mockResolvedValue({ + passed: true, totalScore: 90, dimensions: [], overallFeedback: "ok", + } as unknown as Awaited>); + + const state = new StateManager(root); + const runner = new PipelineRunner({ + state, projectRoot: root, client: TEST_CLIENT, model: "test-model", + } as unknown as ConstructorParameters[0]); + + await runner.reviseFoundation("live-book", "改下主角设定"); + + // 5 个运行时状态文件**必须保持原内容**(消费者看到的是"写了 20 章后"的状态, + // 不是重置为空模板或架构师新输出) + const currentState = await readFile(join(bookDir, "story", "current_state.md"), "utf-8"); + expect(currentState).toContain("第 20 章结束:主角在京城查失踪案"); + expect(currentState).not.toContain("建书时占位"); // init 模式的 seed 占位不该被写 + + const pendingHooks = await readFile(join(bookDir, "story", "pending_hooks.md"), "utf-8"); + expect(pendingHooks).toContain("推进到 15 章"); + expect(pendingHooks).not.toContain("(新生成,不该被写)"); + + const ledger = await readFile(join(bookDir, "story", "particle_ledger.md"), "utf-8"); + expect(ledger).toContain("第 20 章"); + expect(ledger).not.toContain("| 0 | 0 | 初始化 |"); // init 模式的初始模板 + + const subplot = await readFile(join(bookDir, "story", "subplot_board.md"), "utf-8"); + expect(subplot).toContain("已推到 18 章"); + + const emotional = await readFile(join(bookDir, "story", "emotional_arcs.md"), "utf-8"); + expect(emotional).toContain("发现背叛"); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it("Phase 5 书二次 revise 时从 outline/roles 权威源读(不把 shim 喂给 architect)", async () => { + const { mkdtemp, writeFile, mkdir, rm } = await import("node:fs/promises"); + const { tmpdir } = await import("node:os"); + const { join } = await import("node:path"); + const { PipelineRunner } = await import("../pipeline/runner.js"); + const { StateManager } = await import("../state/manager.js"); + + const root = await mkdtemp(join(tmpdir(), "inkos-revise-phase5-")); + const bookDir = join(root, "books", "phase5-book"); + + try { + // 构造一本已经是 Phase 5 的书 + await mkdir(join(bookDir, "story", "outline"), { recursive: true }); + await mkdir(join(bookDir, "story", "roles", "主要角色"), { recursive: true }); + await mkdir(join(bookDir, "story", "roles", "次要角色"), { recursive: true }); + // outline/ 是权威,内容完整 + await writeFile(join(bookDir, "story", "outline", "story_frame.md"), + "## 主题与基调\n完整的段落式世界观描写,包含所有设定细节、人物关系、剧情主线。" + "a".repeat(5000), + "utf-8"); + await writeFile(join(bookDir, "story", "outline", "volume_map.md"), + "## 段 1\n完整卷大纲描写。" + "b".repeat(5000), + "utf-8"); + // roles/ 是权威,内容完整 + await writeFile(join(bookDir, "story", "roles", "主要角色", "林辞.md"), + "## 核心标签\n冷静、执着\n\n## 主角线\n完整角色线,3000 字描写,反映复杂内在位移" + "c".repeat(3000), + "utf-8"); + // story_bible.md / character_matrix.md 是 shim(只有指针和摘录) + await writeFile(join(bookDir, "story", "story_bible.md"), + "# 故事圣经(已废弃)\n\n> 权威来源是 outline/story_frame.md\n\n## story_frame 摘录\n\n只有前 2000 字...", + "utf-8"); + await writeFile(join(bookDir, "story", "character_matrix.md"), + "# 角色矩阵(已废弃)\n\n> 权威来源是 roles/ 目录\n\n## 主要角色\n\n- roles/主要角色/林辞.md", + "utf-8"); + await writeFile(join(bookDir, "story", "book_rules.md"), "# 规则 shim", "utf-8"); + await writeFile(join(bookDir, "story", "volume_outline.md"), "## 卷一 shim", "utf-8"); + await writeFile(join(bookDir, "book.json"), JSON.stringify({ + id: "phase5-book", title: "Phase 5 书", platform: "qidian", genre: "xuanhuan", + status: "active", targetChapters: 50, chapterWordCount: 3000, language: "zh", + createdAt: "2026-04-01T00:00:00.000Z", updatedAt: "2026-04-10T00:00:00.000Z", + }), "utf-8"); + + const generateSpy = vi.spyOn(ArchitectAgent.prototype, "generateFoundation") + .mockResolvedValue({ + storyBible: "(shim)", volumeOutline: "(shim)", + bookRules: "---\nversion: \"1.0\"\n---\n", + currentState: "", pendingHooks: "| hook_id |", + storyFrame: "## 主题\n段落式 v2", + volumeMap: "## 段 1\n卷一 v2", + roles: [{ tier: "major", name: "林辞", content: "新卡 v2" }], + }); + vi.spyOn(FoundationReviewerAgent.prototype, "review").mockResolvedValue({ + passed: true, totalScore: 90, dimensions: [], overallFeedback: "ok", + } as unknown as Awaited>); + + const state = new StateManager(root); + const runner = new PipelineRunner({ + state, projectRoot: root, client: TEST_CLIENT, model: "test-model", + } as unknown as ConstructorParameters[0]); + + await runner.reviseFoundation("phase5-book", "调整某个角色设定"); + + // 检查传给 architect 的 reviseFrom.storyBible 和 characterMatrix 是权威全文, + // 不是 shim 摘录 + const call = generateSpy.mock.calls[0]; + const options = call?.[3] as { reviseFrom?: { storyBible: string; characterMatrix: string } }; + expect(options?.reviseFrom?.storyBible).toContain("完整的段落式世界观描写"); + expect(options?.reviseFrom?.storyBible).not.toContain("已废弃"); + expect(options?.reviseFrom?.characterMatrix).toContain("完整角色线,3000 字描写"); + expect(options?.reviseFrom?.characterMatrix).not.toContain("roles/主要角色/林辞.md"); // shim 里才有文件路径列表 + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it("revise 清空旧 role 文件(删除/改名角色后旧卡片不残留)", async () => { + const { mkdtemp, writeFile, mkdir, rm, access } = await import("node:fs/promises"); + const { tmpdir } = await import("node:os"); + const { join } = await import("node:path"); + const { PipelineRunner } = await import("../pipeline/runner.js"); + const { StateManager } = await import("../state/manager.js"); + + const root = await mkdtemp(join(tmpdir(), "inkos-revise-ghost-")); + const bookDir = join(root, "books", "ghost-book"); + + try { + // 初始状态:Phase 5 书已有 3 个主要角色、2 个次要角色 + await mkdir(join(bookDir, "story", "outline"), { recursive: true }); + await mkdir(join(bookDir, "story", "roles", "主要角色"), { recursive: true }); + await mkdir(join(bookDir, "story", "roles", "次要角色"), { recursive: true }); + await writeFile(join(bookDir, "story", "outline", "story_frame.md"), "## 主题", "utf-8"); + await writeFile(join(bookDir, "story", "outline", "volume_map.md"), "## 段 1", "utf-8"); + await writeFile(join(bookDir, "story", "roles", "主要角色", "林辞.md"), "老卡", "utf-8"); + await writeFile(join(bookDir, "story", "roles", "主要角色", "要删掉的人.md"), "应该消失", "utf-8"); + await writeFile(join(bookDir, "story", "roles", "主要角色", "要改名的A.md"), "改名前", "utf-8"); + await writeFile(join(bookDir, "story", "roles", "次要角色", "老配角.md"), "老次要", "utf-8"); + await writeFile(join(bookDir, "story", "roles", "次要角色", "要删掉的次要.md"), "应该消失", "utf-8"); + await writeFile(join(bookDir, "story", "book_rules.md"), "", "utf-8"); + await writeFile(join(bookDir, "story", "character_matrix.md"), "", "utf-8"); + await writeFile(join(bookDir, "story", "story_bible.md"), "", "utf-8"); + await writeFile(join(bookDir, "story", "volume_outline.md"), "", "utf-8"); + await writeFile(join(bookDir, "book.json"), JSON.stringify({ + id: "ghost-book", title: "测试", platform: "qidian", genre: "xuanhuan", + status: "active", targetChapters: 50, chapterWordCount: 3000, language: "zh", + createdAt: "2026-04-01T00:00:00.000Z", updatedAt: "2026-04-10T00:00:00.000Z", + }), "utf-8"); + + // architect revise 只输出 2 个新 role(主角保留、一个改名后的新 id、删了 "要删掉的人") + vi.spyOn(ArchitectAgent.prototype, "generateFoundation").mockResolvedValue({ + storyBible: "(shim)", volumeOutline: "(shim)", + bookRules: "---\nversion: \"1.0\"\n---\n", + currentState: "", pendingHooks: "| hook_id |", + storyFrame: "## 主题\n新", volumeMap: "## 段 1\n新", + roles: [ + { tier: "major", name: "林辞", content: "新卡" }, + { tier: "major", name: "改名后的B", content: "改名后" }, + ], + }); + vi.spyOn(FoundationReviewerAgent.prototype, "review").mockResolvedValue({ + passed: true, totalScore: 90, dimensions: [], overallFeedback: "ok", + } as unknown as Awaited>); + + const state = new StateManager(root); + const runner = new PipelineRunner({ + state, projectRoot: root, client: TEST_CLIENT, model: "test-model", + } as unknown as ConstructorParameters[0]); + + await runner.reviseFoundation("ghost-book", "精简角色"); + + // 新输出的 2 个 role 应该存在 + await expect(access(join(bookDir, "story", "roles", "主要角色", "林辞.md"))).resolves.not.toThrow(); + await expect(access(join(bookDir, "story", "roles", "主要角色", "改名后的B.md"))).resolves.not.toThrow(); + // 旧的 5 个 role 文件里没出现在新输出的,**必须被清空** + await expect(access(join(bookDir, "story", "roles", "主要角色", "要删掉的人.md"))).rejects.toThrow(); + await expect(access(join(bookDir, "story", "roles", "主要角色", "要改名的A.md"))).rejects.toThrow(); + await expect(access(join(bookDir, "story", "roles", "次要角色", "老配角.md"))).rejects.toThrow(); + await expect(access(join(bookDir, "story", "roles", "次要角色", "要删掉的次要.md"))).rejects.toThrow(); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it("Phase 5 revise 备份目录带 phase5 tag 并包含 outline/ + roles/", async () => { + const { mkdtemp, writeFile, mkdir, rm, readdir, access } = await import("node:fs/promises"); + const { tmpdir } = await import("node:os"); + const { join } = await import("node:path"); + const { PipelineRunner } = await import("../pipeline/runner.js"); + const { StateManager } = await import("../state/manager.js"); + + const root = await mkdtemp(join(tmpdir(), "inkos-revise-backup-")); + const bookDir = join(root, "books", "p5"); + + try { + await mkdir(join(bookDir, "story", "outline"), { recursive: true }); + await mkdir(join(bookDir, "story", "roles", "主要角色"), { recursive: true }); + await writeFile(join(bookDir, "story", "outline", "story_frame.md"), "原 frame", "utf-8"); + await writeFile(join(bookDir, "story", "outline", "volume_map.md"), "原 map", "utf-8"); + await writeFile(join(bookDir, "story", "roles", "主要角色", "A.md"), "原角色 A", "utf-8"); + await writeFile(join(bookDir, "story", "book_rules.md"), "", "utf-8"); + await writeFile(join(bookDir, "story", "character_matrix.md"), "", "utf-8"); + await writeFile(join(bookDir, "story", "story_bible.md"), "", "utf-8"); + await writeFile(join(bookDir, "story", "volume_outline.md"), "", "utf-8"); + await writeFile(join(bookDir, "book.json"), JSON.stringify({ + id: "p5", title: "t", platform: "qidian", genre: "xuanhuan", + status: "active", targetChapters: 50, chapterWordCount: 3000, language: "zh", + createdAt: "2026-04-01T00:00:00.000Z", updatedAt: "2026-04-10T00:00:00.000Z", + }), "utf-8"); + + vi.spyOn(ArchitectAgent.prototype, "generateFoundation").mockResolvedValue({ + storyBible: "(shim)", volumeOutline: "(shim)", + bookRules: "---\nversion: \"1.0\"\n---\n", + currentState: "", pendingHooks: "| hook_id |", + storyFrame: "## 新", volumeMap: "## 新", + roles: [{ tier: "major", name: "B", content: "新" }], + }); + vi.spyOn(FoundationReviewerAgent.prototype, "review").mockResolvedValue({ + passed: true, totalScore: 90, dimensions: [], overallFeedback: "ok", + } as unknown as Awaited>); + + const state = new StateManager(root); + const runner = new PipelineRunner({ + state, projectRoot: root, client: TEST_CLIENT, model: "test-model", + } as unknown as ConstructorParameters[0]); + + await runner.reviseFoundation("p5", "改"); + + const entries = await readdir(join(bookDir, "story")); + const backupDir = entries.find((e) => e.startsWith(".backup-phase5-")); + expect(backupDir).toBeDefined(); + // backup 应该包含 outline/ 和 roles/(Phase 5 权威源) + await expect(access(join(bookDir, "story", backupDir!, "outline", "story_frame.md"))).resolves.not.toThrow(); + await expect(access(join(bookDir, "story", backupDir!, "outline", "volume_map.md"))).resolves.not.toThrow(); + await expect(access(join(bookDir, "story", backupDir!, "roles", "主要角色", "A.md"))).resolves.not.toThrow(); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/core/src/__tests__/state-manager.test.ts b/packages/core/src/__tests__/state-manager.test.ts index 32f15f86..0f78df88 100644 --- a/packages/core/src/__tests__/state-manager.test.ts +++ b/packages/core/src/__tests__/state-manager.test.ts @@ -771,6 +771,19 @@ describe("StateManager", () => { expect(runtimeStat.isDirectory()).toBe(true); }); + it("creates Phase 5 outline/ and roles/ directories", async () => { + await manager.ensureControlDocuments("phase5-book"); + + const storyDir = join(manager.bookDir("phase5-book"), "story"); + const outlineStat = await stat(join(storyDir, "outline")); + const rolesMajorStat = await stat(join(storyDir, "roles", "主要角色")); + const rolesMinorStat = await stat(join(storyDir, "roles", "次要角色")); + + expect(outlineStat.isDirectory()).toBe(true); + expect(rolesMajorStat.isDirectory()).toBe(true); + expect(rolesMinorStat.isDirectory()).toBe(true); + }); + it("bootstraps and returns safe defaults for legacy books", async () => { const storyDir = join(manager.bookDir("legacy-book"), "story"); await mkdir(storyDir, { recursive: true }); diff --git a/packages/core/src/agent/agent-tools.ts b/packages/core/src/agent/agent-tools.ts index fec1e8f3..e877d6f1 100644 --- a/packages/core/src/agent/agent-tools.ts +++ b/packages/core/src/agent/agent-tools.ts @@ -75,6 +75,12 @@ const SubAgentParams = Type.Object({ ], { description: "architect only: writing language. Default: zh" })), targetChapters: Type.Optional(Type.Number({ description: "architect only: total chapter count. Default: 200" })), chapterWordCount: Type.Optional(Type.Number({ description: "architect/writer: words per chapter. Default: 3000" })), + revise: Type.Optional(Type.Boolean({ + description: "architect only: true 表示在已有书上重新生成架构稿(比如把旧的条目式格式升级成段落式架构稿 + 一人一卡的角色目录、或者按 feedback 调整某些细节),而不是新建书籍。需要同时提供 bookId", + })), + feedback: Type.Optional(Type.String({ + description: "architect only: revise 模式下的调整要求。举例:把架构稿从条目式升级成段落式架构稿、某个角色设定需要重新设计、主线冲突表达太弱需要加强等。如果是架构稿评审未通过要求重写的场景,把评审意见的 overallFeedback 原样传入即可", + })), // -- reviser params -- mode: Type.Optional(Type.Union([ Type.Literal("spot-fix"), @@ -120,7 +126,7 @@ export function createSubAgentTool( _signal?: AbortSignal, onUpdate?: AgentToolUpdateCallback, ): Promise> { - const { agent, instruction, bookId, title, chapterNumber, genre, platform, language, targetChapters, chapterWordCount, mode, format, approvedOnly } = params; + const { agent, instruction, bookId, title, chapterNumber, genre, platform, language, targetChapters, chapterWordCount, revise, feedback, mode, format, approvedOnly } = params; const progress = (msg: string) => { onUpdate?.(textResult(msg)); @@ -129,6 +135,17 @@ export function createSubAgentTool( try { switch (agent) { case "architect": { + if (revise) { + if (!bookId) { + return textResult("Error: architect revise 模式需要 bookId,用于定位要重写的书。"); + } + progress(`Revising foundation for "${bookId}"...`); + await pipeline.reviseFoundation(bookId, feedback ?? instruction); + progress(`Foundation revised for "${bookId}".`); + return textResult( + `Book "${bookId}" 架构稿已按要求重写。原书的条目式架构稿已备份到 story/.backup-phase4-<时间戳>/。`, + ); + } if (activeBookId) { return textResult("当前已有书籍,不需要建书。如果你想创建新书,请先回到首页。"); } diff --git a/packages/core/src/agent/context-transform.ts b/packages/core/src/agent/context-transform.ts index f4cad1fc..7eaa0774 100644 --- a/packages/core/src/agent/context-transform.ts +++ b/packages/core/src/agent/context-transform.ts @@ -2,6 +2,7 @@ import type { AgentMessage } from "@mariozechner/pi-agent-core"; import type { UserMessage } from "@mariozechner/pi-ai"; import { readdir, readFile } from "node:fs/promises"; import { join } from "node:path"; +import { isNewLayoutBook } from "../utils/outline-paths.js"; /** Files read in this order; anything else in story/ comes after, sorted alphabetically. */ const PRIORITY_FILES = [ @@ -11,6 +12,12 @@ const PRIORITY_FILES = [ "current_focus.md", ]; +const UPGRADE_HINT = + "[提示] 当前这本书的架构稿是旧的条目式格式(story_bible.md / volume_outline.md / character_matrix.md)。" + + "如果作者有意愿升级成段落式架构稿 + 一人一卡的角色目录(outline/story_frame.md + outline/volume_map.md + roles/)," + + "可以调用 `sub_agent(architect, { revise: true, bookId, feedback: \"把架构稿从条目式升级成段落式架构稿,并把角色矩阵拆成 roles 目录一人一卡\" })`。" + + "升级只改架构稿,不动已写的章节。在作者没明确同意前不要主动触发。"; + export function createBookContextTransform( bookId: string | null, projectRoot: string, @@ -19,14 +26,19 @@ export function createBookContextTransform( return async (messages) => messages; } - const storyDir = join(projectRoot, "books", bookId, "story"); + const bookDir = join(projectRoot, "books", bookId); + const storyDir = join(bookDir, "story"); return async (messages) => { const sections = await readTruthFiles(storyDir); if (sections.length === 0) return messages; + const isNew = await isNewLayoutBook(bookDir); + const hintBlock = isNew ? "" : `\n\n${UPGRADE_HINT}`; + const body = - "[以下是当前书籍的真相文件,每次对话时自动从磁盘读取注入。请基于这些内容进行创作和判断。]\n\n" + + "[以下是当前书籍的真相文件,每次对话时自动从磁盘读取注入。请基于这些内容进行创作和判断。]" + + hintBlock + "\n\n" + sections.map((s) => `=== ${s.name} ===\n${s.content}`).join("\n\n"); const injected: UserMessage = { diff --git a/packages/core/src/agents/architect.ts b/packages/core/src/agents/architect.ts index aa54412a..7cadfc9f 100644 --- a/packages/core/src/agents/architect.ts +++ b/packages/core/src/agents/architect.ts @@ -2,16 +2,42 @@ import { BaseAgent } from "./base.js"; import type { BookConfig, FanficMode } from "../models/book.js"; import type { GenreProfile } from "../models/genre-profile.js"; import { readGenreProfile } from "./rules-reader.js"; -import { writeFile, mkdir } from "node:fs/promises"; +import { writeFile, mkdir, rm } from "node:fs/promises"; import { join } from "node:path"; import { renderHookSnapshot } from "../utils/memory-retrieval.js"; +export interface ArchitectRole { + readonly tier: "major" | "minor"; + readonly name: string; + readonly content: string; +} + export interface ArchitectOutput { readonly storyBible: string; readonly volumeOutline: string; readonly bookRules: string; readonly currentState: string; readonly pendingHooks: string; + // Phase 5 新增字段:段落式架构稿 + 一人一卡角色目录。Optional,老测试 fixture 只 mock + // 旧字段时依然能编译;架构师在运行时始终填充这些。 + readonly storyFrame?: string; + readonly volumeMap?: string; + readonly rhythmPrinciples?: string; + readonly roles?: ReadonlyArray; +} + +/** 拆 markdown 的首部 YAML frontmatter 和正文。没有 frontmatter 时返回 frontmatter: null。 */ +function extractYamlFrontmatter(raw: string): { frontmatter: string | null; body: string } { + if (!raw) return { frontmatter: null, body: "" }; + const stripped = raw.replace(/^```(?:md|markdown|yaml)?\s*\n/, "").replace(/\n```\s*$/, ""); + const leadingMatch = stripped.match(/^\s*---\s*\n([\s\S]*?)\n---\s*\n?([\s\S]*)$/); + if (!leadingMatch) { + return { frontmatter: null, body: stripped }; + } + return { + frontmatter: `---\n${leadingMatch[1]}\n---`, + body: leadingMatch[2].trim(), + }; } export class ArchitectAgent extends BaseAgent { @@ -23,6 +49,15 @@ export class ArchitectAgent extends BaseAgent { book: BookConfig, externalContext?: string, reviewFeedback?: string, + options?: { + reviseFrom?: { + storyBible: string; + volumeOutline: string; + bookRules: string; + characterMatrix: string; + userFeedback: string; + }; + }, ): Promise { const { profile: gp, body: genreBody } = await readGenreProfile(this.ctx.projectRoot, book.genre); @@ -32,6 +67,9 @@ export class ArchitectAgent extends BaseAgent { ? `\n\n## 外部指令\n以下是来自外部系统的创作指令,请将其融入设定中:\n\n${externalContext}\n` : ""; const reviewFeedbackBlock = this.buildReviewFeedbackBlock(reviewFeedback, resolvedLanguage); + const revisePrompt = options?.reviseFrom + ? this.buildRevisePrompt(options.reviseFrom) + : ""; const numericalBlock = gp.numericalSystem ? `- 有明确的数值/资源体系可追踪 @@ -46,108 +84,210 @@ export class ArchitectAgent extends BaseAgent { ? "- 需要年代考据支撑(在 book_rules 中设置 eraConstraints)" : ""; - const storyBiblePrompt = resolvedLanguage === "en" - ? `Use structured second-level headings: -## 01_Worldview -World setting, historical-social frame, and core rules + const basePrompt = resolvedLanguage === "en" + ? this.buildEnglishFoundationPrompt(book, gp, genreBody, contextBlock, reviewFeedbackBlock, numericalBlock, powerBlock, eraBlock) + : this.buildChineseFoundationPrompt(book, gp, genreBody, contextBlock, reviewFeedbackBlock, numericalBlock, powerBlock, eraBlock); + const systemPrompt = revisePrompt + basePrompt; -## 02_Protagonist -Protagonist setup (identity / advantage / personality core / behavioral boundaries) + const langPrefix = resolvedLanguage === "en" + ? `【LANGUAGE OVERRIDE】ALL output (story_frame, volume_map, roles, book_rules, pending_hooks) MUST be written in English. Character names, place names, and all prose must be in English. The === SECTION: === tags remain unchanged. Do NOT emit rhythm_principles or current_state sections — rhythm principles live inside the last paragraph of volume_map; environment/era anchors (when relevant) are woven into story_frame's world-tonal-ground paragraph.\n\n` + : ""; + const userMessage = resolvedLanguage === "en" + ? `Generate the complete foundation for a ${gp.name} novel titled "${book.title}". Write everything in English.` + : `请为标题为"${book.title}"的${gp.name}小说生成完整基础设定。`; -## 03_Factions_and_Characters -Major factions and important supporting characters (for each: name, identity, motivation, relationship to protagonist, independent goal) + const response = await this.chat([ + { role: "system", content: langPrefix + systemPrompt }, + { role: "user", content: userMessage }, + ], { temperature: 0.8, maxTokens: 16384 }); -## 04_Geography_and_Environment -Map / scene design and environmental traits + return this.parseSections(response.content); + } -## 05_Title_and_Blurb -Title method: -- Keep the title clear, direct, and easy to understand -- Use a format that immediately signals genre and core appeal -- Avoid overly literary or misleading titles - -Blurb method (within 300 words, choose one): -1. Open with conflict, then reveal the hook, then leave suspense -2. Summarize only the main line and keep a clear suspense gap -3. Use a miniature scene that captures the book's strongest pull - -Core blurb principle: -- The blurb is product copy that must make readers want to click` - : `用结构化二级标题组织: -## 01_世界观 -世界观设定、核心规则体系 + private buildRevisePrompt(reviseFrom: { + storyBible: string; + volumeOutline: string; + bookRules: string; + characterMatrix: string; + userFeedback: string; + }): string { + return `你在把一本已有书的架构稿从条目式升级成段落式架构稿 + 一人一卡的角色目录。 -## 02_主角 -主角设定(身份/金手指/性格底色/行为边界) +原书信息(条目式架构稿原文,这是权威内容,必须完整保留里面的世界观 / 角色 / 主线 / 伏笔): -## 03_势力与人物 -势力分布、重要配角(每人:名字、身份、动机、与主角关系、独立目标) +【story_bible.md 全文】 +${reviseFrom.storyBible || "(无)"} -## 04_地理与环境 -地图/场景设定、环境特色 +【volume_outline.md 全文】 +${reviseFrom.volumeOutline || "(无)"} -## 05_书名与简介 -书名方法论: -- 书名必须简单扼要、通俗易懂,读者看到书名就能知道题材和主题 -- 采用"题材+核心爽点+主角行为"的长书名格式,避免文艺化 -- 融入平台当下热点词汇,吸引精准流量 -- 禁止题材错位(都市文取玄幻书名会导致读者流失) -- 参考热榜书名风格:俏皮、通俗、有记忆点 - -简介方法论(300字内,三种写法任选其一): -1. 冲突开篇法:第一句抛困境/冲突,第二句亮金手指/核心能力,第三句留悬念 -2. 高度概括法:只挑主线概括(不是全篇概括),必须留悬念 -3. 小剧场法:提炼故事中最经典的桥段,作为引子 - -简介核心原则: -- 简介 = 产品宣传语,必须让读者产生"我要点开看"的冲动 -- 可以从剧情设定、人设、或某个精彩片段切入 -- 必须有噱头(如"凡是被写在笔记本上的名字,最后都得死")`; +【book_rules.md 全文】 +${reviseFrom.bookRules || "(无)"} - const volumeOutlinePrompt = resolvedLanguage === "en" - ? `Volume plan. For each volume include: title, chapter range, core conflict, key turning points, and payoff goal +【character_matrix.md 全文】 +${reviseFrom.characterMatrix || "(无)"} -### Golden First Three Chapters Rule -- Chapter 1: throw the core conflict immediately; no large background dump -- Chapter 2: show the core edge / ability / leverage that answers Chapter 1's pressure -- Chapter 3: establish the first concrete short-term goal that gives readers a reason to continue` - : `卷纲规划,每卷包含:卷名、章节范围、核心冲突、关键转折、收益目标 +你的任务: +1. 把 story_bible 的内容重新组织成 4 段段落式 story_frame.md(主题 / 核心冲突 / 世界观底色 / 终局方向) +2. 把 volume_outline 的内容重新组织成 5 段 volume_map.md + 末尾 1 段节奏原则 +3. 把 character_matrix 里的每个角色拆成一份 roles/主要角色/.md 或 roles/次要角色/.md + +严格约束: +- 世界观设定、角色设定、主线走向、已埋下的伏笔一个字都不能丢 +- 如果原内容里有 bullet 点,把它们连成段落;不要只是把 bullet 转成句号分隔 +- 主次角色的判断依据:character_matrix 里已标注的如果有,沿用;没有的话,把在 volume_outline 里出现频次高、或者承担主线冲突的列为主要,其余为次要 +- 基调 / 语气不要改变 +- 如果原内容里留有空白或未展开项,保留为空,不要主动补全 + +用户额外要求: +${reviseFrom.userFeedback || "(无)"} + +--- + +`; + } + + // ------------------------------------------------------------------------- + // Phase 5 段落式 prompt — zh + // ------------------------------------------------------------------------- + private buildChineseFoundationPrompt( + book: BookConfig, + gp: GenreProfile, + genreBody: string, + contextBlock: string, + reviewFeedbackBlock: string, + numericalBlock: string, + powerBlock: string, + eraBlock: string, + ): string { + return `你是这本书的总架构师。你的唯一输出是**段落密度的基础设定**——不是表格、不是 schema、不是条目化 bullet。这本书的"灵气"从你这里来。你的段落密度决定了后面 planner 能不能读出"稀疏 memo",writer 能不能写出活人,reviewer 能不能校准硬伤。${contextBlock}${reviewFeedbackBlock} + +## 书籍元信息 +- 平台:${book.platform} +- 题材:${gp.name}(${book.genre}) +- 目标章数:${book.targetChapters}章 +- 每章字数:${book.chapterWordCount}字 +- 标题:${book.title} + +## 题材底色 +${genreBody} + +## 产出约束(硬性) +${numericalBlock} +${powerBlock} +${eraBlock} + +## 输出结构(5 个 SECTION,严格按 === SECTION: === 分块,不要漏任何一块) + +## 去重铁律(必读) +禁止在多段里重复同一事实。主角的完整角色线只写在 roles;世界铁律只写在 story_frame.世界观底色;节奏原则只写在 volume_map 最后一段;角色当前现状只写在 roles.当前现状;初始钩子只写在 pending_hooks(startChapter=0 行)。**如果本书是年代文/历史同人/都市重生等需要年份、季节、重大历史事件作为锚点的题材**,把环境/时代锚自然织进 story_frame.世界观底色("1985 年 7 月,非典刚过"这类);**修仙/玄幻/系统等没有真实年份的题材直接省略**,不要硬凑。如果一个段落写了另一段的内容,删掉。 + +## 预算(超预算必删) +- story_frame ≤ 3000 字 +- volume_map ≤ 5000 字 +- roles 总 ≤ 8000 字 +- book_rules ≤ 500 字(仅 YAML) +- pending_hooks ≤ 2000 字 + +=== SECTION: story_frame === + +这是段落式骨架。**4 段**,每段约 600-900 字,不要写表格,不要写 bullet list,写成能被人读下去的段落。段落标题用 \`## \` 开头,段落内部是正经段落。**主角的完整角色线不写在本 section;它的权威来源是 roles/主要角色/<主角>.md。** 本段只需一句指针:"本书主角是 X,完整角色线详见 roles/主要角色/X.md"。 + +### 段 1:主题与基调 +写这本书到底讲的是什么——不是"讲主角如何从弱到强"这种空话,而是具体的命题("一个被时代按在泥里的人,如何选择不被改写"、"当所有人都在撒谎时,坚持记录真相要付出什么代价")。主题下面跟着基调——温情冷冽悲壮肃杀,哪一种?为什么是这种而不是另一种?结尾用一句话指向主角并引向 roles(例:"本书主角是林辞,完整角色线详见 roles/主要角色/林辞.md")。 + +### 段 2:核心冲突与对手定性 +这本书的主要矛盾是什么?不是"正邪对抗",而是"因为 A 相信 X、B 相信 Y,所以他们一定会在某件事上对撞"。主要对手是谁(至少 2 个:一个显性对手 + 一个结构性对手/体制),他们的动机从哪里长出来。对手不是工具,对手有自己的逻辑。 + +### 段 3:世界观底色(铁律 + 质感 + 本书专属规则) +这个世界的运行规则是什么?3-5 条**不可违反的铁律**——以段落形式写出,不要 bullet。这个世界的质感是什么——湿的还是干的、快的还是慢的、噪的还是静的?给 writer 一个明确的感官锚。**这一段同时承担原先 book_rules 正文里写的"叙事视角 / 本书专属规则 / 核心冲突驱动"等内容**——全部合并到这里写一次就够,不要再去 book_rules 重复。 + +### 段 4:终局方向 +这本书最后一章大概是什么感觉——不是"主角登顶"、"大结局"这种套话,而是**最后一个镜头**大致长什么样。主角最后在哪、做什么、身边有谁、心里想什么。这是给全书所有后面的规划一个远方靶子。 + +=== SECTION: volume_map === + +这是分卷段落式地图,**5 段主体 + 1 段节奏原则尾段**。**关键要求:只写到卷级内容**——写清楚每卷的主题、情绪曲线、卷间钩子、角色阶段目标、卷尾不可逆事件。**禁止指定具体章号任务**(不要写"第 17 章让他回家"这种章级布局)。章级规划是 Phase 3 planner 的职责,架构师只搭骨架、不编章目。 + +### 段 1:各卷主题与情绪曲线 +有几卷?每卷的主题一句话,每卷的情绪曲线一段(哪里压、哪里爽、哪里冷、哪里暖)。不要机械的"第一卷打小怪第二卷打大怪",写情绪的流动。 + +### 段 2:卷间钩子与回收承诺 +第 1 卷埋什么钩子、在哪一卷回收;第 2 卷埋什么、在哪一卷回收。段落式写,不要表格。**只写卷级**(如"第 1 卷埋的身世之谜在第 3 卷回收"),不要写具体章号。 + +### 段 3:角色阶段性目标 +主角在第 1 卷末要到什么状态?第 2 卷末?每一卷结束时主角的身份/关系/能力/心境应该是什么。次要角色的阶段性变化也要点到(师父在第 2 卷会死、对手在第 3 卷会黑化等)。写阶段性,不写完整角色线(完整角色线在 roles)。 + +### 段 4:卷尾必须发生的改变 +每一卷最后一章必须发生什么不可逆的事——权力结构改变、关系破裂、秘密暴露、主角身份重定位。写段落,一卷一段。**只写"必须发生什么",不指定是第几章**。 + +### 段 5:节奏原则(具体化 + 通用) +**这是节奏原则的唯一归宿,不再有独立 rhythm_principles section。** 本段输出 6 条节奏原则。**至少 3 条必须具体化到本书**(例:"前 30 章每 5 章一个小爽点"),其余可保留通用原则(例:"拒绝机械降神"、"高潮前 3-5 章埋伏笔")。具体化 + 通用混合是合法的。反面例子:"节奏要张弛有度"(废话)。正面例子:"前 30 章每 5 章一个小爽点,且小爽点必须落在章末 300 字内"。6 条各写 2-3 句,覆盖(顺序不强制、可替换同权重议题): +1. 高潮间距——本书大高潮之间最长多少章?(具体化优先) +2. 喘息频率——高压段多长必须插一章喘息?喘息章承担什么任务? +3. 钩子密度——每章章末留钩数量,主钩最多允许悬多少章? +4. 信息释放节奏——主线信息在前 1/3、中段、后 1/3 分别释放多少比例?(可通用) +5. 爽点节奏——爽点间距多少章一个?什么类型为主?(具体化优先) +6. 情感节点递进——情感关系每多少章必须有一次实质推进? ### 黄金三章法则(前三章必须遵循) - 第1章:抛出核心冲突(主角立即面临困境/危机/选择),禁止大段背景灌输 - 第2章:展示金手指/核心能力(主角如何应对第1章的困境),让读者看到爽点预期 -- 第3章:明确短期目标(主角确立第一个具体可达成的目标),给读者追读理由`; +- 第3章:明确短期目标(主角确立第一个具体可达成的目标),给读者追读理由 - const bookRulesPrompt = resolvedLanguage === "en" - ? `Generate book_rules.md as YAML frontmatter plus narrative guidance: -\`\`\` ---- -version: "1.0" -protagonist: - name: (protagonist name) - personalityLock: [(3-5 personality keywords)] - behavioralConstraints: [(3-5 behavioral constraints)] -genreLock: - primary: ${book.genre} - forbidden: [(2-3 forbidden style intrusions)] -${gp.numericalSystem ? `numericalSystemOverrides: - hardCap: (decide from the setting) - resourceTypes: [(core resource types)]` : ""} -prohibitions: - - (3-5 book-specific prohibitions) -chapterTypesOverride: [] -fatigueWordsOverride: [] -additionalAuditDimensions: [] -enableFullCastTracking: false ---- +=== SECTION: roles === -## Narrative Perspective -(Describe the narrative perspective and style) +一人一卡。**主角卡是本书角色线的唯一权威来源**——story_frame 不再写主角的完整角色线,writer/planner 都从这里读。用以下格式分隔: -## Core Conflict Driver -(Describe the book's core conflict and propulsion) -\`\`\`` - : `生成 book_rules.md 格式的 YAML frontmatter + 叙事指导,包含: +---ROLE--- +tier: major +name: <角色名> +---CONTENT--- +(这里写段落式角色卡,下面的小标题必须全部出现,每段至少 3 行正经段落,不要写表格) + +## 核心标签 +(3-5 个关键词 + 一句话为什么是这些词) + +## 反差细节 +(1-2 个与核心标签反差的具体细节——"冷酷杀手但会给流浪猫留鱼骨"。反差细节是人物立体化的公式,必须有。) + +## 人物小传(过往经历) +(一段段落,说这个人怎么变成现在这样。童年/重大事件/塑造性格的那件事。只写关键过往,简版。) + +## 主角线(起点 → 终点 → 代价) +**只有主角必须写本段;其他 major 角色如果分量重也可以写,否则略过。**主角从哪里出发(身份、处境、核心缺陷、一开始最想要什么),到哪里落脚(最终变成什么样的人、拿到/失去什么),为了这个落脚他付出了什么不可逆的代价(关系、身体、信念、某段过去)。不要只写"变强"这种平面变化,要写**内在的位移**。写足写实。 + +## 当前现状(第 0 章初始状态) +(第 0 章时他在哪、做什么、处境如何、最近最烦心的事。**只写角色个人处境**——初始钩子写在 pending_hooks 的 startChapter=0 行;环境/时代锚(如果是需要年份的题材)织进 story_frame.世界观底色。不再有独立的 current_state section。) + +## 关系网络 +(与主角、与其他重要角色的关系——一句话一条,关系不是标签是动态。) + +## 内在驱动 +(他想要什么、为什么想要、愿意付出什么代价。) + +## 成长变化 +(他在这本书里会经历什么内在位移——变好变坏变复杂,落在哪里。非主角可短可长。) + +---ROLE--- +tier: major +name: <下一个主要角色> +---CONTENT--- +... + +(主要角色至少 3 个:主角 + 主要对手 + 主要协作者。建议 2-3 主 + 2-3 辅,不要灌水。质量 > 数量。) + +---ROLE--- +tier: minor +name: <次要角色名> +---CONTENT--- +(次要角色简化版,只需要 4 个小标题:核心标签 / 反差细节 / 当前现状 / 与主角关系,每段 1-2 行即可) + +(次要角色 3-5 个,按出场密度给。) + +=== SECTION: book_rules === + +**只输出 YAML frontmatter 一块——零段落正文。** 所有的"叙事视角 / 本书专属规则 / 核心冲突驱动"等段落已经合并到 story_frame.世界观底色,不要在这里重复写。 \`\`\` --- version: "1.0" @@ -168,175 +308,366 @@ fatigueWordsOverride: [] additionalAuditDimensions: [] enableFullCastTracking: false --- +\`\`\` -## 叙事视角 -(描述本书叙事视角和风格) - -## 核心冲突驱动 -(描述本书的核心矛盾和驱动力) -\`\`\``; - - const currentStatePrompt = resolvedLanguage === "en" - ? `Initial state card (Chapter 0), include: -| Field | Value | -| --- | --- | -| Current Chapter | 0 | -| Current Location | (starting location) | -| Protagonist State | (initial condition) | -| Current Goal | (first goal) | -| Current Constraint | (initial constraint) | -| Current Alliances | (initial relationships) | -| Current Conflict | (first conflict) |` - : `初始状态卡(第0章),包含: -| 字段 | 值 | -|------|-----| -| 当前章节 | 0 | -| 当前位置 | (起始地点) | -| 主角状态 | (初始状态) | -| 当前目标 | (第一个目标) | -| 当前限制 | (初始限制) | -| 当前敌我 | (初始关系) | -| 当前冲突 | (第一个冲突) |`; - - const pendingHooksPrompt = resolvedLanguage === "en" - ? `Initial hook pool (Markdown table): -| hook_id | start_chapter | type | status | last_advanced_chapter | expected_payoff | payoff_timing | notes | +=== SECTION: pending_hooks === -Rules for the hook table: -- Column 5 must be a pure chapter number, never natural-language description -- During book creation, all planned hooks are still unapplied, so last_advanced_chapter = 0 -- Column 7 must be one of: immediate / near-term / mid-arc / slow-burn / endgame -- If you want to describe the initial clue/signal, put it in notes instead of column 5` - : `初始伏笔池(Markdown表格): +初始伏笔池(Markdown表格,8 列基础版): | hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 回收节奏 | 备注 | 伏笔表规则: - 第5列必须是纯数字章节号,不能写自然语言描述 - 建书阶段所有伏笔都还没正式推进,所以第5列统一填 0 - 第7列必须填写:立即 / 近期 / 中程 / 慢烧 / 终局 之一 -- 如果要说明“初始线索/最初信号”,写进备注,不要写进第5列`; +- 初始线索放备注列,不放第5列 +- **初始世界状态 / 初始敌我关系** 如果有关键信息(例如"主角身上带着父亲的笔记本"、"体制已经开始监视码头"),可以作为 startChapter=0 的种子行录入,备注列说明其"初始状态"属性 + +## 最后强调 +- 符合${book.platform}平台口味、${gp.name}题材特征 +- 主角人设鲜明、行为边界清晰 +- 伏笔前后呼应、配角有独立动机不是工具人 +- **story_frame / volume_map / roles 必须是段落密度,不要退化成 bullet** +- **book_rules 只留 YAML,不要写段落正文** +- **不要输出 rhythm_principles 或 current_state 独立 section**——节奏原则合并进 volume_map 尾段;角色初始状态写在 roles.当前现状,初始钩子写在 pending_hooks(startChapter=0 行),环境/时代锚(仅历史/年代/都市重生等需要年份的题材)织进 story_frame.世界观底色,不要硬凑`; + } - const finalRequirementsPrompt = resolvedLanguage === "en" - ? `Generated content must: -1. Fit the ${book.platform} platform taste -2. Fit the ${gp.name} genre traits -${numericalBlock} -${powerBlock} -${eraBlock} -3. Give the protagonist a clear personality and behavioral boundaries -4. Keep hooks and payoffs coherent -5. Make supporting characters independently motivated rather than pure tools` - : `生成内容必须: -1. 符合${book.platform}平台口味 -2. 符合${gp.name}题材特征 + // ------------------------------------------------------------------------- + // Phase 5 段落式 prompt — en + // ------------------------------------------------------------------------- + private buildEnglishFoundationPrompt( + book: BookConfig, + gp: GenreProfile, + genreBody: string, + contextBlock: string, + reviewFeedbackBlock: string, + numericalBlock: string, + powerBlock: string, + eraBlock: string, + ): string { + return `You are the architect of this book. Your only job is to produce **prose-density foundation design** — not tables, not schema, not bullet lists. The book's aura comes from your prose density: the Phase 3 planner reads sparse memos out of your volume_map only if it was written to chapter-level prose; the writer only produces living characters because your role sheets carry contrast details; the reviewer only catches hard errors because your story_frame set the tonal anchors.${contextBlock}${reviewFeedbackBlock} + +## Book metadata +- Platform: ${book.platform} +- Genre: ${gp.name} (${book.genre}) +- Target chapters: ${book.targetChapters} +- Chapter length: ${book.chapterWordCount} +- Title: ${book.title} + +## Genre body +${genreBody} + +## Output constraints ${numericalBlock} ${powerBlock} ${eraBlock} -3. 主角人设鲜明,有明确行为边界 -4. 伏笔前后呼应,不留悬空线 -5. 配角有独立动机,不是工具人`; - const systemPrompt = `你是一个专业的网络小说架构师。你的任务是为一本新的${gp.name}小说生成完整的基础设定。${contextBlock}${reviewFeedbackBlock} +## Output contract (5 === SECTION: === blocks) -要求: -- 平台:${book.platform} -- 题材:${gp.name}(${book.genre}) -- 目标章数:${book.targetChapters}章 -- 每章字数:${book.chapterWordCount}字 +## Deduplication rule (MANDATORY) +Do not duplicate the same fact across sections. The protagonist's full character arc lives only in roles; world hard-rules live only in story_frame; rhythm principles live only in the last paragraph of volume_map; character initial status lives only in roles.Current_State; initial hooks live only in pending_hooks (start_chapter=0 rows). **When the book is period fiction / historical fanfic / urban reincarnation** — anything pinned to a real year, season, or historic marker — weave the environment/era anchor into story_frame's world-tonal-ground paragraph. **For cultivation / high-fantasy / system genres that have no real-world year, skip it entirely** — do not fabricate an era anchor. If a section repeats content that belongs elsewhere, delete it. -## 题材特征 +## Output budget (over-budget means cut) +- story_frame ≤ 3000 chars +- volume_map ≤ 5000 chars +- roles ≤ 8000 chars total +- book_rules ≤ 500 chars (YAML only) +- pending_hooks ≤ 2000 chars -${genreBody} +=== SECTION: story_frame === -## 生成要求 +Four prose sections, ~600-900 chars each. No tables. No bullet lists. Real paragraphs. **Do NOT write the protagonist's full character arc here** — that is owned by roles/主要角色/.md. Use a single-line pointer inside this block. -你需要生成以下内容,每个部分用 === SECTION: === 分隔: +## 01_Theme_and_Tonal_Ground +What is this book actually about — not "hero grows from weak to strong" (empty), but a concrete proposition. Then the tonal ground: warm / cold / fierce / severe — which, and why this and not another. End with a one-line pointer to the protagonist role file. -=== SECTION: story_bible === -${storyBiblePrompt} +## 02_Core_Conflict_and_Opponent +The book's main tension — not "good vs evil" but "because A believes X and B believes Y, they will inevitably collide on Z". At least two opponents: one visible, one structural/systemic. Opponents have their own logic. -=== SECTION: volume_outline === -${volumeOutlinePrompt} +## 03_World_Tonal_Ground (hard rules + sensory tone + book-specific rules) +The world's operating rules. 3-5 unbreakable laws written as prose, not bullets. Sensory texture: wet or dry, fast or slow, noisy or quiet — give the writer an anchor. **This paragraph also absorbs the narrative prose that used to live in book_rules** (narrative perspective, core conflict driver, book-specific rules). Write them all here once. Do not repeat them in book_rules. -=== SECTION: book_rules === -${bookRulesPrompt} +## 04_Endgame_Direction +What the last chapter roughly feels like. The final shot: where, doing what, around whom, thinking what. A distant target for every planner call downstream. -=== SECTION: current_state === -${currentStatePrompt} +=== SECTION: volume_map === -=== SECTION: pending_hooks === -${pendingHooksPrompt} +Prose volume map, **5 sections + 1 closing rhythm paragraph**. **Critical requirement: stay at volume-level prose only**. No chapter-level task assignment. -${finalRequirementsPrompt}`; +## 01_Volume_Themes_and_Emotional_Curves +How many volumes? Each volume's theme in one sentence; each volume's emotional curve as a paragraph. - const langPrefix = resolvedLanguage === "en" - ? `【LANGUAGE OVERRIDE】ALL output (story_bible, volume_outline, book_rules, current_state, pending_hooks) MUST be written in English. Character names, place names, and all prose must be in English. The === SECTION: === tags remain unchanged.\n\n` - : ""; - const userMessage = resolvedLanguage === "en" - ? `Generate the complete foundation for a ${gp.name} novel titled "${book.title}". Write everything in English.` - : `请为标题为"${book.title}"的${gp.name}小说生成完整基础设定。`; +## 02_Cross_Volume_Hooks_and_Payoff_Promises +Volume 1 plants hook A, paid off in volume N; volume 2 plants hook B, paid off in volume M. Prose, not tables. **Stay at volume-level**. - const response = await this.chat([ - { role: "system", content: langPrefix + systemPrompt }, - { role: "user", content: userMessage }, - ], { temperature: 0.8, maxTokens: 16384 }); +## 03_Character_Stage_Goals +Protagonist's state at end of vol 1, vol 2, ... Supporting characters' stage changes. Stage goals only — full character arc lives in roles. - return this.parseSections(response.content); +## 04_Volume_End_Mandatory_Changes +Each volume's last chapter must contain an irreversible event. Prose, one paragraph per volume. + +## 05_Rhythm_Principles (concrete + universal) +**Single home for rhythm principles — no separate rhythm_principles section.** 6 rhythm principles. **At least 3 must be concretized for this book**; the rest may stay as universal rules. + +### Golden First Three Chapters Rule +- Chapter 1: throw the core conflict immediately; no large background dump +- Chapter 2: show the core edge / ability / leverage that answers Chapter 1's pressure +- Chapter 3: establish the first concrete short-term goal that gives readers a reason to continue + +=== SECTION: roles === + +One-file-per-character prose. **The protagonist card is the single source of truth for the protagonist's full character arc** — story_frame no longer carries it, and writer/planner both read it here. + +---ROLE--- +tier: major +name: +---CONTENT--- +## Core_Tags +(3-5 tags + one sentence on why) + +## Contrast_Detail +(1-2 concrete details that contradict the core tags.) + +## Back_Story +(Prose paragraph — how this person became who they are.) + +## Protagonist_Arc (start → end → cost) +**Mandatory for the protagonist; optional for other majors with substantial arcs.** Internal displacement, not just growth. + +## Current_State (initial state at chapter 0) +(Character-only: initial hooks go in pending_hooks start_chapter=0 rows; environment/era anchors weave into story_frame.) + +## Relationship_Network +(With protagonist, with other majors. One line each.) + +## Inner_Driver +(What they want, why, what they're willing to pay.) + +## Growth_Arc +(Internal displacement across the book.) + +---ROLE--- +tier: major +name: +---CONTENT--- +... + +(Aim for 2-3 majors + 2-3 supporting majors. Quality over quantity.) + +---ROLE--- +tier: minor +name: +---CONTENT--- +(Simplified: Core_Tags / Contrast_Detail / Current_State / Relationship_to_Protagonist only, 1-2 lines each.) + +(3-5 minors.) + +=== SECTION: book_rules === + +**Output ONLY the YAML frontmatter block — zero prose.** All narrative guidance moved into story_frame.03_World_Tonal_Ground. +\`\`\` +--- +version: "1.0" +protagonist: + name: (protagonist name) + personalityLock: [(3-5 personality keywords)] + behavioralConstraints: [(3-5 behavioral constraints)] +genreLock: + primary: ${book.genre} + forbidden: [(2-3 forbidden style intrusions)] +${gp.numericalSystem ? `numericalSystemOverrides: + hardCap: (decide from setting) + resourceTypes: [(core resource types)]` : ""} +prohibitions: + - (3-5 book-specific prohibitions) +chapterTypesOverride: [] +fatigueWordsOverride: [] +additionalAuditDimensions: [] +enableFullCastTracking: false +--- +\`\`\` + +=== SECTION: pending_hooks === + +Initial hook pool (Markdown table, 8-column base): +| hook_id | start_chapter | type | status | last_advanced_chapter | expected_payoff | payoff_timing | notes | + +Rules: +- Column 5 is a pure chapter number, not narrative description +- At book creation all planned hooks have last_advanced_chapter = 0 +- Column 7 must be: immediate / near-term / mid-arc / slow-burn / endgame +- Put initial signal text in notes, not column 5 +- **Initial world / alliance state**: any load-bearing initial condition can be seeded as a start_chapter=0 row with a notes tag indicating its initial-state nature + +## Final emphasis +- Fit ${book.platform} platform taste and ${gp.name} genre traits +- Protagonist persona clear with sharp behavioral boundaries +- Hooks planted with payoff promises; supporting characters have independent motivation +- **story_frame / volume_map / roles must be prose density — no bullet-list degradation** +- **book_rules is YAML only — no prose body** +- **Do NOT emit rhythm_principles or current_state as separate sections** — rhythm principles live in the last paragraph of volume_map; character initial status goes in roles.Current_State; initial hooks go in pending_hooks (start_chapter=0 rows); environment/era anchors (only when the genre has a real year) are woven into story_frame`; } + /** + * Write architect foundation output to disk. + * + * @param mode + * - "init"(默认):首次建书。写架构稿 + 初始化所有运行时状态文件 + * (current_state / pending_hooks / particle_ledger / subplot_board / + * emotional_arcs)为空模板。 + * - "revise":在已有书上重写架构稿。**只改架构稿相关文件**——outline/ / + * roles/ / 4 个 legacy shim——**完全不动运行时状态文件**。这和 + * context-transform 注入给 LLM 的 upgrade hint 承诺"只改架构稿不动已写 + * 章节"一致;如果在 revise 模式下触动运行时文件,会把 consolidator 累积 + * 的章节状态、伏笔推进、资源账本等全部重置。 + */ async writeFoundationFiles( bookDir: string, output: ArchitectOutput, numericalSystem: boolean = true, language: "zh" | "en" = "zh", + mode: "init" | "revise" = "init", ): Promise { const storyDir = join(bookDir, "story"); - await mkdir(storyDir, { recursive: true }); - - const writes: Array> = [ - writeFile(join(storyDir, "story_bible.md"), output.storyBible, "utf-8"), - writeFile(join(storyDir, "volume_outline.md"), output.volumeOutline, "utf-8"), - writeFile(join(storyDir, "book_rules.md"), output.bookRules, "utf-8"), - writeFile(join(storyDir, "current_state.md"), output.currentState, "utf-8"), - writeFile(join(storyDir, "pending_hooks.md"), output.pendingHooks, "utf-8"), - ]; - - if (numericalSystem) { - writes.push( - writeFile( + const outlineDir = join(storyDir, "outline"); + const rolesDir = join(storyDir, "roles"); + const rolesMajorDir = join(rolesDir, "主要角色"); + const rolesMinorDir = join(rolesDir, "次要角色"); + + await Promise.all([ + mkdir(storyDir, { recursive: true }), + mkdir(outlineDir, { recursive: true }), + mkdir(rolesMajorDir, { recursive: true }), + mkdir(rolesMinorDir, { recursive: true }), + ]); + + const writes: Array> = []; + + const storyFrame = output.storyFrame ?? ""; + const volumeMap = output.volumeMap ?? ""; + const rhythmPrinciples = output.rhythmPrinciples ?? ""; + const roles = output.roles ?? []; + const isPhase5Output = storyFrame.trim().length > 0; + + // debug: 让排查时能一眼看出 LLM 按哪套格式输出、落到哪套文件布局。 + // 如果用户新建书后发现只有 story_bible.md / 没有 outline/,看这行日志能 + // 确认是 LLM 没按新 prompt 输出(走了 legacy 分支),而不是 writeFoundationFiles + // 本身的 bug。 + this.ctx.logger?.info( + `[architect] writeFoundationFiles layout=${isPhase5Output ? "phase5" : "legacy"} ` + + `storyFrame=${storyFrame.length}chars volumeMap=${volumeMap.length}chars roles=${roles.length}`, + ); + + if (isPhase5Output) { + // book_rules 的 YAML frontmatter 提取后拼到 story_frame.md 顶部,作为权威位置。 + const { frontmatter: bookRulesFrontmatter, body: bookRulesBody } = + extractYamlFrontmatter(output.bookRules); + const storyFrameWithFrontmatter = bookRulesFrontmatter + ? `${bookRulesFrontmatter}\n\n${storyFrame.trim()}\n` + : storyFrame; + + // Phase 5 权威文件 + writes.push(writeFile(join(outlineDir, "story_frame.md"), storyFrameWithFrontmatter, "utf-8")); + writes.push(writeFile(join(outlineDir, "volume_map.md"), volumeMap, "utf-8")); + if (rhythmPrinciples.trim()) { + const rhythmFileName = language === "en" ? "rhythm_principles.md" : "节奏原则.md"; + writes.push(writeFile(join(outlineDir, rhythmFileName), rhythmPrinciples, "utf-8")); + } + + // revise 模式下先清空旧 role 文件,再写本次输出——避免改名/删除/合并角色 + // 后的旧卡片残留被 readRoleCards 当作有效角色继续注入(见 Bug 3)。 + // 备份由上游 runner.reviseFoundation 在调用前完成,这里可以安全清空。 + // init 模式下目录本来就是空的,不需要清。 + if (mode === "revise") { + await rm(rolesMajorDir, { recursive: true, force: true }); + await rm(rolesMinorDir, { recursive: true, force: true }); + await mkdir(rolesMajorDir, { recursive: true }); + await mkdir(rolesMinorDir, { recursive: true }); + } + + // 一人一卡 + for (const role of roles) { + const targetDir = role.tier === "major" ? rolesMajorDir : rolesMinorDir; + const safeName = role.name.replace(/[/\\:*?"<>|]/g, "_").trim(); + if (!safeName) continue; + writes.push(writeFile(join(targetDir, `${safeName}.md`), role.content, "utf-8")); + } + + // Legacy shim 文件 + writes.push(writeFile( + join(storyDir, "story_bible.md"), + this.buildStoryBibleShim(storyFrameWithFrontmatter, language), + "utf-8", + )); + writes.push(writeFile( + join(storyDir, "volume_outline.md"), + volumeMap, + "utf-8", + )); + writes.push(writeFile( + join(storyDir, "character_matrix.md"), + this.buildCharacterMatrixShim(roles, language), + "utf-8", + )); + writes.push(writeFile( + join(storyDir, "book_rules.md"), + this.buildBookRulesShim(bookRulesBody, language), + "utf-8", + )); + } else { + // Legacy 输出路径:LLM 还按老 prompt 输出 story_bible / volume_outline。 + writes.push(writeFile(join(storyDir, "story_bible.md"), output.storyBible, "utf-8")); + writes.push(writeFile(join(storyDir, "volume_outline.md"), output.volumeOutline, "utf-8")); + writes.push(writeFile(join(storyDir, "book_rules.md"), output.bookRules, "utf-8")); + writes.push(writeFile( + join(storyDir, "character_matrix.md"), + language === "en" + ? "# Character Matrix\n\n\n" + : "# 角色矩阵\n\n\n", + "utf-8", + )); + } + + // 运行时状态文件——**只在 init 模式写**。revise 模式下这些文件已经存在且 + // 被 consolidator 累积了章节状态(伏笔进度、角色位置、资源账本、情感曲线 + // 等),重写会把已写章节的真实状态全部清零,违反 context-transform 里给 + // LLM 的承诺"升级只改架构稿,不动已写的章节"(见 Bug 1)。 + if (mode === "init") { + // current_state.md — 架构师不再产出结构化初始状态,给占位 seed;运行时由 + // consolidator 每章追加。如果 output 里带了内容(legacy 输出或 reviser + // 生成),直接用。 + const currentStateSeed = output.currentState?.trim() + ? output.currentState + : (language === "en" + ? "# Current State\n\n> Seeded at book creation. Runtime state is appended by the consolidator after each chapter. Initial per-character state lives in roles/*.Current_State; load-bearing initial world facts live in pending_hooks rows with start_chapter=0.\n" + : "# 当前状态\n\n> 建书时占位。运行时每章之后由 consolidator 追加最新状态。每个角色的初始状态详见 roles/*.当前现状;承重的初始世界设定见 pending_hooks 里 startChapter=0 的行。\n"); + writes.push(writeFile(join(storyDir, "current_state.md"), currentStateSeed, "utf-8")); + writes.push(writeFile(join(storyDir, "pending_hooks.md"), output.pendingHooks, "utf-8")); + + // 运行时 append log 文件,下游 state-validator / consolidator 依赖这些存在。 + if (numericalSystem) { + writes.push(writeFile( join(storyDir, "particle_ledger.md"), language === "en" ? "# Resource Ledger\n\n| Chapter | Opening Value | Source | Integrity | Delta | Closing Value | Evidence |\n| --- | --- | --- | --- | --- | --- | --- |\n| 0 | 0 | Initialization | - | 0 | 0 | Initial book state |\n" : "# 资源账本\n\n| 章节 | 期初值 | 来源 | 完整度 | 增量 | 期末值 | 依据 |\n|------|--------|------|--------|------|--------|------|\n| 0 | 0 | 初始化 | - | 0 | 0 | 开书初始 |\n", "utf-8", - ), - ); - } - - // Initialize new truth files - writes.push( - writeFile( + )); + } + writes.push(writeFile( join(storyDir, "subplot_board.md"), language === "en" ? "# Subplot Board\n\n| Subplot ID | Subplot | Related Characters | Start Chapter | Last Active Chapter | Chapters Since | Status | Progress Summary | Payoff ETA |\n| --- | --- | --- | --- | --- | --- | --- | --- | --- |\n" : "# 支线进度板\n\n| 支线ID | 支线名 | 相关角色 | 起始章 | 最近活跃章 | 距今章数 | 状态 | 进度概述 | 回收ETA |\n|--------|--------|----------|--------|------------|----------|------|----------|---------|\n", "utf-8", - ), - writeFile( + )); + writes.push(writeFile( join(storyDir, "emotional_arcs.md"), language === "en" ? "# Emotional Arcs\n\n| Character | Chapter | Emotional State | Trigger Event | Intensity (1-10) | Arc Direction |\n| --- | --- | --- | --- | --- | --- |\n" : "# 情感弧线\n\n| 角色 | 章节 | 情绪状态 | 触发事件 | 强度(1-10) | 弧线方向 |\n|------|------|----------|----------|------------|----------|\n", "utf-8", - ), - writeFile( - join(storyDir, "character_matrix.md"), - language === "en" - ? "# Character Matrix\n\n\n" - : "# 角色矩阵\n\n\n", - "utf-8", - ), - ); + )); + } await Promise.all(writes); } @@ -550,7 +881,7 @@ The volume_outline should naturally extend the existing narrative arc. Continue 3. **场景新鲜度**:续写部分至少50%的关键场景发生在导入章节未出现的地点或情境中 4. **不重复会议**:如果导入章节以会议/讨论结束,续写必须从行动开始,不能再开一轮会` : `## 续写方向 -卷纲应自然延续已有叙事弧线。从导入章节的结尾处接续——推进现有冲突、兑现已埋伏笔、引入从当前局势中有机产生的新变数。不要回顾已知信息。`; +卷纲应自然延续已有故事线。从导入章节的结尾处接续——推进现有冲突、兑现已埋伏笔、引入从当前局势中有机产生的新变数。不要回顾已知信息。`; const workingModeEn = isSeries ? `## Working Mode @@ -804,26 +1135,124 @@ ${trimmed}\n`; parsedSections.set(normalizedName, content.slice(start, end).trim()); } - const extract = (name: string): string => { - const section = parsedSections.get(this.normalizeSectionName(name)); - if (!section) { - throw new Error(`Architect output missing required section: ${name}`); - } - if (name !== "pending_hooks") { - return section; - } - return this.normalizePendingHooksSection(this.stripTrailingAssistantCoda(section)); - }; + // Phase 5 新 sections + const storyFrame = parsedSections.get("story_frame") ?? ""; + const volumeMap = parsedSections.get("volume_map") ?? ""; + const rhythmPrinciples = parsedSections.get("rhythm_principles") ?? ""; + const rolesRaw = parsedSections.get("roles") ?? ""; + // Legacy sections — 当 LLM 还按老 prompt 输出时兜底。 + const legacyStoryBible = parsedSections.get("story_bible") ?? ""; + const legacyVolumeOutline = parsedSections.get("volume_outline") ?? ""; + const bookRules = parsedSections.get("book_rules"); + const currentStateLegacy = parsedSections.get("current_state") ?? ""; + const pendingHooksRaw = parsedSections.get("pending_hooks"); + + // 用老名字输出且没有 story_frame/volume_map 时,roles 可空(走 legacy shim fallback)。 + const usingLegacyOutlineNames = !storyFrame && !volumeMap + && (legacyStoryBible.length > 0 || legacyVolumeOutline.length > 0); + + const effectiveStoryFrame = storyFrame || legacyStoryBible; + const effectiveVolumeMap = volumeMap || legacyVolumeOutline; + + const missing: string[] = []; + if (!effectiveStoryFrame) missing.push("story_frame"); + if (!effectiveVolumeMap) missing.push("volume_map"); + if (!rolesRaw.trim() && !usingLegacyOutlineNames) missing.push("roles"); + if (!bookRules) missing.push("book_rules"); + if (!pendingHooksRaw) missing.push("pending_hooks"); + if (missing.length > 0) { + throw new Error( + `Architect output missing required section${missing.length > 1 ? "s" : ""}: ${missing.join(", ")}`, + ); + } + + const roles = this.parseRoles(rolesRaw); + const pendingHooks = this.normalizePendingHooksSection( + this.stripTrailingAssistantCoda(pendingHooksRaw!), + ); + + // Shim-facing 老字段:新 prompt 下用 buildStoryBibleShim 生成指针内容; + // 老 prompt 下直接用老内容。volumeOutline 也同理。 + const storyBible = legacyStoryBible || effectiveStoryFrame; + const volumeOutline = legacyVolumeOutline || effectiveVolumeMap; return { - storyBible: extract("story_bible"), - volumeOutline: extract("volume_outline"), - bookRules: extract("book_rules"), - currentState: extract("current_state"), - pendingHooks: extract("pending_hooks"), + storyBible, + volumeOutline, + bookRules: bookRules!, + // current_state 在 Phase 5 下不是必需 section——writeFoundationFiles 会 seed + // 一个占位文件,consolidator 运行时按章追加。 + currentState: currentStateLegacy, + pendingHooks, + storyFrame: effectiveStoryFrame, + volumeMap: effectiveVolumeMap, + rhythmPrinciples, + roles, }; } + /** Parse ---ROLE---...---CONTENT--- 块。畸形块静默丢弃。 */ + private parseRoles(raw: string): ReadonlyArray { + if (!raw.trim()) return []; + + const blocks = raw.split(/^---ROLE---$/m).map((chunk) => chunk.trim()).filter(Boolean); + const roles: ArchitectRole[] = []; + + for (const block of blocks) { + const contentSplit = block.split(/^---CONTENT---$/m); + if (contentSplit.length < 2) continue; + + const headerRaw = contentSplit[0]!.trim(); + const content = contentSplit.slice(1).join("\n---CONTENT---\n").trim(); + + const tierMatch = headerRaw.match(/tier\s*[::]\s*(major|minor|主要|次要)/i); + const nameMatch = headerRaw.match(/name\s*[::]\s*(.+)/i); + if (!tierMatch || !nameMatch) continue; + + const tierValue = tierMatch[1]!.toLowerCase(); + const tier: "major" | "minor" = (tierValue === "major" || tierValue === "主要") ? "major" : "minor"; + const name = nameMatch[1]!.trim(); + if (!name || !content) continue; + + roles.push({ tier, name, content }); + } + + return roles; + } + + private buildStoryBibleShim(storyFrame: string, language: "zh" | "en"): string { + if (language === "en") { + return `# Story Bible (compat pointer — deprecated)\n\n> This file is kept for external readers only. The authoritative source is now:\n> - outline/story_frame.md (theme / tonal ground / core conflict / world rules / endgame)\n> - outline/volume_map.md (volume-level plot map)\n> - roles/ directory (one-file-per-character sheets)\n\n## Excerpt from story_frame\n\n${storyFrame.slice(0, 2000)}\n`; + } + return `# 故事圣经(兼容指针——已废弃)\n\n> 本文件仅为外部读取保留。权威来源已迁移至:\n> - outline/story_frame.md(主题 / 基调 / 核心冲突 / 世界铁律 / 终局)\n> - outline/volume_map.md(卷级分卷地图)\n> - roles/ 文件夹(一人一卡角色档案)\n\n## story_frame 摘录\n\n${storyFrame.slice(0, 2000)}\n`; + } + + private buildCharacterMatrixShim(roles: ReadonlyArray, language: "zh" | "en"): string { + const majorLines = roles.filter((role) => role.tier === "major") + .map((role) => `- roles/主要角色/${role.name}.md`); + const minorLines = roles.filter((role) => role.tier === "minor") + .map((role) => `- roles/次要角色/${role.name}.md`); + + if (language === "en") { + return `# Character Matrix (compat pointer — deprecated)\n\n> This file is kept for external readers only. Authoritative source is now the roles/ directory (one-file-per-character).\n\n## Major characters\n\n${majorLines.join("\n") || "(none)"}\n\n## Minor characters\n\n${minorLines.join("\n") || "(none)"}\n`; + } + return `# 角色矩阵(兼容指针——已废弃)\n\n> 本文件仅为外部读取保留。权威来源已迁移至 roles/ 文件夹(一人一卡)。\n\n## 主要角色\n\n${majorLines.join("\n") || "(无)"}\n\n## 次要角色\n\n${minorLines.join("\n") || "(无)"}\n`; + } + + private buildBookRulesShim(bookRulesBody: string, language: "zh" | "en"): string { + const trimmedBody = bookRulesBody.trim(); + if (language === "en") { + const excerpt = trimmedBody + ? `\n\n## Narrative guidance excerpt\n\n${trimmedBody}\n` + : ""; + return `# Book Rules (compat pointer — deprecated)\n\n> This file is kept for external readers only. The authoritative YAML frontmatter now lives at the top of outline/story_frame.md.${excerpt}`; + } + const excerpt = trimmedBody + ? `\n\n## 叙事指引摘录\n\n${trimmedBody}\n` + : ""; + return `# 本书规则(兼容指针——已废弃)\n\n> 本文件仅为外部读取保留。权威 YAML frontmatter 已迁移至 outline/story_frame.md 顶部。${excerpt}`; + } + private normalizeSectionName(name: string): string { return name .normalize("NFKC") diff --git a/packages/core/src/agents/chapter-analyzer.ts b/packages/core/src/agents/chapter-analyzer.ts index 9364cd50..8a0678b0 100644 --- a/packages/core/src/agents/chapter-analyzer.ts +++ b/packages/core/src/agents/chapter-analyzer.ts @@ -3,6 +3,7 @@ import type { BookConfig } from "../models/book.js"; import type { GenreProfile } from "../models/genre-profile.js"; import type { ContextPackage, RuleStack } from "../models/input-governance.js"; import { readGenreProfile, readBookRules } from "./rules-reader.js"; +import { readStoryFrame, readVolumeMap, readCharacterContext, readCurrentStateWithFallback } from "../utils/outline-paths.js"; import { parseWriterOutput, type ParsedWriterOutput } from "./writer-parser.js"; import { buildGovernedMemoryEvidenceBlocks } from "../utils/governed-context.js"; import { @@ -45,14 +46,14 @@ export class ChapterAnalyzerAgent extends BaseAgent { subplotBoard, emotionalArcs, characterMatrix, storyBible, volumeOutline, ] = await Promise.all([ - this.readFileOrDefault(join(bookDir, "story/current_state.md"), resolvedLanguage), + readCurrentStateWithFallback(bookDir), this.readFileOrDefault(join(bookDir, "story/particle_ledger.md"), resolvedLanguage), this.readFileOrDefault(join(bookDir, "story/pending_hooks.md"), resolvedLanguage), this.readFileOrDefault(join(bookDir, "story/subplot_board.md"), resolvedLanguage), this.readFileOrDefault(join(bookDir, "story/emotional_arcs.md"), resolvedLanguage), - this.readFileOrDefault(join(bookDir, "story/character_matrix.md"), resolvedLanguage), - this.readFileOrDefault(join(bookDir, "story/story_bible.md"), resolvedLanguage), - this.readFileOrDefault(join(bookDir, "story/volume_outline.md"), resolvedLanguage), + readCharacterContext(bookDir), + readStoryFrame(bookDir), + readVolumeMap(bookDir), ]); const parsedBookRules = await readBookRules(bookDir); const bookRulesBody = parsedBookRules?.body ?? ""; diff --git a/packages/core/src/agents/consolidator.ts b/packages/core/src/agents/consolidator.ts index 98767332..4a0d412d 100644 --- a/packages/core/src/agents/consolidator.ts +++ b/packages/core/src/agents/consolidator.ts @@ -1,6 +1,7 @@ import { BaseAgent } from "./base.js"; import { readFile, writeFile, mkdir } from "node:fs/promises"; import { join } from "node:path"; +import { readVolumeMap } from "../utils/outline-paths.js"; export interface ConsolidationResult { readonly volumeSummaries: string; @@ -26,12 +27,11 @@ export class ConsolidatorAgent extends BaseAgent { async consolidate(bookDir: string): Promise { const storyDir = join(bookDir, "story"); const summariesPath = join(storyDir, "chapter_summaries.md"); - const outlinePath = join(storyDir, "volume_outline.md"); const volumeSummariesPath = join(storyDir, "volume_summaries.md"); const [summariesRaw, outlineRaw] = await Promise.all([ readFile(summariesPath, "utf-8").catch(() => ""), - readFile(outlinePath, "utf-8").catch(() => ""), + readVolumeMap(bookDir), ]); if (!summariesRaw || !outlineRaw) { diff --git a/packages/core/src/agents/continuity.ts b/packages/core/src/agents/continuity.ts index dbafccf4..78ebaa8a 100644 --- a/packages/core/src/agents/continuity.ts +++ b/packages/core/src/agents/continuity.ts @@ -6,6 +6,7 @@ import type { ContextPackage, RuleStack } from "../models/input-governance.js"; import { readGenreProfile, readBookLanguage, readBookRules } from "./rules-reader.js"; import { getFanficDimensionConfig, FANFIC_DIMENSIONS } from "./fanfic-dimensions.js"; import { readFile, readdir } from "node:fs/promises"; +import { readCurrentStateWithFallback, readCharacterContext, readVolumeMap } from "../utils/outline-paths.js"; import { filterHooks, filterSummaries, filterSubplots, filterEmotionalArcs, filterCharacterMatrix } from "../utils/context-filter.js"; import { buildGovernedMemoryEvidenceBlocks } from "../utils/governed-context.js"; import { join } from "node:path"; @@ -334,17 +335,17 @@ export class ContinuityAuditor extends BaseAgent { ): Promise { const [diskCurrentState, diskLedger, diskHooks, styleGuideRaw, subplotBoard, emotionalArcs, characterMatrix, chapterSummaries, parentCanon, fanficCanon, volumeOutline] = await Promise.all([ - this.readFileSafe(join(bookDir, "story/current_state.md")), + readCurrentStateWithFallback(bookDir), this.readFileSafe(join(bookDir, "story/particle_ledger.md")), this.readFileSafe(join(bookDir, "story/pending_hooks.md")), this.readFileSafe(join(bookDir, "story/style_guide.md")), this.readFileSafe(join(bookDir, "story/subplot_board.md")), this.readFileSafe(join(bookDir, "story/emotional_arcs.md")), - this.readFileSafe(join(bookDir, "story/character_matrix.md")), + readCharacterContext(bookDir), this.readFileSafe(join(bookDir, "story/chapter_summaries.md")), this.readFileSafe(join(bookDir, "story/parent_canon.md")), this.readFileSafe(join(bookDir, "story/fanfic_canon.md")), - this.readFileSafe(join(bookDir, "story/volume_outline.md")), + readVolumeMap(bookDir), ]); const currentState = options?.truthFileOverrides?.currentState ?? diskCurrentState; const ledger = options?.truthFileOverrides?.ledger ?? diskLedger; diff --git a/packages/core/src/agents/planner.ts b/packages/core/src/agents/planner.ts index e41870f4..8dbc8e05 100644 --- a/packages/core/src/agents/planner.ts +++ b/packages/core/src/agents/planner.ts @@ -1,6 +1,7 @@ import { readFile, writeFile, mkdir } from "node:fs/promises"; import { join } from "node:path"; import { BaseAgent } from "./base.js"; +import { readStoryFrame, readVolumeMap, readCurrentStateWithFallback } from "../utils/outline-paths.js"; import type { BookConfig } from "../models/book.js"; import { parseBookRules } from "../models/book-rules.js"; import { ChapterIntentSchema, type ChapterConflict, type ChapterIntent } from "../models/input-governance.js"; @@ -58,11 +59,11 @@ export class PlannerAgent extends BaseAgent { ] = await Promise.all([ this.readFileOrDefault(sourcePaths.authorIntent), this.readFileOrDefault(sourcePaths.currentFocus), - this.readFileOrDefault(sourcePaths.storyBible), - this.readFileOrDefault(sourcePaths.volumeOutline), + readStoryFrame(input.bookDir), + readVolumeMap(input.bookDir), this.readFileOrDefault(sourcePaths.chapterSummaries), this.readFileOrDefault(sourcePaths.bookRules), - this.readFileOrDefault(sourcePaths.currentState), + readCurrentStateWithFallback(input.bookDir), ]); const outlineNode = this.findOutlineNode(volumeOutline, input.chapterNumber); diff --git a/packages/core/src/agents/reviser.ts b/packages/core/src/agents/reviser.ts index 48ebb573..2e638bd1 100644 --- a/packages/core/src/agents/reviser.ts +++ b/packages/core/src/agents/reviser.ts @@ -5,6 +5,7 @@ import type { LengthSpec } from "../models/length-governance.js"; import type { AuditIssue } from "./continuity.js"; import type { ContextPackage, RuleStack } from "../models/input-governance.js"; import { readGenreProfile, readBookLanguage, readBookRules } from "./rules-reader.js"; +import { readStoryFrame, readVolumeMap, readCharacterContext, readCurrentStateWithFallback } from "../utils/outline-paths.js"; import { countChapterLength } from "../utils/length-metrics.js"; import { buildGovernedMemoryEvidenceBlocks } from "../utils/governed-context.js"; import { filterSummaries } from "../utils/context-filter.js"; @@ -74,13 +75,13 @@ export class ReviserAgent extends BaseAgent { }, ): Promise { const [currentState, ledger, hooks, styleGuideRaw, volumeOutline, storyBible, characterMatrix, chapterSummaries, parentCanon, fanficCanon] = await Promise.all([ - this.readFileSafe(join(bookDir, "story/current_state.md")), + readCurrentStateWithFallback(bookDir), this.readFileSafe(join(bookDir, "story/particle_ledger.md")), this.readFileSafe(join(bookDir, "story/pending_hooks.md")), this.readFileSafe(join(bookDir, "story/style_guide.md")), - this.readFileSafe(join(bookDir, "story/volume_outline.md")), - this.readFileSafe(join(bookDir, "story/story_bible.md")), - this.readFileSafe(join(bookDir, "story/character_matrix.md")), + readVolumeMap(bookDir), + readStoryFrame(bookDir), + readCharacterContext(bookDir), this.readFileSafe(join(bookDir, "story/chapter_summaries.md")), this.readFileSafe(join(bookDir, "story/parent_canon.md")), this.readFileSafe(join(bookDir, "story/fanfic_canon.md")), diff --git a/packages/core/src/agents/writer.ts b/packages/core/src/agents/writer.ts index 997011b5..993fa838 100644 --- a/packages/core/src/agents/writer.ts +++ b/packages/core/src/agents/writer.ts @@ -8,6 +8,7 @@ import { buildObserverSystemPrompt, buildObserverUserPrompt } from "./observer-p import { parseSettlerDeltaOutput } from "./settler-delta-parser.js"; import { parseSettlementOutput } from "./settler-parser.js"; import { readGenreProfile, readBookRules } from "./rules-reader.js"; +import { readStoryFrame, readVolumeMap, readCharacterContext, readCurrentStateWithFallback } from "../utils/outline-paths.js"; import { detectCrossChapterRepetition, detectParagraphLengthDrift, @@ -123,16 +124,16 @@ export class WriterAgent extends BaseAgent { chapterSummaries, subplotBoard, emotionalArcs, characterMatrix, styleProfileRaw, parentCanon, fanficCanonRaw, ] = await Promise.all([ - this.readFileOrDefault(join(bookDir, "story/story_bible.md")), - this.readFileOrDefault(join(bookDir, "story/volume_outline.md")), + readStoryFrame(bookDir), + readVolumeMap(bookDir), this.readFileOrDefault(join(bookDir, "story/style_guide.md")), - this.readFileOrDefault(join(bookDir, "story/current_state.md")), + readCurrentStateWithFallback(bookDir), this.readFileOrDefault(join(bookDir, "story/particle_ledger.md")), this.readFileOrDefault(join(bookDir, "story/pending_hooks.md")), this.readFileOrDefault(join(bookDir, "story/chapter_summaries.md")), this.readFileOrDefault(join(bookDir, "story/subplot_board.md")), this.readFileOrDefault(join(bookDir, "story/emotional_arcs.md")), - this.readFileOrDefault(join(bookDir, "story/character_matrix.md")), + readCharacterContext(bookDir), this.readFileOrDefault(join(bookDir, "story/style_profile.json")), this.readFileOrDefault(join(bookDir, "story/parent_canon.md")), this.readFileOrDefault(join(bookDir, "story/fanfic_canon.md")), @@ -419,14 +420,14 @@ export class WriterAgent extends BaseAgent { characterMatrix, volumeOutline, ] = await Promise.all([ - this.readFileOrDefault(join(input.bookDir, "story/current_state.md")), + readCurrentStateWithFallback(input.bookDir), this.readFileOrDefault(join(input.bookDir, "story/particle_ledger.md")), this.readFileOrDefault(join(input.bookDir, "story/pending_hooks.md")), this.readFileOrDefault(join(input.bookDir, "story/chapter_summaries.md")), this.readFileOrDefault(join(input.bookDir, "story/subplot_board.md")), this.readFileOrDefault(join(input.bookDir, "story/emotional_arcs.md")), - this.readFileOrDefault(join(input.bookDir, "story/character_matrix.md")), - this.readFileOrDefault(join(input.bookDir, "story/volume_outline.md")), + readCharacterContext(input.bookDir), + readVolumeMap(input.bookDir), ]); const { profile: genreProfile } = await readGenreProfile(this.ctx.projectRoot, input.book.genre); diff --git a/packages/core/src/llm/provider.ts b/packages/core/src/llm/provider.ts index 09f4deca..b804b5fd 100644 --- a/packages/core/src/llm/provider.ts +++ b/packages/core/src/llm/provider.ts @@ -93,8 +93,21 @@ export interface LLMClient { readonly _apiKey?: string; readonly defaults: { readonly temperature: number; + /** + * Per-call fallback: 当 agent 调 chat() 不传 options.maxTokens 时用这个值。 + * 不是硬上限——per-call 显式传的值会覆盖它,不会被它限制。 + */ readonly maxTokens: number; - readonly maxTokensCap: number | null; // non-null only when user explicitly configured + /** + * Per-call 硬上限。null 表示不封顶;非 null 表示给 chat() 的 per-call + * maxTokens 加一个 Math.min(perCall, cap) 约束。 + * + * 语义必须跟 defaults.maxTokens 严格分开:maxTokens 是 fallback, + * maxTokensCap 是 cap。旧实现把两者用同一个数推导,导致 agent per-call 16384 + * 被 config.maxTokens=8192 误裁(见 tests/__tests__/provider.test.ts 的 + * "per-call maxTokens is not capped by config.maxTokens" 回归测试)。 + */ + readonly maxTokensCap: number | null; readonly thinkingBudget: number; readonly extra: Record; }; @@ -130,8 +143,12 @@ export interface ChatWithToolsResult { export function createLLMClient(config: LLMConfig): LLMClient { const defaults = { temperature: config.temperature ?? 0.7, + // fallback: agent 没传 per-call 时用这个 maxTokens: config.maxTokens ?? 8192, - maxTokensCap: config.maxTokens ?? null, // only cap when user explicitly set maxTokens + // cap: 只在用户显式配 maxTokensCap 时生效;默认 null = 不封顶 per-call。 + // **禁止**改成 `config.maxTokens ?? null` —— 那样会让 architect 的 per-call + // 16384 被用户 config.maxTokens=8192 自动裁剪,基础设定输出会被截断。 + maxTokensCap: config.maxTokensCap ?? null, thinkingBudget: config.thinkingBudget ?? 0, extra: config.extra ?? {}, }; diff --git a/packages/core/src/models/project.ts b/packages/core/src/models/project.ts index 46d606c5..f08527e0 100644 --- a/packages/core/src/models/project.ts +++ b/packages/core/src/models/project.ts @@ -19,6 +19,13 @@ export const LLMConfigSchema = z.object({ model: z.string().min(1), temperature: z.number().min(0).max(2).default(0.7), maxTokens: z.number().int().min(1).default(8192), + // per-call 硬上限。默认 undefined → createLLMClient 里变 null → 不封顶 per-call。 + // 只有用户确实要限制单次调用最大输出时才显式设。注意:这个字段跟 maxTokens + // 语义不同:maxTokens 是"agent 没传 per-call 时的 fallback",maxTokensCap + // 是"给 agent per-call 加硬上限"。分开成两个字段是为了避免旧实现下 + // config.maxTokens 同时被当 fallback 和 cap 的歧义(旧行为会把 architect + // per-call 16384 裁到 config.maxTokens=8192,导致基础设定输出被截断)。 + maxTokensCap: z.number().int().min(1).optional(), thinkingBudget: z.number().int().min(0).default(0), extra: z.record(z.unknown()).optional(), headers: z.record(z.string()).optional(), diff --git a/packages/core/src/pipeline/runner.ts b/packages/core/src/pipeline/runner.ts index 7735dc4f..f816a6bd 100644 --- a/packages/core/src/pipeline/runner.ts +++ b/packages/core/src/pipeline/runner.ts @@ -35,6 +35,7 @@ import { loadNarrativeMemorySeed, loadSnapshotCurrentStateFacts } from "../state import { rewriteStructuredStateFromMarkdown } from "../state/state-bootstrap.js"; import { readFile, readdir, writeFile, mkdir, rename, rm, stat } from "node:fs/promises"; import { join } from "node:path"; +import { readStoryFrame, readVolumeMap, readCharacterContext, readCurrentStateWithFallback, isNewLayoutBook } from "../utils/outline-paths.js"; import { parseStateDegradedReviewNote, resolveStateDegradedBaseStatus, @@ -513,6 +514,170 @@ export class PipelineRunner { } } + /** + * Revise an existing book's foundation — 把已有书的架构稿重写(legacy 条目式升级 + * 到段落式 / Phase 5 书按 feedback 再次调整细节),把架构稿相关文件备份到 + * story/.backup--/。 + * + * 关键约束:**只改架构稿文件**(outline/ + roles/ + 4 个 legacy shim), + * 不动任何运行时状态文件(current_state / pending_hooks / particle_ledger / + * subplot_board / emotional_arcs)—— 跟 context-transform 里给 LLM 的 upgrade + * hint 承诺"升级只改架构稿,不动已写的章节"保持一致。 + * + * 两种来源: + * - legacy 书(没有 outline/story_frame.md):读 story_bible.md 等 4 个 + * flat 文件作为原内容 + * - Phase 5 书(outline/story_frame.md 已存在):读 outline/ + roles/ 的 + * 权威内容作为原内容——**不能读 flat 文件**,那些是 shim(只有指针 + 2000 + * 字摘录)会丢信息 + */ + async reviseFoundation(bookId: string, feedback: string): Promise { + const bookDir = this.state.bookDir(bookId); + const storyDir = join(bookDir, "story"); + const isPhase5 = await isNewLayoutBook(bookDir); + + // 1. 备份架构稿相关文件(不包含运行时状态文件,那些不会被动) + const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); + const backupTag = isPhase5 ? "phase5" : "phase4"; + const backupDir = join(storyDir, `.backup-${backupTag}-${timestamp}`); + await mkdir(backupDir, { recursive: true }); + + // 备份 legacy flat 文件(两种书都有这几个——legacy 是权威,Phase 5 是 shim) + const flatFiles = ["story_bible.md", "volume_outline.md", "book_rules.md", "character_matrix.md"]; + for (const fileName of flatFiles) { + try { + const content = await readFile(join(storyDir, fileName), "utf-8"); + await writeFile(join(backupDir, fileName), content, "utf-8"); + } catch { + /* 文件不存在就跳过 */ + } + } + + // Phase 5 书还要备份权威来源:outline/ 和 roles/ 两个目录 + if (isPhase5) { + await this.copyDirShallow(join(storyDir, "outline"), join(backupDir, "outline")); + await this.copyDirRecursive(join(storyDir, "roles"), join(backupDir, "roles")); + } + + // 2. 读原内容作为 reviseFrom 输入 —— 必须从权威源读 + const book = await this.state.loadBookConfig(bookId); + let oldStoryBible: string, oldVolumeOutline: string, oldBookRules: string, oldCharacterMatrix: string; + + if (isPhase5) { + // Phase 5 书的 story_bible.md / volume_outline.md / character_matrix.md + // 都是 shim(只有指针 + 摘录),信息不完整。从 outline-paths helper 读 + // 权威文件:outline/story_frame.md / outline/volume_map.md / roles/**/*.md + [oldStoryBible, oldVolumeOutline, oldCharacterMatrix] = await Promise.all([ + readStoryFrame(bookDir), + readVolumeMap(bookDir), + readCharacterContext(bookDir), + ]); + // book_rules.md 在 Phase 5 下虽然是 shim,但 YAML frontmatter 已经搬到 + // story_frame.md 顶部了(writeFoundationFiles 做的合并),这里读 shim + // 文件 OK——里面的"叙事指引摘录"部分是唯一遗留信息。 + oldBookRules = await readFile(join(storyDir, "book_rules.md"), "utf-8").catch(() => ""); + } else { + // legacy 书:4 个 flat 文件就是权威 + [oldStoryBible, oldVolumeOutline, oldBookRules, oldCharacterMatrix] = await Promise.all([ + readFile(join(storyDir, "story_bible.md"), "utf-8").catch(() => ""), + readFile(join(storyDir, "volume_outline.md"), "utf-8").catch(() => ""), + readFile(join(storyDir, "book_rules.md"), "utf-8").catch(() => ""), + readFile(join(storyDir, "character_matrix.md"), "utf-8").catch(() => ""), + ]); + } + + // 3. 架构师按 reviseFrom + feedback 重写 + const architect = new ArchitectAgent(this.agentCtxFor("architect", bookId)); + const foundation = await architect.generateFoundation(book, undefined, undefined, { + reviseFrom: { + storyBible: oldStoryBible, + volumeOutline: oldVolumeOutline, + bookRules: oldBookRules, + characterMatrix: oldCharacterMatrix, + userFeedback: feedback, + }, + }); + + // 4. 可选 foundation-reviewer 审核——挂了只 warn 不阻断 + const reviewer = new FoundationReviewerAgent(this.agentCtxFor("foundation-reviewer", bookId)); + const resolvedLanguage = (book.language ?? "zh") === "en" ? "en" as const : "zh" as const; + try { + const review = await reviewer.review({ + foundation, + mode: "original", + language: resolvedLanguage, + } as Parameters[0]); + if (!review.passed) { + this.config.logger?.warn?.( + `[reviseFoundation] 审核未通过,仍接受转换结果。反馈:${review.overallFeedback ?? ""}`, + ); + } + } catch (error) { + this.config.logger?.warn?.( + `[reviseFoundation] 审核调用失败,跳过:${error instanceof Error ? error.message : String(error)}`, + ); + } + + // 5. 目录补全 → 写新文件(mode="revise" 不动运行时状态 + 清空旧 role 文件) + const outlineDir = join(storyDir, "outline"); + await mkdir(outlineDir, { recursive: true }); + await mkdir(join(storyDir, "roles", "主要角色"), { recursive: true }); + await mkdir(join(storyDir, "roles", "次要角色"), { recursive: true }); + + const { profile: gp } = await this.loadGenreProfile(book.genre); + await architect.writeFoundationFiles( + bookDir, + foundation, + gp.numericalSystem, + book.language ?? gp.language, + "revise", + ); + } + + /** Shallow copy (non-recursive) — used to back up outline/ which has no subdirs. */ + private async copyDirShallow(src: string, dest: string): Promise { + try { + await mkdir(dest, { recursive: true }); + const entries = await readdir(src); + await Promise.all( + entries.map(async (entry) => { + try { + const content = await readFile(join(src, entry), "utf-8"); + await writeFile(join(dest, entry), content, "utf-8"); + } catch { + /* skip files we can't read */ + } + }), + ); + } catch { + /* src doesn't exist → nothing to back up */ + } + } + + /** Recursive copy — used to back up roles/ which has 主要角色/ + 次要角色/. */ + private async copyDirRecursive(src: string, dest: string): Promise { + try { + await mkdir(dest, { recursive: true }); + const entries = await readdir(src, { withFileTypes: true }); + for (const entry of entries) { + const srcPath = join(src, entry.name); + const destPath = join(dest, entry.name); + if (entry.isDirectory()) { + await this.copyDirRecursive(srcPath, destPath); + } else if (entry.isFile()) { + try { + const content = await readFile(srcPath, "utf-8"); + await writeFile(destPath, content, "utf-8"); + } catch { + /* skip */ + } + } + } + } catch { + /* src doesn't exist */ + } + } + /** Import external source material and generate fanfic_canon.md */ async importFanficCanon( bookId: string, @@ -1119,8 +1284,8 @@ export class PipelineRunner { readSafe(join(storyDir, "current_state.md")), readSafe(join(storyDir, "particle_ledger.md")), readSafe(join(storyDir, "pending_hooks.md")), - readSafe(join(storyDir, "story_bible.md")), - readSafe(join(storyDir, "volume_outline.md")), + readStoryFrame(bookDir, "(文件不存在)"), + readVolumeMap(bookDir, "(文件不存在)"), readSafe(join(storyDir, "book_rules.md")), ]); @@ -1847,14 +2012,14 @@ export class PipelineRunner { const [storyBible, currentState, ledger, hooks, summaries, subplots, emotions, matrix] = await Promise.all([ - readSafe(join(parentDir, "story/story_bible.md")), - readSafe(join(parentDir, "story/current_state.md")), + readStoryFrame(parentDir, "(无)"), + readCurrentStateWithFallback(parentDir, "(无)"), readSafe(join(parentDir, "story/particle_ledger.md")), readSafe(join(parentDir, "story/pending_hooks.md")), readSafe(join(parentDir, "story/chapter_summaries.md")), readSafe(join(parentDir, "story/subplot_board.md")), readSafe(join(parentDir, "story/emotional_arcs.md")), - readSafe(join(parentDir, "story/character_matrix.md")), + readCharacterContext(parentDir, "(无)"), ]); const response = await chatCompletion(this.config.client, this.config.model, [ diff --git a/packages/core/src/state/manager.ts b/packages/core/src/state/manager.ts index 52a37a23..e709b85d 100644 --- a/packages/core/src/state/manager.ts +++ b/packages/core/src/state/manager.ts @@ -34,9 +34,15 @@ export class StateManager { ): Promise { const storyDir = join(bookDir, "story"); const runtimeDir = join(storyDir, "runtime"); + const outlineDir = join(storyDir, "outline"); + const rolesMajorDir = join(storyDir, "roles", "主要角色"); + const rolesMinorDir = join(storyDir, "roles", "次要角色"); await mkdir(storyDir, { recursive: true }); await mkdir(runtimeDir, { recursive: true }); + await mkdir(outlineDir, { recursive: true }); + await mkdir(rolesMajorDir, { recursive: true }); + await mkdir(rolesMinorDir, { recursive: true }); await this.writeIfMissing( join(storyDir, "author_intent.md"), diff --git a/packages/core/src/utils/memory-retrieval.ts b/packages/core/src/utils/memory-retrieval.ts index 88d5e113..761d17aa 100644 --- a/packages/core/src/utils/memory-retrieval.ts +++ b/packages/core/src/utils/memory-retrieval.ts @@ -1,5 +1,6 @@ import { readFile } from "node:fs/promises"; import { join } from "node:path"; +import { readCurrentStateWithFallback } from "./outline-paths.js"; import { ChapterSummariesStateSchema, CurrentStateStateSchema, @@ -71,7 +72,7 @@ export async function retrieveMemorySelection(params: { structuredHooks, structuredSummaries, ] = await Promise.all([ - readFile(join(storyDir, "current_state.md"), "utf-8").catch(() => ""), + readCurrentStateWithFallback(params.bookDir), readFile(join(storyDir, "volume_summaries.md"), "utf-8").catch(() => ""), readStructuredState(join(stateDir, "current_state.json"), CurrentStateStateSchema), readStructuredState(join(stateDir, "hooks.json"), HooksStateSchema), diff --git a/packages/core/src/utils/outline-paths.ts b/packages/core/src/utils/outline-paths.ts new file mode 100644 index 00000000..5b08636b --- /dev/null +++ b/packages/core/src/utils/outline-paths.ts @@ -0,0 +1,275 @@ +/** + * Phase 5 (v13) path resolution — prefer the new prose outline files, fall + * back to legacy paths so older books keep working during transition. + * + * Maps: + * story/outline/story_frame.md → preferred replacement for story_bible.md + * story/outline/volume_map.md → preferred replacement for volume_outline.md + * story/roles/主要角色/*.md + + * story/roles/次要角色/*.md → preferred replacement for character_matrix.md + * + * All helpers accept a bookDir (path to a book root, containing `story/`) + * and return a string — either the new-file content when it exists, or the + * legacy file content, or an empty default placeholder. + */ + +import { readFile, readdir, access } from "node:fs/promises"; +import { join } from "node:path"; + +/** + * Detect whether a book uses the Phase 5 new layout (outline/story_frame.md + * exists on disk). If yes, story_bible.md / book_rules.md are compat shims. + * If no, those files ARE the authoritative source. + */ +export async function isNewLayoutBook(bookDir: string): Promise { + try { + await access(join(bookDir, "story", "outline", "story_frame.md")); + return true; + } catch { + return false; + } +} + +async function readOr(path: string, fallback: string): Promise { + try { + return await readFile(path, "utf-8"); + } catch { + return fallback; + } +} + +/** Read story_frame.md, falling back to legacy story_bible.md. */ +export async function readStoryFrame( + bookDir: string, + fallbackPlaceholder: string = "", +): Promise { + const newPath = join(bookDir, "story", "outline", "story_frame.md"); + const legacyPath = join(bookDir, "story", "story_bible.md"); + + const newContent = await readOr(newPath, ""); + if (newContent.trim()) return newContent; + + return readOr(legacyPath, fallbackPlaceholder); +} + +/** Read volume_map.md, falling back to legacy volume_outline.md. */ +export async function readVolumeMap( + bookDir: string, + fallbackPlaceholder: string = "", +): Promise { + const newPath = join(bookDir, "story", "outline", "volume_map.md"); + const legacyPath = join(bookDir, "story", "volume_outline.md"); + + const newContent = await readOr(newPath, ""); + if (newContent.trim()) return newContent; + + return readOr(legacyPath, fallbackPlaceholder); +} + +/** Read the rhythm principles file (zh or en variant). */ +export async function readRhythmPrinciples(bookDir: string): Promise { + const zhPath = join(bookDir, "story", "outline", "节奏原则.md"); + const enPath = join(bookDir, "story", "outline", "rhythm_principles.md"); + + const zh = await readOr(zhPath, ""); + if (zh.trim()) return zh; + return readOr(enPath, ""); +} + +export interface RoleCard { + readonly tier: "major" | "minor"; + readonly name: string; + readonly content: string; +} + +/** + * Read the roles/ directory. Returns [] when no roles are present (e.g. old + * books still on character_matrix.md). + */ +export async function readRoleCards(bookDir: string): Promise> { + const rolesRoot = join(bookDir, "story", "roles"); + const majorDirZh = join(rolesRoot, "主要角色"); + const minorDirZh = join(rolesRoot, "次要角色"); + const majorDirEn = join(rolesRoot, "major"); + const minorDirEn = join(rolesRoot, "minor"); + + const cards: RoleCard[] = []; + await Promise.all([ + collectRoleDir(majorDirZh, "major", cards), + collectRoleDir(minorDirZh, "minor", cards), + collectRoleDir(majorDirEn, "major", cards), + collectRoleDir(minorDirEn, "minor", cards), + ]); + return cards; +} + +async function collectRoleDir( + dir: string, + tier: "major" | "minor", + out: RoleCard[], +): Promise { + let entries: string[]; + try { + entries = await readdir(dir); + } catch { + return; + } + const reads = entries + .filter((entry) => entry.endsWith(".md")) + .map(async (entry) => { + const content = await readOr(join(dir, entry), ""); + if (!content.trim()) return; + out.push({ + tier, + name: entry.replace(/\.md$/, ""), + content, + }); + }); + await Promise.all(reads); +} + +/** + * Render role cards in a format compatible with downstream consumers that + * previously expected character_matrix.md prose. When no role cards exist, + * returns the legacy character_matrix.md content or the placeholder. + */ +export async function readCharacterContext( + bookDir: string, + fallbackPlaceholder: string = "", +): Promise { + const cards = await readRoleCards(bookDir); + if (cards.length > 0) { + const groups: Record<"major" | "minor", RoleCard[]> = { major: [], minor: [] }; + for (const card of cards) groups[card.tier].push(card); + + const render = (tierCards: RoleCard[], heading: string): string => { + if (tierCards.length === 0) return ""; + const sections = tierCards.map((card) => `### ${card.name}\n\n${card.content.trim()}`); + return `## ${heading}\n\n${sections.join("\n\n")}`; + }; + + const blocks = [ + render(groups.major, "主要角色 / Major characters"), + render(groups.minor, "次要角色 / Minor characters"), + ].filter(Boolean); + + return blocks.join("\n\n"); + } + + // Fallback: legacy character_matrix.md (may itself be a shim pointer). + const legacyPath = join(bookDir, "story", "character_matrix.md"); + return readOr(legacyPath, fallbackPlaceholder); +} + +// --------------------------------------------------------------------------- +// Phase 5 consolidation: current_state.md initial fallback +// +// After architect consolidation (7→5 sections), current_state.md is seeded +// with a tiny placeholder at book creation. Real content only arrives once +// the consolidator has appended output from chapter 1 onward. Readers that +// previously relied on architect-provided initial state (writer phase-1 +// creative prompt, continuity, chapter-analyzer, reviser, composer) should +// substitute a derived initial-state block when the seed placeholder is all +// that's on disk — otherwise the "## 当前状态卡" block in prompts degenerates +// into a meta note about runtime append behaviour. +// --------------------------------------------------------------------------- + +/** + * Marker substring emitted by architect.writeFoundationFiles when seeding + * current_state.md. Its presence is how readers detect "nothing real yet". + */ +const CURRENT_STATE_SEED_MARKERS = [ + "建书时占位", + "Seeded at book creation", +]; + +export function isCurrentStateSeedPlaceholder(raw: string): boolean { + const trimmed = raw.trim(); + if (!trimmed) return true; + // Heuristic: short file AND contains one of the seed markers. + if (trimmed.length > 600) return false; + return CURRENT_STATE_SEED_MARKERS.some((marker) => trimmed.includes(marker)); +} + +function extractCurrentStateFromRole(content: string): string | null { + // Accept both zh (`## 当前现状`) and en (`## Current_State` / `## Current State`). + const pattern = /^##\s*(?:当前现状|Current[_\s]?State)[^\n]*$/im; + const match = content.match(pattern); + if (!match || match.index === undefined) return null; + const after = content.slice(match.index + match[0].length); + // Cut at next `## ` heading (same or higher level). + const nextHeading = after.search(/^##\s/m); + const raw = nextHeading >= 0 ? after.slice(0, nextHeading) : after; + const text = raw.trim(); + return text.length > 0 ? text : null; +} + +function extractSeedHooksFromPendingHooks(raw: string): string[] { + if (!raw.trim()) return []; + const lines = raw.split("\n").map((line) => line.trim()).filter(Boolean); + const seedRows: string[] = []; + for (const line of lines) { + if (!line.startsWith("|")) continue; + if (/^\|\s*-/.test(line)) continue; + const cells = line.split("|").slice(1, -1).map((cell) => cell.trim()); + if (cells.length < 2) continue; + if (cells[0]?.toLowerCase() === "hook_id" || cells[0] === "hookId") continue; + const startCh = Number.parseInt(cells[1] ?? "", 10); + if (!Number.isFinite(startCh) || startCh !== 0) continue; + // cells[2] type, cells[5] expected payoff, last cell notes + const notes = cells[cells.length - 1] ?? ""; + const summary = [cells[0], cells[2], notes].filter(Boolean).join(" · "); + if (summary) seedRows.push(summary); + } + return seedRows; +} + +/** + * Read current_state.md; when the file is only a seed placeholder (chapter 0, + * before consolidator has appended anything), derive an initial-state block + * from roles/*.Current_State + pending_hooks startChapter=0 rows so callers + * still have substantive content to feed into writer / analyzer prompts. + */ +export async function readCurrentStateWithFallback( + bookDir: string, + fallbackPlaceholder: string = "", +): Promise { + const storyDir = join(bookDir, "story"); + const currentStatePath = join(storyDir, "current_state.md"); + const raw = await readOr(currentStatePath, ""); + + if (!isCurrentStateSeedPlaceholder(raw)) { + return raw; + } + + const [cards, pendingHooks] = await Promise.all([ + readRoleCards(bookDir), + readOr(join(storyDir, "pending_hooks.md"), ""), + ]); + + const roleLines = cards + .map((card) => { + const state = extractCurrentStateFromRole(card.content); + if (!state) return null; + const tierLabel = card.tier === "major" ? "主要" : "次要"; + return `- ${card.name}(${tierLabel}):${state.replace(/\s+/g, " ")}`; + }) + .filter((line): line is string => line !== null); + + const hookLines = extractSeedHooksFromPendingHooks(pendingHooks); + + if (roleLines.length === 0 && hookLines.length === 0) { + return raw.trim() ? raw : fallbackPlaceholder; + } + + const parts: string[] = ["# 初始状态(第 0 章,由 roles + 种子伏笔派生)"]; + if (roleLines.length > 0) { + parts.push("\n## 角色初始位置 / 处境"); + parts.push(...roleLines); + } + if (hookLines.length > 0) { + parts.push("\n## 种子伏笔(startChapter = 0)"); + parts.push(...hookLines.map((line) => `- ${line}`)); + } + return parts.join("\n"); +} diff --git a/packages/studio/src/components/chat/BookFormCard.tsx b/packages/studio/src/components/chat/BookFormCard.tsx deleted file mode 100644 index 50fbc99f..00000000 --- a/packages/studio/src/components/chat/BookFormCard.tsx +++ /dev/null @@ -1,199 +0,0 @@ -import type { Theme } from "../../hooks/use-theme"; -import { cn } from "../../lib/utils"; -import { Tool, ToolHeader, ToolContent } from "../ai-elements/tool"; -import { Loader2 } from "lucide-react"; - -export interface BookFormArgs { - title?: string; - genre?: string; - platform?: string; - targetChapters?: number; - chapterWordCount?: number; - language?: string; - brief?: string; -} - -export interface BookFormCardProps { - readonly args: BookFormArgs; - readonly onArgsChange: (args: BookFormArgs) => void; - readonly onConfirm: () => void; - readonly confirming: boolean; - readonly theme: Theme; -} - -const PLATFORM_OPTIONS = [ - { label: "番茄小说", value: "tomato" }, - { label: "起点中文网", value: "qidian" }, - { label: "飞卢", value: "feilu" }, - { label: "其他", value: "other" }, -] as const; - -const LANGUAGE_OPTIONS = [ - { label: "中文", value: "zh" }, - { label: "English", value: "en" }, -] as const; - -const labelClass = "text-[10px] uppercase tracking-[0.18em] text-muted-foreground font-semibold"; - -const inputClass = cn( - "w-full rounded-lg border border-border/60 bg-background/80 px-3 py-1.5 text-sm", - "outline-none focus:border-primary/50 focus:ring-2 focus:ring-primary/10 transition-all", - "placeholder:text-muted-foreground/40", -); - -function RadioGroup({ - options, - value, - onChange, - disabled, -}: { - readonly options: ReadonlyArray<{ readonly label: string; readonly value: string }>; - readonly value: string | undefined; - readonly onChange: (v: string) => void; - readonly disabled?: boolean; -}) { - return ( -
- {options.map((opt) => ( - - ))} -
- ); -} - -export function BookFormCard({ args, onArgsChange, onConfirm, confirming }: BookFormCardProps) { - const disabled = confirming; - const update = (key: K, value: BookFormArgs[K]) => { - if (disabled) return; - onArgsChange({ ...args, [key]: value }); - }; - - const disabledInput = disabled ? "opacity-60 cursor-not-allowed" : ""; - const toolState = confirming ? "input-available" : "approval-requested"; - - return ( - - - -
- {/* 书名 */} -
- - update("title", e.target.value)} - placeholder="输入书名" - disabled={disabled} - className={cn(inputClass, disabledInput)} - /> -
- - {/* 题材 */} -
- - update("genre", e.target.value)} - placeholder="如 xuanhuan、urban、romance" - disabled={disabled} - className={cn(inputClass, disabledInput)} - /> -
- - {/* 目标平台 */} -
- - update("platform", v)} - disabled={disabled} - /> -
- - {/* 目标章数 + 每章字数 */} -
-
- - update("targetChapters", e.target.value ? Number(e.target.value) : undefined)} - placeholder="如 200" - disabled={disabled} - className={cn(inputClass, disabledInput)} - /> -
-
- - update("chapterWordCount", e.target.value ? Number(e.target.value) : undefined)} - placeholder="如 2000" - disabled={disabled} - className={cn(inputClass, disabledInput)} - /> -
-
- - {/* 写作语言 */} -
- - update("language", v)} - disabled={disabled} - /> -
- - {/* 创意简述 */} -
- -
- {args.brief || AI 会根据你的描述自动生成} -
-
-
- - {/* 确认按钮 */} -
-

确认后将由 Architect 生成完整 foundation

- -
-
-
- ); -} diff --git a/packages/studio/src/components/chat/ChatMessage.tsx b/packages/studio/src/components/chat/ChatMessage.tsx index 8c861d96..511b7a29 100644 --- a/packages/studio/src/components/chat/ChatMessage.tsx +++ b/packages/studio/src/components/chat/ChatMessage.tsx @@ -1,51 +1,25 @@ import type { Theme } from "../../hooks/use-theme"; -import { cn } from "../../lib/utils"; import { Message, MessageContent, MessageResponse, } from "../ai-elements/message"; -import { BookFormCard } from "./BookFormCard"; -import type { BookFormArgs } from "./BookFormCard"; -import { - CheckCircle2, - XCircle, - Loader2, -} from "lucide-react"; - -export interface ToolCall { - readonly name: string; - readonly arguments: Record; -} +import { XCircle } from "lucide-react"; export interface ChatMessageProps { readonly role: "user" | "assistant"; readonly content: string; readonly timestamp: number; readonly theme: Theme; - readonly toolCall?: ToolCall; - readonly onArgsChange?: (args: Record) => void; - readonly onConfirm?: () => void; - readonly confirming?: boolean; } export function ChatMessage({ role, content, - timestamp, - theme, - toolCall, - onArgsChange, - onConfirm, - confirming, }: ChatMessageProps) { const isUser = role === "user"; - const isStatus = content.startsWith("\u22EF"); - const isSuccess = content.startsWith("\u2713"); const isError = content.startsWith("\u2717"); - const hasBookForm = toolCall?.name === "create_book" && onArgsChange && onConfirm; - return ( @@ -60,16 +34,6 @@ export function ChatMessage({ {content} )} - - {hasBookForm && ( - onArgsChange(a as Record)} - onConfirm={onConfirm} - confirming={confirming ?? false} - theme={theme} - /> - )} ); } diff --git a/packages/studio/src/components/sidebar/SummarySection.tsx b/packages/studio/src/components/sidebar/SummarySection.tsx index 5dc3d11f..466ad86d 100644 --- a/packages/studio/src/components/sidebar/SummarySection.tsx +++ b/packages/studio/src/components/sidebar/SummarySection.tsx @@ -1,9 +1,25 @@ import { useEffect } from "react"; +import { Streamdown } from "streamdown"; +import { cjk } from "@streamdown/cjk"; +import { code } from "@streamdown/code"; +import { math } from "@streamdown/math"; +import { mermaid } from "@streamdown/mermaid"; import { useChatStore } from "../../store/chat"; import type { BookSummary } from "../../store/chat"; import { fetchJson } from "../../hooks/use-api"; import { SidebarCard } from "./SidebarCard"; +const streamdownPlugins = { cjk, code, math, mermaid }; + +const SIDEBAR_MD_CLASS = + "text-xs text-muted-foreground leading-relaxed " + + "[&>*:first-child]:mt-0 [&>*:last-child]:mb-0 " + + "[&>p+p]:mt-1.5 [&_strong]:text-foreground [&_strong]:font-medium " + + "[&_ul]:list-disc [&_ul]:pl-4 [&_ol]:list-decimal [&_ol]:pl-4 [&_li]:my-0.5 " + + "[&_h1]:hidden [&_h2]:text-xs [&_h2]:font-medium [&_h2]:text-foreground [&_h2]:mt-1.5 [&_h2]:mb-0.5 " + + "[&_h3]:text-xs [&_h3]:font-medium [&_h3]:text-foreground [&_h3]:mt-1.5 [&_h3]:mb-0.5 " + + "[&_code]:text-[11px] [&_code]:px-1 [&_code]:py-0.5 [&_code]:rounded [&_code]:bg-secondary/60"; + function parseStoryBible(content: string): BookSummary { const sections = content.split(/^##\s+/m); let world = ""; @@ -47,22 +63,24 @@ export function SummarySection({ bookId }: SummarySectionProps) { <> {summary.world && ( -

+ {summary.world} -

+
)} {(summary.protagonist || summary.cast) && ( {summary.protagonist && ( -

+ {summary.protagonist} -

+ )} {summary.cast && ( -

- {summary.cast} -

+
+ + {summary.cast} + +
)}
)} diff --git a/packages/studio/src/hooks/use-hash-route.ts b/packages/studio/src/hooks/use-hash-route.ts index c6ae0d80..22f05d5b 100644 --- a/packages/studio/src/hooks/use-hash-route.ts +++ b/packages/studio/src/hooks/use-hash-route.ts @@ -58,14 +58,18 @@ export function useHashRoute() { }, []); const setRoute = useCallback((newRoute: HashRoute) => { + // 先同步 React state:无论目标页面是否写 URL,保证页面立刻切换。 + // 之前只在非 hash 页面才 setRouteState,hash 页面完全靠 hashchange 事件回调触发。 + // 但当 URL 没有实际变化时(比如从 services → logs → services,中间的 logs + // 不写 URL,URL 一直停在 #/services),再次赋值同一个 hash 不会触发 hashchange, + // React state 就永远停留在 logs,表现为"点不动"。 + setRouteState(newRoute); if (HASH_PAGES.has(newRoute.page)) { const hash = routeToHash(newRoute); - if (hash) { + if (hash && window.location.hash !== hash) { window.location.hash = hash; - return; } } - setRouteState(newRoute); }, []); const nav = { diff --git a/packages/studio/src/pages/ChatPage.tsx b/packages/studio/src/pages/ChatPage.tsx index 6fc6068d..5eae69d9 100644 --- a/packages/studio/src/pages/ChatPage.tsx +++ b/packages/studio/src/pages/ChatPage.tsx @@ -2,7 +2,6 @@ import { useRef, useEffect, useMemo, useState } from "react"; import type { Theme } from "../hooks/use-theme"; import type { TFunction } from "../hooks/use-i18n"; import type { SSEMessage } from "../hooks/use-sse"; -import { useApi } from "../hooks/use-api"; import { chatSelectors, useChatStore } from "../store/chat"; import { useServiceStore } from "../store/service"; import { @@ -32,7 +31,6 @@ import { MessageContent, } from "../components/ai-elements/message"; import { - clearBookCreateSessionId, filterModelGroups, getBookCreateSessionId, setBookCreateSessionId, @@ -58,22 +56,15 @@ export interface ChatPageProps { export function ChatPage({ activeBookId, nav, theme, t, sse: _sse }: ChatPageProps) { // -- Store selectors -- - const activeSession = useChatStore(chatSelectors.activeSession); const messages = useChatStore(chatSelectors.activeMessages); const activeSessionId = useChatStore((s) => s.activeSessionId); const input = useChatStore((s) => s.input); const loading = useChatStore(chatSelectors.isActiveSessionStreaming); - const pendingBookArgs = activeSession?.pendingBookArgs ?? null; - const bookCreating = useChatStore((s) => s.bookCreating); - const createProgress = useChatStore((s) => s.createProgress); const selectedModel = useChatStore((s) => s.selectedModel); const selectedService = useChatStore((s) => s.selectedService); // -- Store actions -- const setInput = useChatStore((s) => s.setInput); const sendMessage = useChatStore((s) => s.sendMessage); - const setPendingBookArgs = useChatStore((s) => s.setPendingBookArgs); - const handleCreateBook = useChatStore((s) => s.handleCreateBook); - const setCreateProgress = useChatStore((s) => s.setCreateProgress); const setSelectedModel = useChatStore((s) => s.setSelectedModel); const loadSessionList = useChatStore((s) => s.loadSessionList); const createSession = useChatStore((s) => s.createSession); @@ -142,29 +133,12 @@ export function ChatPage({ activeBookId, nav, theme, t, sse: _sse }: ChatPagePro el.style.height = `${Math.min(el.scrollHeight, 200)}px`; }, [input]); - // Auto-scroll on new messages or progress updates + // Auto-scroll on new messages useEffect(() => { if (scrollRef.current) { scrollRef.current.scrollTo({ top: scrollRef.current.scrollHeight, behavior: "smooth" }); } - }, [messages, createProgress]); - - // Listen for pipeline log events during book creation - useEffect(() => { - if (!bookCreating) { - setCreateProgress(""); - return; - } - const es = new EventSource("/api/v1/events"); - es.addEventListener("log", (e: MessageEvent) => { - try { - const data = e.data ? JSON.parse(e.data) : null; - const msg = data?.message as string | undefined; - if (msg) setCreateProgress(msg); - } catch { /* ignore */ } - }); - return () => { es.close(); }; - }, [bookCreating, setCreateProgress]); + }, [messages]); // Entering a book loads its latest session; book-create mode persists its orphan session in localStorage. useEffect(() => { @@ -221,15 +195,6 @@ export function ChatPage({ activeBookId, nav, theme, t, sse: _sse }: ChatPagePro void sendMessage(activeSessionId, text, activeBookId); }; - const onCreateBook = async () => { - if (!activeSessionId) return; - const newBookId = await handleCreateBook(activeSessionId, activeBookId); - if (newBookId) { - clearBookCreateSessionId(); - nav.toBook(newBookId); - } - }; - const handleQuickAction = (command: string) => { if (!activeSessionId) return; void sendMessage(activeSessionId, command, activeBookId); @@ -312,16 +277,6 @@ export function ChatPage({ activeBookId, nav, theme, t, sse: _sse }: ChatPagePro content={item.part.content} timestamp={msg.timestamp} theme={theme} - toolCall={msg.toolCall?.name === "create_book" && pendingBookArgs - ? { name: msg.toolCall.name, arguments: pendingBookArgs } - : msg.toolCall} - onArgsChange={msg.toolCall?.name === "create_book" - ? (args) => setPendingBookArgs(args) - : undefined} - onConfirm={msg.toolCall?.name === "create_book" - ? () => void onCreateBook() - : undefined} - confirming={msg.toolCall?.name === "create_book" ? bookCreating : undefined} /> ); } @@ -336,16 +291,6 @@ export function ChatPage({ activeBookId, nav, theme, t, sse: _sse }: ChatPagePro content={msg.content} timestamp={msg.timestamp} theme={theme} - toolCall={msg.toolCall?.name === "create_book" && pendingBookArgs - ? { name: msg.toolCall.name, arguments: pendingBookArgs } - : msg.toolCall} - onArgsChange={msg.toolCall?.name === "create_book" - ? (args) => setPendingBookArgs(args) - : undefined} - onConfirm={msg.toolCall?.name === "create_book" - ? () => void onCreateBook() - : undefined} - confirming={msg.toolCall?.name === "create_book" ? bookCreating : undefined} /> )} @@ -362,22 +307,6 @@ export function ChatPage({ activeBookId, nav, theme, t, sse: _sse }: ChatPagePro )} - {/* Book creation progress */} - {bookCreating && ( -
-
- -
-
-
{isZh ? "\u6B63\u5728\u521B\u5EFA\u4E66\u7C4D..." : "Creating book..."}
- {createProgress && ( -
- {createProgress} -
- )} -
-
- )} )} @@ -396,42 +325,6 @@ export function ChatPage({ activeBookId, nav, theme, t, sse: _sse }: ChatPagePro {/* Input area */}
- {pendingBookArgs && !loading ? ( - /* create_book tool call pending — show action buttons */ -
- -
- setInput(e.target.value)} - onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); onSend(input); } }} - placeholder={isZh ? "或输入修改要求…" : "Or type changes..."} - disabled={!activeSessionId} - className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground/50" - /> - {input.trim() && ( - - )} -
-
- ) : ( - /* Normal input */