Pārlūkot izejas kodu

fix(novel): 修正严格写作模型路由与中文标签

Agent 调度和分析阶段改用默认模型,正文生成与返修保留聊天模型,最终去 AI 味使用专用模型。\n补齐章节执行报告、返修、复检和计划履约步骤的中文显示。
darknessomi 3 mēneši atpakaļ
vecāks
revīzija
8f605d229b

+ 1 - 1
src/components/chat/chat-panel.tsx

@@ -1278,7 +1278,7 @@ export function ChatPanel() {
         return
       }
       if (!agentSupportsTools) {
-        setDeAiSkillWarningMessage("当前模型不支持Agent功能,请更换模型")
+        setDeAiSkillWarningMessage("Agent 调度模型不支持工具调用,请更换小说设置中的默认模型")
         return
       }
       if (!agentSkillConfigLoaded || !agentConfig) {

+ 65 - 2
src/hooks/use-agent-config.spec.ts

@@ -3,7 +3,7 @@ import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"
 import React from "react"
 import { createRoot } from "react-dom/client"
 import { act } from "react"
-import type { LlmConfig, ProviderConfigs, SearchApiConfig } from "@/stores/wiki-store"
+import { DEFAULT_NOVEL_CONFIG, type LlmConfig, type NovelConfig, type ProviderConfigs, type SearchApiConfig } from "@/stores/wiki-store"
 import type { WikiProject } from "@/types/wiki"
 import type { Conversation, DisplayMessage } from "@/stores/chat-store"
 import type { OutlineChatConversation } from "@/stores/outline-chat-store"
@@ -29,6 +29,8 @@ interface StoreStates {
     dataVersion: number
     llmConfig: LlmConfig
     providerConfigs: ProviderConfigs
+    defaultLlmModel: string
+    novelConfig: NovelConfig
     searchApiConfig: SearchApiConfig
     mcpConfig: McpConfig
     aiWorkflowMode: AiWorkflowMode
@@ -57,7 +59,15 @@ async function renderHook(systemPrompt: string, overrides: StoreStates & {
     project: null as WikiProject | null,
     dataVersion: 0,
     llmConfig: baseLlmConfig,
-    providerConfigs: {} as ProviderConfigs,
+    providerConfigs: {
+      openai: {
+        enabled: true,
+        apiKey: "test-key",
+        savedModels: [{ id: "gpt-4o", name: "GPT-4o", model: "gpt-4o", createdAt: 1 }],
+      },
+    } as ProviderConfigs,
+    defaultLlmModel: "",
+    novelConfig: { ...DEFAULT_NOVEL_CONFIG },
     searchApiConfig: {
       provider: "none",
       apiKey: "",
@@ -260,6 +270,59 @@ describe("useAgentConfig", () => {
     await cleanup()
   }, 15000)
 
+  it("uses the default model for Agent orchestration while preserving the chat model for chapter writing", async () => {
+    const providerConfigs: ProviderConfigs = {
+      custom: {
+        enabled: true,
+        apiKey: "test-key",
+        savedModels: [
+          { id: "writer", name: "Writer", model: "writer-model", createdAt: 1 },
+          { id: "workflow", name: "Workflow", model: "workflow-model", createdAt: 2 },
+        ],
+      },
+    }
+    const { result, cleanup } = await renderHook("test prompt", {
+      wiki: {
+        aiChatModel: "custom/writer-model",
+        defaultLlmModel: "custom/workflow-model",
+        novelConfig: { ...DEFAULT_NOVEL_CONFIG, defaultLlmModel: "custom/workflow-model" },
+        providerConfigs,
+        project: { path: "/tmp/project" } as WikiProject,
+      },
+      skillConfig: {
+        version: 1,
+        defaultSkillId: "built-in:comprehensive",
+        disabledSkillIds: [],
+        projectSkills: [],
+        builtInSkillOverrides: [],
+        lastChapterDeAiSkillId: null,
+      },
+    })
+
+    expect(result.config?.modelId).toBe("workflow-model")
+    expect(result.config?.llmConfig.model).toBe("workflow-model")
+
+    const workflowTool = result.registry.get("run_chapter_workflow")
+    const deepChapterModule = await import("@/lib/novel/deep-chapter-generation")
+    const runDeepChapterGeneration = vi.mocked(deepChapterModule.runDeepChapterGeneration)
+    runDeepChapterGeneration.mockResolvedValueOnce({
+      finalContent: "正文",
+      taskBrief: "任务书",
+      draftContent: "初稿",
+      reviewResults: [],
+      revised: false,
+    })
+    await workflowTool?.execute({ userRequest: "写第三章" })
+    expect(runDeepChapterGeneration).toHaveBeenCalledWith(
+      expect.objectContaining({ llmConfig: expect.objectContaining({ model: "writer-model" }) }),
+      expect.any(Object),
+      undefined,
+      undefined,
+    )
+
+    await cleanup()
+  }, 15000)
+
   it("passes Web Search settings into the web_search agent tool", async () => {
     webSearchMock.mockResolvedValueOnce([])
     const searchApiConfig: SearchApiConfig = {

+ 11 - 5
src/hooks/use-agent-config.ts

@@ -5,7 +5,7 @@ import { useOutlineChatStore } from "@/stores/outline-chat-store"
 import { loadDeAiSkillConfig, type DeAiSkillConfig } from "@/lib/novel/de-ai-skill-library"
 import { loadAllLinkedSkillsContent, loadUserSkillConfig, resolveEnabledWritingSkills } from "@/lib/novel/user-skill-store"
 import type { UserSkill } from "@/lib/novel/skill-library"
-import { resolveModelConfig } from "@/lib/novel/model-resolver"
+import { resolveDefaultModel, resolveModelConfig } from "@/lib/novel/model-resolver"
 import { runDeepChapterGeneration } from "@/lib/novel/deep-chapter-generation"
 import { normalizePath } from "@/lib/path-utils"
 import { ToolRegistry } from "@/lib/agent/registry"
@@ -28,6 +28,8 @@ export interface UseAgentConfigResult {
 
 export function useAgentConfig(systemPrompt: string, getPlanBlueprint?: () => string | undefined): UseAgentConfigResult {
   const aiChatModel = useWikiStore((s) => s.aiChatModel)
+  const defaultLlmModel = useWikiStore((s) => s.defaultLlmModel)
+  const novelDefaultLlmModel = useWikiStore((s) => s.novelConfig.defaultLlmModel)
   const projectPath = useWikiStore((s) => s.project?.path)
   const dataVersion = useWikiStore((s) => s.dataVersion)
   const baseLlmConfig = useWikiStore((s) => s.llmConfig)
@@ -108,7 +110,8 @@ export function useAgentConfig(systemPrompt: string, getPlanBlueprint?: () => st
   )
 
   return useMemo(() => {
-    const supportsTools = modelSupportsTools(aiChatModel)
+    const agentLlmConfig = resolveDefaultModel(baseLlmConfig)
+    const supportsTools = modelSupportsTools(agentLlmConfig.model)
 
     if (!supportsTools || !projectPath || !skillConfigLoaded) {
       return {
@@ -123,7 +126,7 @@ export function useAgentConfig(systemPrompt: string, getPlanBlueprint?: () => st
       }
     }
 
-    const llmConfig = resolveModelConfig(aiChatModel, baseLlmConfig, providerConfigs)
+    const chapterWritingLlmConfig = resolveModelConfig(aiChatModel, baseLlmConfig, providerConfigs)
     const registry = new ToolRegistry()
     const wikiPath = `${normalizePath(projectPath)}/wiki`
     const novelMode = useWikiStore.getState().novelMode
@@ -131,7 +134,7 @@ export function useAgentConfig(systemPrompt: string, getPlanBlueprint?: () => st
       ? new RealMcpConnector(mcpConfig)
       : undefined
     const mcpRuntime = buildMcpRuntime(mcpConfig, undefined, realMcpConnector)
-    const config = buildAgentConfig(aiChatModel, systemPrompt, registry, {
+    const config = buildAgentConfig(agentLlmConfig.model, systemPrompt, registry, {
       wikiPath,
       getSkillConfig,
       getUserSkills,
@@ -139,7 +142,8 @@ export function useAgentConfig(systemPrompt: string, getPlanBlueprint?: () => st
       getChatConversations,
       getOutlineConversations,
       mcpTools: mcpRuntime.mcpTools,
-      llmConfig,
+      llmConfig: agentLlmConfig,
+      chapterWritingLlmConfig,
       aiWorkflowMode,
       runDeepChapterGeneration,
       draftMode: novelMode,
@@ -160,6 +164,8 @@ export function useAgentConfig(systemPrompt: string, getPlanBlueprint?: () => st
     }
   }, [
     aiChatModel,
+    defaultLlmModel,
+    novelDefaultLlmModel,
     projectPath,
     skillConfigLoaded,
     baseLlmConfig,

+ 4 - 2
src/lib/agent/tools/index.ts

@@ -53,6 +53,8 @@ export interface ToolFactoryOptions {
   enabledToolNames?: string[]
   disabledTools?: string[]
   llmConfig?: LlmConfig
+  /** 章节正文专用模型。Agent 调度可使用默认模型,但正文仍必须使用聊天框模型。 */
+  chapterWritingLlmConfig?: LlmConfig
   aiWorkflowMode?: LegacyAiWorkflowMode
   runDeepChapterGeneration?: RunDeepChapterGeneration
   onToolEvent?: (event: AgentToolEvent) => void
@@ -98,13 +100,13 @@ export function registerAllBuiltInTools(registry: ToolRegistry, options: ToolFac
   if (
     shouldRegister("run_chapter_workflow") &&
     options.projectPath &&
-    options.llmConfig &&
+    (options.chapterWritingLlmConfig || options.llmConfig) &&
     options.aiWorkflowMode &&
     options.runDeepChapterGeneration
   ) {
     registry.register(createRunChapterWorkflowTool({
       projectPath: options.projectPath,
-      llmConfig: options.llmConfig,
+      llmConfig: options.chapterWritingLlmConfig || options.llmConfig!,
       aiWorkflowMode: options.aiWorkflowMode,
       runDeepChapterGeneration: options.runDeepChapterGeneration,
       onToolEvent: options.onToolEvent,

+ 6 - 0
src/lib/agent/workflow-trace.spec.ts

@@ -139,5 +139,11 @@ describe("getWorkflowToolDescription", () => {
     expect(getWorkflowToolDescription(call({ id: "draft", name: "chapter_draft", params: { chars: 3200 } }))).toBe("生成章节正文初稿")
     expect(getWorkflowToolDescription(call({ id: "review", name: "chapter_review" }))).toBe("执行 AI 审稿")
     expect(getWorkflowToolDescription(call({ id: "polish", name: "chapter_final_polish" }))).toBe("简单审查与去AI味")
+    expect(getWorkflowToolDescription(call({ id: "report", name: "chapter_execution_report" }))).toBe("检查章节执行清单")
+    expect(getWorkflowToolDescription(call({ id: "repair", name: "chapter_execution_repair" }))).toBe("返修执行清单失败项")
+    expect(getWorkflowToolDescription(call({ id: "recheck", name: "chapter_execution_recheck" }))).toBe("复检章节执行清单")
+    expect(getWorkflowToolDescription(call({ id: "compliance", name: "chapter_plan_compliance" }))).toBe("检查章节计划履约度")
+    expect(getWorkflowToolDescription(call({ id: "deviation-repair", name: "chapter_plan_deviation_repair" }))).toBe("返修章节计划偏离点")
+    expect(getWorkflowToolDescription(call({ id: "deviation-recheck", name: "chapter_plan_deviation_recheck" }))).toBe("复检章节计划履约度")
   })
 })

+ 12 - 0
src/lib/agent/workflow-trace.ts

@@ -190,6 +190,18 @@ export function getWorkflowToolDescription(call: WorkflowToolCall): string {
       return "返修后角色一致性复审"
     case "chapter_final_polish":
       return "简单审查与去AI味"
+    case "chapter_execution_report":
+      return "检查章节执行清单"
+    case "chapter_execution_repair":
+      return "返修执行清单失败项"
+    case "chapter_execution_recheck":
+      return "复检章节执行清单"
+    case "chapter_plan_compliance":
+      return "检查章节计划履约度"
+    case "chapter_plan_deviation_repair":
+      return "返修章节计划偏离点"
+    case "chapter_plan_deviation_recheck":
+      return "复检章节计划履约度"
     case "chapter_complete":
       return "完成多任务写作循环"
     default:

+ 82 - 0
src/lib/novel/deep-chapter-generation.spec.ts

@@ -155,6 +155,88 @@ function createLegacyPlanComplianceDeps(reviewResults: NovelReviewResult[] = [])
 }
 
 describe("runDeepChapterGeneration", () => {
+  it("routes workflow, prose, and de-AI stages to their configured models", async () => {
+    const previousState = useWikiStore.getState()
+    useWikiStore.setState({
+      aiChatModel: "custom/writer-model",
+      defaultLlmModel: "custom/workflow-model",
+      providerConfigs: {
+        custom: {
+          enabled: true,
+          apiKey: "test-key",
+          savedModels: [
+            { id: "writer", name: "Writer", model: "writer-model", createdAt: 1 },
+            { id: "workflow", name: "Workflow", model: "workflow-model", createdAt: 2 },
+            { id: "review", name: "Review", model: "review-model", createdAt: 3 },
+            { id: "de-ai", name: "De-AI", model: "de-ai-model", createdAt: 4 },
+          ],
+        },
+      },
+      novelConfig: {
+        ...previousState.novelConfig,
+        defaultLlmModel: "custom/workflow-model",
+        reviewModel: "custom/review-model",
+        deAiModel: "custom/de-ai-model",
+        deepPreviousChaptersAnalysis: false,
+      },
+    })
+
+    try {
+      const deps = createDeps([{
+        severity: "error",
+        type: "plot",
+        message: "需要返修",
+        evidence: "正文",
+        relatedMemory: "大纲",
+        suggestion: "修正偏差",
+      }])
+      const calledModels: string[] = []
+      vi.mocked(deps.streamChat).mockImplementation(async (
+        config: LlmConfig,
+        messages: ChatMessage[],
+        callbacks: StreamCallbacks,
+      ) => {
+        calledModels.push(config.model)
+        const prompt = messagesPromptText(messages)
+        const content = prompt.includes("简单审查") || prompt.includes("去AI味")
+          ? chapterText("最终去AI味正文")
+          : prompt.includes("返修")
+            ? chapterText("返修正文内容")
+            : prompt.includes("正文")
+              ? chapterText("初稿正文内容")
+              : "写作任务书内容"
+        callbacks.onToken(content)
+        callbacks.onDone()
+      })
+
+      await runDeepChapterGeneration(
+        {
+          projectPath: "E:/Novel",
+          userRequest: "生成第三章",
+          chapterNumber: 3,
+          llmConfig: { ...llmConfig, model: "writer-model" },
+          aiWorkflowMode: "strict",
+        },
+        {},
+        deps,
+      )
+
+      expect(calledModels).toEqual([
+        "workflow-model",
+        "writer-model",
+        "writer-model",
+        "de-ai-model",
+      ])
+    } finally {
+      useWikiStore.setState({
+        aiChatModel: previousState.aiChatModel,
+        defaultLlmModel: previousState.defaultLlmModel,
+        providerConfigs: previousState.providerConfigs,
+        novelConfig: previousState.novelConfig,
+      })
+    }
+  })
+
   it("uses the confirmed plan when retrieving context and selecting the de-AI skill", async () => {
     const deps = createDeps()
     const planBlueprint = "确认计划:新角色顾舟在北塔登场,并带出铜钥匙。"

+ 9 - 8
src/lib/novel/deep-chapter-generation.ts

@@ -20,7 +20,7 @@ import {
   contextPackToPrompt,
   type ContextPack,
 } from "./context-engine";
-import { resolveNovelModel } from "./model-resolver";
+import { resolveDefaultModel, resolveNovelModel } from "./model-resolver";
 import { reviewChapter, type NovelReviewResult } from "./review-adapter";
 import type { TaskRouteResult } from "./task-router";
 import type { GoldenThreeChapterRequest } from "./golden-three-chapters";
@@ -481,6 +481,7 @@ export async function runDeepChapterGeneration(
   const resumeCheckpoint = input.resumeCheckpoint;
   const novelConfig = useWikiStore.getState().novelConfig;
   const writingConfig = resolveWritingConfig(input.llmConfig);
+  const workflowConfig = resolveDefaultModel(input.llmConfig);
   const deAiConfig = resolveNovelModel(input.llmConfig, novelConfig, "deAi");
   const workflowProfile = resolveChapterWorkflowProfile(input.aiWorkflowMode);
   const lengthSpec = resolveCurrentChapterLengthSpec();
@@ -505,7 +506,7 @@ export async function runDeepChapterGeneration(
   if (!executionContract && workflowProfile.runExecutionContractBuild && planBlueprint) {
     try {
       const buildContract = deps.runChapterExecutionContractBuild || runChapterExecutionContractBuild;
-      executionContract = await buildContract(writingConfig, planBlueprint, signal);
+      executionContract = await buildContract(workflowConfig, planBlueprint, signal);
     } catch (error) {
       rethrowIfUserAbort(error, signal);
       console.warn("[Deep Chapter] 执行清单生成失败,使用本地兜底解析:", error);
@@ -556,7 +557,7 @@ export async function runDeepChapterGeneration(
       previousChaptersAnalysis = await analyzePreviousChapters(
         input.projectPath,
         input.chapterNumber,
-        writingConfig,
+        workflowConfig,
         3,
         signal,
       );
@@ -760,7 +761,7 @@ export async function runDeepChapterGeneration(
       },
       () =>
         collectModelText(
-          writingConfig,
+          workflowConfig,
           [
             {
               role: "user",
@@ -1393,7 +1394,7 @@ export async function runDeepChapterGeneration(
           detail: "按场景验收标准逐项检查最终正文。",
           params: workflowBaseParams,
         },
-        () => runReport(writingConfig, executionContract!, finalContent, signal),
+        () => runReport(workflowConfig, executionContract!, finalContent, signal),
         (value) => executionReportToToolSummary(value),
         (value) => ({
           status: value.status,
@@ -1446,7 +1447,7 @@ export async function runDeepChapterGeneration(
                 detail: "返修后再次检查执行清单失败项是否消除。",
                 params: workflowBaseParams,
               },
-              () => runReport(writingConfig, executionContract!, repairedCandidate, signal),
+              () => runReport(workflowConfig, executionContract!, repairedCandidate, signal),
               (value) => executionReportToToolSummary(value),
               (value) => ({
                 status: value.status,
@@ -1514,7 +1515,7 @@ export async function runDeepChapterGeneration(
       planCompliance = await runChapterWorkflowStep(
         callbacks,
         complianceStep,
-        () => runCompliance(writingConfig, planExecutionSummary, finalContent, signal),
+        () => runCompliance(workflowConfig, planExecutionSummary, finalContent, signal),
         (value) => value ? "计划履约度检查完成。" : "计划履约度检查完成,未返回具体结果。",
         (value) => ({ hasComplianceResult: Boolean(value?.trim()) }),
       );
@@ -1573,7 +1574,7 @@ export async function runDeepChapterGeneration(
                 callbacks,
                 recheckStep,
                 () => runCompliance(
-                  writingConfig,
+                  workflowConfig,
                   planExecutionSummary,
                   repairedCandidate,
                   signal,