Просмотр исходного кода

feat: add style import, canon import, and agent search tools

Pipeline runner:
- generateStyleGuide(): statistical + LLM qualitative analysis
- importCanon(): read 8 parent truth files → generate parent_canon.md

Agent loop:
- New tools: web_search, web_fetch, import_style, import_canon
- Updated system prompt with tool table and usage flows
- Fixed JSON.parse error handling in tool execution
majx_mac 8 месяцев назад
Родитель
Сommit
f03023c3ef
2 измененных файлов с 284 добавлено и 5 удалено
  1. 95 4
      packages/core/src/pipeline/agent.ts
  2. 189 1
      packages/core/src/pipeline/runner.ts

+ 95 - 4
packages/core/src/pipeline/agent.ts

@@ -106,6 +106,53 @@ const TOOLS: ReadonlyArray<ToolDefinition> = [
       required: ["bookId"],
     },
   },
+  {
+    name: "web_search",
+    description: "搜索互联网。用于查找题材相关资料、避免套路化、获取灵感。",
+    parameters: {
+      type: "object",
+      properties: {
+        query: { type: "string", description: "搜索关键词" },
+      },
+      required: ["query"],
+    },
+  },
+  {
+    name: "web_fetch",
+    description: "抓取指定URL的文本内容。用于读取搜索结果中的详细页面。",
+    parameters: {
+      type: "object",
+      properties: {
+        url: { type: "string", description: "要抓取的URL" },
+        maxChars: { type: "number", description: "最大返回字符数(默认8000)" },
+      },
+      required: ["url"],
+    },
+  },
+  {
+    name: "import_style",
+    description: "从参考文本生成文风指南(统计 + LLM定性分析)。生成 style_profile.json 和 style_guide.md。",
+    parameters: {
+      type: "object",
+      properties: {
+        bookId: { type: "string", description: "目标书籍ID" },
+        referenceText: { type: "string", description: "参考文本(至少2000字)" },
+      },
+      required: ["bookId", "referenceText"],
+    },
+  },
+  {
+    name: "import_canon",
+    description: "从正传导入正典参照,生成 parent_canon.md,启用番外写作和审计模式。",
+    parameters: {
+      type: "object",
+      properties: {
+        targetBookId: { type: "string", description: "番外书籍ID" },
+        parentBookId: { type: "string", description: "正传书籍ID" },
+      },
+      required: ["targetBookId", "parentBookId"],
+    },
+  },
 ];
 
 export interface AgentLoopOptions {
@@ -142,6 +189,10 @@ export async function runAgentLoop(
 | revise_chapter | 修订章节(支持 polish/rewrite/rework/spot-fix/anti-detect 五种模式) |
 | write_full_pipeline | 完整管线:写 → 审 → 改(如需要) |
 | scan_market | 扫描平台排行榜,分析市场趋势 |
+| web_search | 搜索互联网,获取题材资料或灵感 |
+| web_fetch | 抓取指定URL的文本内容 |
+| import_style | 从参考文本生成文风指南(统计+LLM分析) |
+| import_canon | 从正传导入正典参照,启用番外模式 |
 
 ## 长期记忆
 
@@ -164,7 +215,10 @@ export async function runAgentLoop(
 
 - 用户提供了题材/创意但没说要扫描市场 → 跳过 scan_market,直接 create_book
 - 用户说了书名/bookId → 直接操作,不需要先 list_books
-- 每完成一步,简要汇报进展`,
+- 每完成一步,简要汇报进展
+- 仿写流程:用户提供参考文本 → import_style → 生成 style_guide.md,后续写作自动参照
+- 番外流程:先 create_book 建番外书 → import_canon 导入正传正典 → 然后正常 write_draft
+- web_search 可用于建书前调研题材、查找避免套路化的灵感`,
     },
     { role: "user", content: instruction },
   ];
@@ -192,11 +246,10 @@ export async function runAgentLoop(
 
     // Execute tool calls
     for (const toolCall of result.toolCalls) {
-      const args = JSON.parse(toolCall.arguments) as Record<string, unknown>;
-      options?.onToolCall?.(toolCall.name, args);
-
       let toolResult: string;
       try {
+        const args = JSON.parse(toolCall.arguments) as Record<string, unknown>;
+        options?.onToolCall?.(toolCall.name, args);
         toolResult = await executeTool(pipeline, state, config, toolCall.name, args);
       } catch (e) {
         toolResult = JSON.stringify({ error: String(e) });
@@ -314,6 +367,44 @@ async function executeTool(
       return JSON.stringify(results);
     }
 
+    case "web_search": {
+      const { searchWeb } = await import("../utils/web-search.js");
+      const result = await searchWeb(args.query as string);
+      return JSON.stringify(result);
+    }
+
+    case "web_fetch": {
+      const { fetchUrl } = await import("../utils/web-search.js");
+      const text = await fetchUrl(args.url as string, (args.maxChars as number) ?? 8000);
+      return JSON.stringify({ url: args.url, content: text });
+    }
+
+    case "import_style": {
+      const guide = await pipeline.generateStyleGuide(
+        args.bookId as string,
+        args.referenceText as string,
+      );
+      return JSON.stringify({
+        bookId: args.bookId,
+        statsProfile: "story/style_profile.json",
+        styleGuide: "story/style_guide.md",
+        guidePreview: guide.slice(0, 500),
+      });
+    }
+
+    case "import_canon": {
+      const canon = await pipeline.importCanon(
+        args.targetBookId as string,
+        args.parentBookId as string,
+      );
+      return JSON.stringify({
+        targetBookId: args.targetBookId,
+        parentBookId: args.parentBookId,
+        output: "story/parent_canon.md",
+        canonPreview: canon.slice(0, 500),
+      });
+    }
+
     default:
       return JSON.stringify({ error: `Unknown tool: ${name}` });
   }

+ 189 - 1
packages/core/src/pipeline/runner.ts

@@ -1,4 +1,5 @@
 import type { LLMClient } from "../llm/provider.js";
+import { chatCompletion } from "../llm/provider.js";
 import type { BookConfig } from "../models/book.js";
 import type { ChapterMeta } from "../models/chapter.js";
 import type { NotifyChannel } from "../models/project.js";
@@ -18,7 +19,7 @@ import type { WebhookEvent } from "../notify/webhook.js";
 import type { AgentContext } from "../agents/base.js";
 import type { AuditResult, AuditIssue } from "../agents/continuity.js";
 import type { RadarResult } from "../agents/radar.js";
-import { readFile, readdir, writeFile } from "node:fs/promises";
+import { readFile, readdir, writeFile, mkdir } from "node:fs/promises";
 import { join } from "node:path";
 
 export interface PipelineConfig {
@@ -561,6 +562,193 @@ export class PipelineRunner {
     };
   }
 
+  // ---------------------------------------------------------------------------
+  // Import operations (style imitation + canon for spinoff)
+  // ---------------------------------------------------------------------------
+
+  /**
+   * Generate a qualitative style guide from reference text via LLM.
+   * Also saves the statistical style_profile.json.
+   */
+  async generateStyleGuide(bookId: string, referenceText: string): Promise<string> {
+    if (referenceText.length < 500) {
+      throw new Error(`Reference text too short (${referenceText.length} chars, minimum 500). Provide at least 2000 chars for reliable style extraction.`);
+    }
+
+    const { analyzeStyle } = await import("../agents/style-analyzer.js");
+    const bookDir = this.state.bookDir(bookId);
+    const storyDir = join(bookDir, "story");
+    await mkdir(storyDir, { recursive: true });
+
+    // Statistical fingerprint
+    const profile = analyzeStyle(referenceText);
+    await writeFile(join(storyDir, "style_profile.json"), JSON.stringify(profile, null, 2), "utf-8");
+
+    // LLM qualitative extraction
+    const response = await chatCompletion(this.config.client, this.config.model, [
+      {
+        role: "system",
+        content: `你是一位文学风格分析专家。分析参考文本的写作风格,提取可供模仿的定性特征。
+
+输出格式(Markdown):
+## 叙事声音与语气
+(冷峻/热烈/讽刺/温情/...,附1-2个原文例句)
+
+## 对话风格
+(角色说话的共性特征:句子长短、口头禅倾向、方言痕迹、对话节奏)
+
+## 场景描写特征
+(五感偏好、意象选择、描写密度、环境与情绪的关联方式)
+
+## 转折与衔接手法
+(场景如何切换、时间跳跃的处理方式、段落间的过渡特征)
+
+## 节奏特征
+(长短句分布、段落长度偏好、高潮/舒缓的交替方式)
+
+## 词汇偏好
+(高频特色用词、比喻/修辞倾向、口语化程度)
+
+## 情绪表达方式
+(直白抒情 vs 动作外化、内心独白的频率和风格)
+
+## 独特习惯
+(任何值得模仿的个人写作习惯)
+
+分析必须基于原文实际特征,不要泛泛而谈。每个部分用1-2个原文例句佐证。`,
+      },
+      {
+        role: "user",
+        content: `分析以下参考文本的写作风格:\n\n${referenceText.slice(0, 20000)}`,
+      },
+    ], { temperature: 0.3, maxTokens: 4096 });
+
+    await writeFile(join(storyDir, "style_guide.md"), response.content, "utf-8");
+    return response.content;
+  }
+
+  /**
+   * Import canon from parent book for spinoff writing.
+   * Reads parent's truth files, uses LLM to generate parent_canon.md in target book.
+   */
+  async importCanon(targetBookId: string, parentBookId: string): Promise<string> {
+    // Validate both books exist
+    const bookIds = await this.state.listBooks();
+    if (!bookIds.includes(parentBookId)) {
+      throw new Error(`Parent book "${parentBookId}" not found. Available: ${bookIds.join(", ") || "(none)"}`);
+    }
+    if (!bookIds.includes(targetBookId)) {
+      throw new Error(`Target book "${targetBookId}" not found. Available: ${bookIds.join(", ") || "(none)"}`);
+    }
+
+    const parentDir = this.state.bookDir(parentBookId);
+    const targetDir = this.state.bookDir(targetBookId);
+    const storyDir = join(targetDir, "story");
+    await mkdir(storyDir, { recursive: true });
+
+    const readSafe = async (path: string): Promise<string> => {
+      try { return await readFile(path, "utf-8"); } catch { return "(无)"; }
+    };
+
+    const parentBook = await this.state.loadBookConfig(parentBookId);
+
+    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")),
+        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")),
+      ]);
+
+    const response = await chatCompletion(this.config.client, this.config.model, [
+      {
+        role: "system",
+        content: `你是一位网络小说架构师。基于正传的全部设定和状态文件,生成一份完整的"正传正典参照"文档,供番外写作和审计使用。
+
+输出格式(Markdown):
+# 正传正典(《{正传书名}》)
+
+## 世界规则(完整,来自正传设定)
+(力量体系、地理设定、阵营关系、核心规则——完整复制,不压缩)
+
+## 正典约束(不可违反的事实)
+| 约束ID | 类型 | 约束内容 | 严重性 |
+|---|---|---|---|
+| C01 | 人物存亡 | ... | critical |
+(列出所有硬性约束:谁活着、谁死了、什么事件已经发生、什么规则不可违反)
+
+## 角色快照
+| 角色 | 当前状态 | 性格底色 | 对话特征 | 已知信息 | 未知信息 |
+|---|---|---|---|---|---|
+(从状态卡和角色矩阵中提取每个重要角色的完整快照)
+
+## 角色双态处理原则
+- 未来会变强的角色:写潜力暗示
+- 未来会黑化的角色:写微小裂痕
+- 未来会死的角色:写导致死亡的性格底色
+
+## 关键事件时间线
+| 章节 | 事件 | 涉及角色 | 对番外的约束 |
+|---|---|---|---|
+(从章节摘要中提取关键事件)
+
+## 伏笔状态
+| Hook ID | 类型 | 状态 | 内容 | 预期回收 |
+|---|---|---|---|---|
+
+## 资源账本快照
+(当前资源状态)
+
+---
+meta:
+  parentBookId: "{parentBookId}"
+  parentTitle: "{正传书名}"
+  generatedAt: "{ISO timestamp}"
+
+要求:
+1. 世界规则完整复制,不压缩——准确性优先
+2. 正典约束必须穷尽,遗漏会导致番外与正传矛盾
+3. 角色快照必须包含信息边界(已知/未知),防止番外中角色引用不该知道的信息`,
+      },
+      {
+        role: "user",
+        content: `正传书名:${parentBook.title}
+正传ID:${parentBookId}
+
+## 正传世界设定
+${storyBible}
+
+## 正传当前状态卡
+${currentState}
+
+## 正传资源账本
+${ledger}
+
+## 正传伏笔池
+${hooks}
+
+## 正传章节摘要
+${summaries}
+
+## 正传支线进度
+${subplots}
+
+## 正传情感弧线
+${emotions}
+
+## 正传角色矩阵
+${matrix}`,
+      },
+    ], { temperature: 0.3, maxTokens: 16384 });
+
+    await writeFile(join(storyDir, "parent_canon.md"), response.content, "utf-8");
+    return response.content;
+  }
+
   // ---------------------------------------------------------------------------
   // Helpers
   // ---------------------------------------------------------------------------