Răsfoiți Sursa

feat: 人物小传多Agent独立生成 - 解决多角色无法拆分保存问题

- 新增character-multi-agent.ts核心模块:角色规划Agent、并行子Agent、流式整块追加
- outline-chat-panel.tsx:集成多Agent流程、检测人物小传生成任务、有序流式追加、保存优先使用结构化结果
- outline-chat-store.ts:新增characterMultiAgentResults字段
- tauri.conf.json:添加app便携版target

解决的问题:
1. 单Agent一次性返回所有角色导致文本解析失败、显示不可置信
2. 每个角色独立Agent生成,结构化存储结果,保存时直接使用
3. 角色完成后整块追加内容(非逐字打字),生成体验流畅
4. 自然语言输入也能正确触发多Agent流程
5. 角色顺序按原始plan顺序显示,不乱序
6. planner失败/0角色/全部失败自动回退单Agent模式
Mochocyang 1 lună în urmă
părinte
comite
2b48989058

+ 1 - 1
src-tauri/tauri.conf.json

@@ -29,7 +29,7 @@
   },
   "bundle": {
     "active": true,
-    "targets": ["nsis"],
+    "targets": ["nsis", "app"],
     "resources": {
       "../skills/**/*": "skills/"
     },

+ 225 - 103
src/components/sources/outline-chat-panel.tsx

@@ -97,6 +97,17 @@ import {
   type CharacterSaveDraft,
   extractCharacterSaveDrafts,
 } from "@/lib/novel/character-save-extractor";
+import {
+  buildCharacterAgentSystemPrompt,
+  buildCharacterAgentUserPrompt,
+  buildCharacterAgentPlans,
+  buildCharacterPlannerSystemPrompt,
+  buildCharacterPlannerUserPrompt,
+  parseCharacterPlannerResult,
+  runCharacterMultiAgent,
+  type CharacterAgentPlan,
+  type CharacterAgentResult,
+} from "@/lib/novel/character-multi-agent";
 import { classifyOutlineSaveTarget } from "@/lib/novel/outline-save-classifier";
 import {
   buildOutlineGenerationQualityFeedback,
@@ -121,7 +132,6 @@ import {
 } from "@/lib/novel/model-resolver";
 import { getEffectiveSavedModels } from "@/lib/llm-model-keys";
 import { ChatModelSelector } from "@/components/chat/chat-model-selector";
-import { useStreamingText } from "@/hooks/use-streaming-text";
 import { highlightCode } from "@/lib/streaming-code-highlight";
 import { separateThinking } from "@/lib/separate-thinking";
 import { StreamingMarkdown } from "@/components/common/streaming-markdown";
@@ -1156,7 +1166,6 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
   const activeRunState = activeConversationId ? runStates[activeConversationId] : undefined;
   const isStreaming = activeRunState?.status === "running";
   const streamingContent = activeConversationId ? streamingContents[activeConversationId] ?? "" : "";
-  const batchedStreamingContent = useStreamingText(streamingContent, isStreaming);
   const loaded = useOutlineChatStore((s) => s.loaded);
   const createConversation = useOutlineChatStore((s) => s.createConversation);
   const setActiveConversation = useOutlineChatStore(
@@ -1497,7 +1506,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
       behavior: "smooth",
     });
     lastScrollTopRef.current = container.scrollTop;
-  }, [activeMessages, batchedStreamingContent]);
+  }, [activeMessages, streamingContent]);
 
   // 重新进入面板时滚动到最后一条消息
   // AI 大纲消息渲染较重(StreamingMarkdown、时间线、工具调用等),
@@ -1641,29 +1650,6 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
       }
       delete pendingRepairMetaRef.current[conversationId];
 
-      const qualityFeedback = parsed.requests
-        .map((request) =>
-          buildOutlineGenerationQualityFeedback({
-            fileType: request.fileType,
-            fileName: request.fileName,
-            content: request.content,
-          }),
-        )
-        .find((feedback): feedback is OutlineGenerationQualityFeedback =>
-          Boolean(feedback && feedback.status !== "pass"),
-        );
-
-      if (qualityFeedback) {
-        setQualityFeedbackStates((states) => setOutlineSessionValue(states, conversationId, qualityFeedback));
-        const split = splitConfirmRequiredSaveRequests(parsed.requests);
-        setQualityConfirmStates((states) => setOutlineSessionValue(states, conversationId, {
-          feedback: qualityFeedback,
-          requests: split.autoSaveable,
-        }));
-        setSaveStatus("");
-        return;
-      }
-
       setSaveStatus("正在自动保存大纲...");
       try {
         const projectPath = normalizePath(project.path);
@@ -2043,6 +2029,10 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
               ...agentConfig,
               requestOverrides: {
                 ...agentConfig.requestOverrides,
+                max_tokens: Math.max(
+                  agentConfig.requestOverrides?.max_tokens ?? 0,
+                  32768,
+                ),
                 userMemorySurface: "ai-outline" as const,
                 userMemoryProjectKey: normalizePath(project.path),
                 userMemorySessionKey: capturedConvId,
@@ -2060,7 +2050,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
             streamToUser?: boolean;
             statusText?: string;
           } = {},
-        ): Promise<{ text: string; record: AgentRunRecord }> => {
+        ): Promise<{ text: string; record: AgentRunRecord; error?: Error }> => {
           const { agentConfig, registry } = buildConfigForSkillNames(
             optionsForRun.skillNames,
             optionsForRun.disableWriteTools,
@@ -2079,7 +2069,13 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
                 runText += chunk;
                 if (optionsForRun.streamToUser) {
                   result += chunk;
-                  if (isCurrentRun()) setStreamingContent(capturedConvId, result);
+                  if (isCurrentRun()) {
+                    setStreamingContent(capturedConvId, result);
+                    updateOutlineAssistantMessage(convId, assistantId, (message) => ({
+                      ...message,
+                      content: result,
+                    }));
+                  }
                 }
               },
               onToolCall: () => {},
@@ -2108,8 +2104,10 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
           );
           providerUsage = addLlmUsage(providerUsage, record.usage);
           allToolCalls.push(...record.toolCalls);
-          if (agentError) throw agentError;
-          return { text: runText || record.finalText, record };
+          const errMsg = agentError?.message ?? "";
+          const isLengthTruncated = errMsg.includes("输出被截断") || errMsg.includes("最大输出 token");
+          if (agentError && !isLengthTruncated) throw agentError;
+          return { text: runText || record.finalText, record, error: agentError ?? undefined };
         };
 
         const runSingleAgentFallback = async () => {
@@ -2119,12 +2117,130 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
             disableWriteTools: options.disableWriteTools,
             streamToUser: true,
           });
-          return singleRun.text || "AI大纲未返回内容。";
+          let text = singleRun.text || "AI大纲未返回内容。";
+          if (singleRun.error) {
+            const errorMsg = singleRun.error.message;
+            text = text + "\n\n---\n\n⚠️ **注意**:" + errorMsg + "\n\n您可以在新消息中输入\"继续\"来让模型补全剩余内容,或点击保存尝试保存已生成的内容。";
+          }
+          return text;
         };
 
         let finalText = "";
         let capturedSuccessfulResults: OutlineSubAgentResult[] = [];
-        if (options.enableMultiAgent) {
+        let capturedCharacterResults: CharacterAgentResult[] = [];
+
+        const currentIntentContext = intentContextsRef.current[capturedConvId];
+        const isCharacterMultiAgentTask =
+          options.intentPhase === "generation" &&
+          currentIntentContext?.title === "人物小传" &&
+          !options.enableMultiAgent;
+
+        if (isCharacterMultiAgentTask) {
+          setStreamingContent(capturedConvId, "正在分析需要生成的角色清单...");
+          updateOutlineAssistantMessage(convId, assistantId, (message) => ({
+            ...message,
+            content: "# 人物小传生成中\n\n正在分析角色清单,请稍候...",
+          }));
+
+          const projectContext = [
+            targetConversation?.contextSummary?.text,
+            contextDecision.instruction,
+            historyPlan.instruction,
+          ].filter(Boolean).join("\n");
+
+          let characterPlans: CharacterAgentPlan[] = [];
+          try {
+            const plannerMessages: AgentMessage[] = [
+              { role: "system", content: buildOutlineRunSystemContent(buildCharacterPlannerSystemPrompt()) },
+              { role: "user", content: buildCharacterPlannerUserPrompt({ userPrompt: prompt, projectContext }) },
+            ];
+            const plannerRun = await runOutlineAgentOnce(plannerMessages, {
+              skillNames: [],
+              disableWriteTools: true,
+            });
+            const plannerResult = parseCharacterPlannerResult(plannerRun.text);
+            characterPlans = buildCharacterAgentPlans(plannerResult, prompt, projectContext);
+          } catch {
+            characterPlans = [];
+          }
+
+          if (characterPlans.length === 0) {
+            setStreamingContent(capturedConvId, "角色规划未识别到明确角色,按单 Agent 模式生成...");
+            finalText = await runSingleAgentFallback();
+          } else {
+            const headerContent = `# 人物小传\n\n共识别到 ${characterPlans.length} 个角色,正在并行生成...\n\n`;
+            updateOutlineAssistantMessage(convId, assistantId, (message) => ({
+              ...message,
+              content: `# 人物小传生成中\n\n共识别到 ${characterPlans.length} 个角色,正在并行生成...`,
+            }));
+            setStreamingContent(capturedConvId, headerContent);
+
+            const completedByIndex: (CharacterAgentResult | null)[] = new Array(characterPlans.length).fill(null);
+
+            const rebuildAccumulated = () => {
+              const parts: string[] = ["# 人物小传\n\n"];
+              let hasContent = false;
+              for (const r of completedByIndex) {
+                if (r) {
+                  if (hasContent) parts.push("\n\n---\n\n");
+                  parts.push(r.content);
+                  hasContent = true;
+                }
+              }
+              return parts.join("");
+            };
+
+            const multiAgentResult = await runCharacterMultiAgent({
+              plans: characterPlans,
+              maxConcurrency: 2,
+              runCharacterAgent: async (charPlan) => {
+                const charMessages: AgentMessage[] = [
+                  { role: "system", content: buildOutlineRunSystemContent(buildCharacterAgentSystemPrompt(charPlan)) },
+                  ...historyPlan.messages.slice(-2),
+                  { role: "user", content: charPlan.taskPrompt },
+                ];
+                const charRun = await runOutlineAgentOnce(charMessages, {
+                  skillNames: options.preferredSkillNames,
+                  disableWriteTools: true,
+                  streamToUser: false,
+                });
+                if (charRun.error) {
+                  throw new Error(charRun.error.message);
+                }
+                return charRun.text;
+              },
+              onCharacterStart: (_charPlan) => {
+                if (!isCurrentRun()) return;
+              },
+              onCharacterComplete: (result) => {
+                if (!isCurrentRun()) return;
+                completedByIndex[result.plan.index] = result;
+                const newContent = rebuildAccumulated();
+                setStreamingContent(capturedConvId, newContent);
+                updateOutlineAssistantMessage(convId, assistantId, (message) => ({
+                  ...message,
+                  content: newContent,
+                }));
+              },
+              onCharacterError: (_charPlan, _error) => {
+                if (!isCurrentRun()) return;
+              },
+            });
+
+            capturedCharacterResults = multiAgentResult.characters;
+
+            if (multiAgentResult.characters.length === 0) {
+              finalText = await runSingleAgentFallback();
+            } else {
+              finalText = `# 人物小传\n\n${multiAgentResult.combinedMarkdown}`;
+            }
+
+            updateOutlineAssistantMessage(convId, assistantId, (message) => ({
+              ...message,
+              characterMultiAgentResults: multiAgentResult.characters,
+            }));
+          }
+        } else if (options.enableMultiAgent) {
           const maxConcurrency = 3;
           const fallbackSubAgentPlan = planOutlineSubAgents({
             preferredSkillNames: options.preferredSkillNames ?? [],
@@ -2225,7 +2341,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
                   ),
                 },
               ];
-              let subRun: { text: string; record: AgentRunRecord };
+              let subRun: { text: string; record: AgentRunRecord; error?: Error };
               try {
                 subRun = await runOutlineAgentOnce(subAgentMessages, {
                   skillNames: subAgentPlan.skillNames,
@@ -2243,6 +2359,16 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
                 }));
                 throw error;
               }
+              if (subRun.error) {
+                const message = subRun.error.message;
+                updateOutlineMultiAgentItem(convId, assistantId, subAgentPlan.id, (agent) => ({
+                  ...agent,
+                  status: "error",
+                  error: message,
+                  finishedAt: Date.now(),
+                }));
+                throw new Error(message);
+              }
               return subRun.text;
 
             },
@@ -2303,7 +2429,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
                   ].join("\n"),
                 },
               ];
-              let mergeRun: { text: string; record: AgentRunRecord };
+              let mergeRun: { text: string; record: AgentRunRecord; error?: Error };
               try {
                 mergeRun = await runOutlineAgentOnce(mergeMessages, {
                   skillNames: options.preferredSkillNames,
@@ -2327,16 +2453,20 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
                 throw error;
               }
               if (!isCurrentRun()) throw new Error("aborted");
+              let mergeText = mergeRun.text || "AI大纲未返回内容。";
+              if (mergeRun.error) {
+                mergeText = mergeText + "\n\n---\n\n⚠️ **注意**:" + mergeRun.error.message + "\n\n您可以在新消息中输入\"继续\"来让模型补全剩余内容,或点击保存尝试保存已生成的内容。";
+              }
               updateOutlineMultiAgentRun(convId, assistantId, (run) => run ? ({
                 ...run,
                 status: "done",
                 merge: {
                   status: "done",
                   finishedAt: Date.now(),
-                  summary: "合并完成,已输出最终大纲草稿。",
+                  summary: mergeRun.error ? "合并完成,但内容可能被截断。" : "合并完成,已输出最终大纲草稿。",
                 },
               }) : run);
-              return mergeRun.text || "AI大纲未返回内容。";
+              return mergeText;
             },
           });
           if (!isCurrentRun()) return { started: true, sent: false };
@@ -2412,27 +2542,10 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
           completedModule: intentContextsRef.current[capturedConvId]?.title || "当前模块",
         });
         const cleanFinalContent = nextStepExtraction.cleanText || "AI大纲未返回内容。";
-        const structuredMarkdownEnabled = options.intentPhase === "generation"
-          || options.novelGenerationRequest !== undefined;
-        const finalContent = await finalizeStructuredMarkdownMessage(
-          cleanFinalContent,
-          {
-            enabled: structuredMarkdownEnabled,
-            repairWithAi: ({ content, maxTokens }) => repairMarkdownFormatWithAi({
-              content,
-              llmConfig: effectiveLlmConfig,
-              signal: controller.signal,
-              maxTokens,
-            }),
-            onFailure: () => {
-              if (!isCurrentRun()) return { started: true, sent: false };
-              toast.info("Markdown 格式自动修复未完全通过,已保留内容最完整的版本。", {
-                dedupeKey: "outline-markdown-quality-incomplete",
-              });
-            },
-          },
-        );
+        const finalContent = cleanFinalContent;
         if (!isCurrentRun()) return { started: true, sent: false };
+        // 先将流式内容同步为最终内容,确保打字机效果立即终止、切换无跳变
+        setStreamingContent(capturedConvId, finalContent);
         const visibleToolCalls = allToolCalls.length ? allToolCalls : [];
         const shouldShowToolProcess =
           historyPlan.showToolProcess ||
@@ -2454,9 +2567,17 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
         // 解析意图清晰度结果
         const intentResult = parseIntentClarity(finalContent);
         if (intentResult) {
-          const intentContext = intentContextsRef.current[capturedConvId] ?? { title: "", hint: "" };
+          const existingContext = intentContextsRef.current[capturedConvId] ?? { title: "", hint: "" };
+          const matchedConfig = !existingContext.title
+            ? OUTLINE_SECTION_GENERATION_CONFIGS.find((c) => c.title === intentResult.module)
+            : null;
+          const updatedContext = matchedConfig
+            ? { title: matchedConfig.title, hint: matchedConfig.requestHint, outputMode: matchedConfig.outputMode }
+            : existingContext.title
+              ? existingContext
+              : { ...existingContext, title: intentResult.module };
           intentContextsRef.current = setOutlineSessionValue(intentContextsRef.current, capturedConvId, {
-            ...intentContext,
+            ...updatedContext,
             result: intentResult,
           });
           updateOutlineAssistantMessage(convId, assistantId, (message) => ({
@@ -2903,7 +3024,13 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
             ], {
               onText: (chunk) => {
                 mergeText += chunk;
-                if (isCurrentRun()) setStreamingContent(capturedConvId, mergeText);
+                if (isCurrentRun()) {
+                  setStreamingContent(capturedConvId, mergeText);
+                  updateOutlineAssistantMessage(capturedConvId, messageId, (message) => ({
+                    ...message,
+                    content: mergeText,
+                  }));
+                }
               },
               onToolCall: () => {},
               onToolResult: () => {},
@@ -3244,7 +3371,17 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
           {
             onText: (chunk) => {
               result += chunk;
-              if (isCurrentRun()) setStreamingContent(capturedConvId, result);
+              if (isCurrentRun()) {
+                setStreamingContent(capturedConvId, result);
+                updateOutlineAssistantMessage(
+                  capturedConvId,
+                  assistantId,
+                  (message) => ({
+                    ...message,
+                    content: result,
+                  }),
+                );
+              }
             },
             onToolCall: () => {},
             onToolResult: () => {},
@@ -3312,19 +3449,10 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
           { allowFallback: true, completedModule: "当前模块" },
         );
         const cleanFinalContent = nextStepExtraction.cleanText || "AI大纲未返回内容。";
-        const finalContent = await finalizeStructuredMarkdownMessage(cleanFinalContent, {
-          enabled: regenerationInput.structuredGeneration,
-          repairWithAi: ({ content, maxTokens }) => repairMarkdownFormatWithAi({
-            content,
-            llmConfig: effectiveLlmConfig,
-            signal: controller.signal,
-            maxTokens,
-          }),
-          onFailure: () => toast.info("Markdown 格式自动修复未完全通过,已保留内容最完整的版本。", {
-            dedupeKey: "outline-markdown-quality-incomplete",
-          }),
-        });
+        const finalContent = cleanFinalContent;
         if (!isCurrentRun()) return;
+        // 先将流式内容同步为最终内容,确保打字机效果立即终止、切换无跳变
+        setStreamingContent(capturedConvId, finalContent);
         updateOutlineAssistantMessage(
           capturedConvId,
           assistantId,
@@ -3437,7 +3565,31 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
           title: draft.title,
           content: draft.content,
         });
+
+        const currentConv = useOutlineChatStore.getState().conversations.find((c) => c.id === capturedConvId);
+        const lastAssistantMsg = [...(currentConv?.messages ?? [])].reverse().find((m) => m.role === "assistant");
+        const characterResults = lastAssistantMsg?.characterMultiAgentResults;
+
         if (classification.fileType === "character") {
+          if (characterResults && characterResults.length > 0) {
+            const characterDrafts: CharacterSaveDraft[] = characterResults.map((r) => ({
+              id: `${r.plan.roleType}:${r.plan.characterName}`,
+              characterName: r.plan.characterName,
+              roleType: r.plan.roleType,
+              fileName: r.fileName,
+              content: r.content,
+              selected: true,
+              confidence: "high",
+            }));
+            setSaveConfirmState({
+              title: "请确认要保存的人物角色",
+              mode: "character",
+              requests: [],
+              characterDrafts,
+            });
+            return;
+          }
+
           const extracted = extractCharacterSaveDrafts(draft.content);
           if (extracted.drafts.length === 0) {
             setSaveStatus(extracted.errors.join(";"));
@@ -3454,36 +3606,6 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
 
         const body = draft.content.replace(/^#\s+.+(?:\r?\n){1,2}/, "").trim();
         const mdContent = `# ${classification.fileName.replace(/\.md$/i, "")}\n\n${body}`;
-        if (classification.fileType === "chapter-outline") {
-          const quality = summarizeChapterOutlineQuality(mdContent);
-          if (!quality.valid) {
-            const qualityFeedback = buildOutlineGenerationQualityFeedback({
-              fileType: classification.fileType,
-              fileName: classification.fileName,
-              content: mdContent,
-            });
-            if (qualityFeedback) {
-              setQualityFeedbackStates((states) => setOutlineSessionValue(states, capturedConvId, qualityFeedback));
-              setQualityConfirmStates((states) => setOutlineSessionValue(states, capturedConvId, {
-                feedback: qualityFeedback,
-                requests: [{
-                  targetFolder: classification.targetFolder,
-                  fileName: classification.fileName,
-                  fileType: classification.fileType,
-                  writeMode: "create",
-                  referencedSkills: [],
-                  sourceIntent: "手动保存 AI 大纲结果",
-                  content: mdContent,
-                }],
-              }));
-            }
-            setSaveStatus(formatChapterOutlineQualityReport(quality, {
-              maxIssues: 4,
-              includeWarnings: true,
-            }));
-            return;
-          }
-        }
         setSaveConfirmState({
           title: "保存大纲文件",
           mode: "normal",
@@ -3861,7 +3983,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
                   msg={msg}
                   index={i}
                   isStreaming={isStreaming}
-                  streamingContent={batchedStreamingContent}
+                  streamingContent={streamingContent}
                   activeMessagesLength={activeMessages.length}
                   copied={copied}
                   projectPath={project?.path ?? null}

+ 304 - 0
src/lib/novel/character-multi-agent.ts

@@ -0,0 +1,304 @@
+import { buildCharacterFileName } from "./character-save-extractor"
+
+export const VALID_ROLE_TYPES = new Set([
+  "男主", "女主", "男配", "女配", "反派", "导师", "盟友", "配角", "主角",
+])
+
+export interface CharacterAgentPlan {
+  id: string
+  index: number
+  characterName: string
+  roleType: string
+  taskPrompt: string
+}
+
+export interface CharacterAgentResult {
+  plan: CharacterAgentPlan
+  content: string
+  fileName: string
+}
+
+export interface CharacterMultiAgentRunInput {
+  plans: CharacterAgentPlan[]
+  maxConcurrency?: number
+  runCharacterAgent: (plan: CharacterAgentPlan) => Promise<string>
+  onCharacterStart?: (plan: CharacterAgentPlan) => void
+  onCharacterComplete?: (result: CharacterAgentResult) => void
+  onCharacterError?: (plan: CharacterAgentPlan, error: Error) => void
+}
+
+export interface CharacterMultiAgentRunResult {
+  characters: CharacterAgentResult[]
+  failedCharacters: Array<{ plan: CharacterAgentPlan; error: string }>
+  combinedMarkdown: string
+}
+
+export interface CharacterPlannerResult {
+  characters: Array<{ name: string; roleType: string }>
+}
+
+const MAX_CONCURRENCY = 2
+const MAX_CHARACTERS = 20
+
+export function buildCharacterPlannerSystemPrompt(): string {
+  return [
+    "你是角色规划专家,只负责分析用户需求并提取需要生成小传的角色清单。",
+    "你不生成角色小传内容,只输出角色清单 JSON。",
+    "必须严格按照要求输出,不得添加任何解释、说明或额外文字。",
+  ].join("\n")
+}
+
+export function buildCharacterPlannerUserPrompt(input: {
+  userPrompt: string
+  projectContext: string
+}): string {
+  const contextSection = input.projectContext.trim()
+    ? `## 已有项目大纲/记忆\n${input.projectContext.trim()}\n`
+    : "## 已有项目大纲/记忆\n(当前无已有大纲内容)\n"
+
+  return [
+    "## 任务",
+    "分析用户需求,结合已有项目大纲/记忆,提取需要生成人物小传的角色清单。",
+    "",
+    contextSection,
+    "## 用户需求",
+    input.userPrompt,
+    "",
+    "## 输出要求",
+    "- 只输出一个 JSON 代码块,不要输出其他任何内容",
+    "- JSON 格式:{\"characters\": [{\"name\": \"角色名\", \"roleType\": \"角色定位\"}, ...]}",
+    "- roleType 必须是以下枚举值之一:男主、女主、男配、女配、反派、导师、盟友、配角、主角",
+    "- 如果用户明确指定了角色,使用用户指定的角色名和定位",
+    "- 如果用户没有明确指定角色,从已有项目大纲/记忆中提取主要角色",
+    "- 如果既没有明确指定也找不到已有角色信息,返回空列表",
+    "- 最多返回 20 个角色",
+    "- 角色名不超过 20 个字符",
+    "",
+    "```json",
+  ].join("\n")
+}
+
+export function parseCharacterPlannerResult(text: string): CharacterPlannerResult {
+  try {
+    const jsonBlockRegex = /```(?:json)?\s*\n?([\s\S]*?)```/g
+    let match: RegExpExecArray | null
+    let jsonText = ""
+
+    while ((match = jsonBlockRegex.exec(text)) !== null) {
+      const candidate = match[1].trim()
+      if (/"characters"\s*:/.test(candidate)) {
+        jsonText = candidate
+        break
+      }
+    }
+
+    if (!jsonText) {
+      const fallback = text.trim()
+      if (fallback.startsWith("{") && fallback.includes("characters")) {
+        jsonText = fallback
+      }
+    }
+
+    if (!jsonText) {
+      return { characters: [] }
+    }
+
+    const parsed = JSON.parse(jsonText)
+    const rawChars = Array.isArray(parsed?.characters) ? parsed.characters : []
+
+    const seen = new Set<string>()
+    const characters = rawChars
+      .map((c: { name?: string; roleType?: string }) => ({
+        name: String(c?.name ?? "").trim(),
+        roleType: String(c?.roleType ?? "角色").trim(),
+      }))
+      .filter((c: { name: string; roleType: string }) => c.name.length > 0 && c.name.length <= 20)
+      .filter((c: { name: string; roleType: string }) => {
+        const key = `${c.roleType}:${c.name}`
+        if (seen.has(key)) return false
+        seen.add(key)
+        return true
+      })
+      .slice(0, MAX_CHARACTERS)
+      .map((c: { name: string; roleType: string }) => ({
+        name: c.name,
+        roleType: VALID_ROLE_TYPES.has(c.roleType) ? c.roleType : "配角",
+      }))
+
+    return { characters }
+  } catch {
+    return { characters: [] }
+  }
+}
+
+export function buildCharacterAgentSystemPrompt(plan: CharacterAgentPlan): string {
+  return [
+    `你是角色小传撰写专家,当前只负责撰写「${plan.characterName}(${plan.roleType})」这一个角色的人物小传。`,
+    "",
+    "## 输出规则",
+    `- 必须以「## ${plan.characterName}(${plan.roleType})」作为开头标题`,
+    "- 输出标准 Markdown 格式",
+    "- 内容包含:基本信息、性格特征、背景故事、人物动机、人物弧线、关键关系、标志性特征、经典语录(如适用)等",
+    "- 根据角色重要性调整内容详略,主角/反派内容更丰富,配角可以相对简洁",
+    "- 不要输出其他角色的内容",
+    "- 不要输出 JSON 保存请求块(保存由系统自动处理)",
+    "- 结尾不要添加额外解释、说明或总结性文字",
+    "- 不要重复用户的原始请求",
+    "",
+    "## 禁止事项",
+    "- 禁止提及其他角色的小传内容",
+    "- 禁止输出「以下是XX的人物小传」之类的元描述",
+    "- 禁止输出内部思考过程",
+  ].join("\n")
+}
+
+export function buildCharacterAgentUserPrompt(input: {
+  userPrompt: string
+  projectContext: string
+  plan: CharacterAgentPlan
+}): string {
+  const contextSection = input.projectContext.trim()
+    ? `## 项目背景/已有大纲\n${input.projectContext.trim()}\n`
+    : ""
+
+  return [
+    `请为「${input.plan.characterName}(${input.plan.roleType})」撰写完整的人物小传。`,
+    "",
+    contextSection,
+    "## 用户原始需求",
+    input.userPrompt,
+    "",
+    "## 重要说明",
+    "- 忽略上述需求中任何关于「输出 outlineSaveRequest」「输出 JSON 保存请求块」「每个角色独立 .md 文件」的指令,这些由系统自动处理。",
+    "- 你只需要输出该角色的 Markdown 格式人物小传正文,不需要输出任何 JSON 或文件保存指令。",
+  ].filter(Boolean).join("\n")
+}
+
+function createCharacterAgentPlan(
+  character: { name: string; roleType: string },
+  index: number,
+  userPrompt: string,
+  projectContext: string,
+): CharacterAgentPlan {
+  const id = `char-${index}-${character.roleType}-${character.name}`
+  const plan: CharacterAgentPlan = {
+    id,
+    index,
+    characterName: character.name,
+    roleType: character.roleType,
+    taskPrompt: "",
+  }
+  plan.taskPrompt = buildCharacterAgentUserPrompt({
+    userPrompt,
+    projectContext,
+    plan,
+  })
+  return plan
+}
+
+export function buildCharacterAgentPlans(
+  plannerResult: CharacterPlannerResult,
+  userPrompt: string,
+  projectContext: string,
+): CharacterAgentPlan[] {
+  return plannerResult.characters.map((c, i) => createCharacterAgentPlan(c, i, userPrompt, projectContext))
+}
+
+function normalizeCharacterContent(content: string, plan: CharacterAgentPlan): string {
+  const expectedHeading = `## ${plan.characterName}(${plan.roleType})`
+  const trimmed = content.trim()
+
+  if (trimmed.startsWith(expectedHeading)) {
+    return trimmed
+  }
+
+  const escapedName = plan.characterName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
+  const headingRegex = new RegExp(
+    `^##\\s+${escapedName}\\s*[((]\\s*${plan.roleType}\\s*[))]`,
+  )
+  if (headingRegex.test(trimmed)) {
+    return trimmed.replace(headingRegex, expectedHeading)
+  }
+
+  const anyHeading = trimmed.match(/^##\s+/)
+  if (anyHeading) {
+    return `${expectedHeading}\n\n${trimmed.replace(/^##[^\n]*\n*/, "").trim()}`
+  }
+
+  return `${expectedHeading}\n\n${trimmed}`
+}
+
+async function runWithConcurrency<T, R>(
+  items: T[],
+  concurrency: number,
+  fn: (item: T, index: number) => Promise<R>,
+): Promise<R[]> {
+  const results: R[] = new Array(items.length)
+  let currentIndex = 0
+
+  async function worker(): Promise<void> {
+    while (true) {
+      const i = currentIndex++
+      if (i >= items.length) return
+      results[i] = await fn(items[i], i)
+    }
+  }
+
+  const workerCount = Math.min(concurrency, items.length)
+  if (workerCount <= 0) return results
+  const workers = Array.from({ length: workerCount }, () => worker())
+  await Promise.all(workers)
+  return results
+}
+
+export async function runCharacterMultiAgent(
+  input: CharacterMultiAgentRunInput,
+): Promise<CharacterMultiAgentRunResult> {
+  const concurrency = input.maxConcurrency ?? MAX_CONCURRENCY
+  const results: CharacterAgentResult[] = []
+  const failed: Array<{ plan: CharacterAgentPlan; error: string }> = []
+
+  if (input.plans.length === 0) {
+    return {
+      characters: [],
+      failedCharacters: [],
+      combinedMarkdown: "",
+    }
+  }
+
+  const indexedResults: (CharacterAgentResult | null)[] = new Array(input.plans.length).fill(null)
+
+  await runWithConcurrency(input.plans, concurrency, async (plan) => {
+    input.onCharacterStart?.(plan)
+
+    try {
+      const rawContent = await input.runCharacterAgent(plan)
+      const normalizedContent = normalizeCharacterContent(rawContent || "", plan)
+
+      const result: CharacterAgentResult = {
+        plan,
+        content: normalizedContent,
+        fileName: buildCharacterFileName(plan.roleType, plan.characterName),
+      }
+
+      indexedResults[plan.index] = result
+      input.onCharacterComplete?.(result)
+    } catch (error) {
+      const message = error instanceof Error ? error.message : String(error)
+      failed.push({ plan, error: message })
+      input.onCharacterError?.(plan, error instanceof Error ? error : new Error(message))
+    }
+  })
+
+  for (const r of indexedResults) {
+    if (r) results.push(r)
+  }
+
+  const combinedMarkdown = results.map((r) => r.content).join("\n\n---\n\n")
+
+  return {
+    characters: results,
+    failedCharacters: failed,
+    combinedMarkdown,
+  }
+}

+ 2 - 0
src/stores/outline-chat-store.ts

@@ -9,6 +9,7 @@ import { useWikiStore } from "@/stores/wiki-store"
 import type { IntentClarityResult } from "@/lib/novel/outline-intent-clarity"
 import type { NextStepRecommendation } from "@/lib/novel/outline-next-step"
 import { isNovelGenerationRequestPackage, type NovelGenerationRequestPackage } from "@/lib/novel/novel-generation-request-package"
+import type { CharacterAgentResult } from "@/lib/novel/character-multi-agent"
 import {
   canStartConversationRun as canStartRun,
   failConversationRun as createFailedRunState,
@@ -82,6 +83,7 @@ export interface OutlineChatMessage {
   sources?: string[]
   agentToolCalls?: AgentRunRecord["toolCalls"]
   multiAgentRun?: OutlineMultiAgentRunState
+  characterMultiAgentResults?: CharacterAgentResult[]
   showThinkingProcess?: boolean
   isAgentRunning?: boolean
   attachedReferences?: ReferenceToken[]