diff --git a/backend/app/database.py b/backend/app/database.py index 716d64a..d243d52 100644 --- a/backend/app/database.py +++ b/backend/app/database.py @@ -43,38 +43,121 @@ def _seed_builtin_skills() -> None: builtin_skills = [ Skill( - name="通用写作助手", + name="网文写作助手", description="全能写作助手,可使用所有工具", - system_prompt="你是一个专业的小说写作助手,擅长构思情节、塑造角色、打磨文笔。请根据用户需求提供创作建议,必要时主动使用工具查看和操作小说数据。", - allowed_tools="[]", + system_prompt=( + "你是一个专业的网文小说写作助手,擅长构思情节、塑造角色、打磨文笔。" + "请根据用户需求提供创作建议,必要时主动使用工具查看和操作小说数据。\n" + "- 书写章节前,应对先查看对应的大纲与前一章的内容\n" + "- 确保章节开头可以呼应上一张的钩子\n" + "- 确保在章节尾留下钩子" + ), + allowed_tools=json.dumps([ + "get_novel_info", "list_outlines", "get_outline_detail", + "create_outline", "update_outline", "delete_outline", "reorder_outlines", + "get_world_setting", "list_characters", + "list_chapters", "get_chapter_detail", "create_chapter", "update_chapter", "delete_chapter", + "list_files", "read_file", + ]), is_builtin=True, ), Skill( name="大纲规划师", description="构建清晰的故事骨架,规划情节节点", - system_prompt="你是大纲规划专家。请帮助用户构建清晰的故事骨架,包括起承转合、情节节点和分章结构。分析现有大纲时请关注因果逻辑、节奏控制和悬念布置。在操作大纲时,注意保持节点间的逻辑连贯性。", - allowed_tools=json.dumps(["get_novel_info", "list_outlines", "create_outline", "update_outline", "delete_outline", "reorder_outlines"]), + system_prompt=( + "你是大纲规划专家。请帮助用户构建清晰的故事骨架,包括起承转合、情节节点和分章结构。" + "分析现有大纲时请关注因果逻辑、节奏控制和悬念布置。在操作大纲时,注意保持节点间的逻辑连贯性。" + ), + allowed_tools=json.dumps([ + "get_novel_info", "list_outlines", "get_outline_detail", + "create_outline", "update_outline", "delete_outline", "reorder_outlines", + "get_world_setting", "list_characters", "get_character_detail", + "list_chapters", "get_chapter_detail", + "list_files", "read_file", + ]), is_builtin=True, ), Skill( name="角色塑造师", description="设计立体、有深度的角色", - system_prompt="你是角色塑造专家。请帮助用户设计立体、有深度的角色,包括外貌、性格、动机、成长弧线和人物关系。注意角色间的化学反应和戏剧张力,确保每个角色都有独特的声音和行为逻辑。", - allowed_tools=json.dumps(["get_novel_info", "list_characters", "create_character", "update_character", "delete_character"]), + system_prompt=( + "你是角色塑造专家。请帮助用户设计立体、有深度的角色,包括外貌、性格、动机、成长弧线和人物关系。" + "注意角色间的化学反应和戏剧张力,确保每个角色都有独特的声音和行为逻辑。" + ), + allowed_tools=json.dumps([ + "get_novel_info", "list_characters", "get_character_detail", + "create_character", "update_character", "delete_character", + "list_outlines", "get_outline_detail", "get_world_setting", + "list_chapters", "get_chapter_detail", "list_files", + ]), is_builtin=True, ), Skill( name="世界观架构师", description="构建完整自洽的虚构世界", - system_prompt="你是世界观构建专家。请帮助用户设计完整自洽的虚构世界,包括地理、历史、政治、经济、魔法或科技体系等。确保设定内部逻辑一致,并与故事主线相辅相成。", - allowed_tools=json.dumps(["get_novel_info", "get_world_setting", "save_world_setting", "list_outlines", "list_characters"]), + system_prompt=( + "你是世界观构建专家。请帮助用户设计完整自洽的虚构世界,包括地理、历史、政治、经济、魔法或科技体系等。" + "确保设定内部逻辑一致,并与故事主线相辅相成。" + ), + allowed_tools=json.dumps([ + "get_novel_info", "get_world_setting", "save_world_setting", + "list_outlines", "get_outline_detail", + "list_characters", "get_character_detail", + "list_files", "read_file", + ]), is_builtin=True, ), Skill( - name="文稿分析师", - description="分析上传的小说或剧本文件", - system_prompt="你是文稿分析专家。请仔细阅读用户上传的文件,提取关键信息,分析文风、角色、情节结构,并提供改进建议。分析时可以分段读取大文件,注意把握整体脉络。", - allowed_tools=json.dumps(["get_novel_info", "list_files", "read_file", "list_outlines", "list_characters", "list_chapters"]), + name="大纲拆解专家", + description="分析上传的小说或剧本文件,并拆解为大纲", + system_prompt=( + "你是文稿分析专家。请仔细阅读用户上传的文件,提取关键信息,分析文风、角色、情节结构,并提供改进建议。" + "分析时调用subagent分段读取大文件并做出分析,注意把握整体脉络。\n" + "单次调用subagent最多查看原文的5000字,并进行工具调用保存,循环处理,直到整个小说都拆解完成。\n" + "拆解完成后仅输出完成即可,不需要进行任何形式的总结。\n" + "在每次阅读完成后,必须调用工具将人物写入系统。\n" + "阅读过程中逐步完成以下内容拆解:\n- 世界观\n- 大纲\n" + "在每次阅读完成后,必须调用工具将世界观与大纲写入系统。\n" + "注意:\n" + "- 调用更新和创建新大纲时,必须先调用查看章节列表,确保没有重复内容\n" + "- 每次调用工具写入大纲后,记录当前进度\n" + "- 每次读取原文前,注意延续上下文中上一次的进度\n" + "- 拆解全本小说完成前不要停止" + ), + allowed_tools=json.dumps([ + "get_novel_info", "list_outlines", "get_outline_detail", + "create_outline", "update_outline", "delete_outline", "reorder_outlines", + "get_world_setting", "save_world_setting", + "list_characters", "get_character_detail", + "list_chapters", "get_chapter_detail", + "list_files", "read_file", + ]), + is_builtin=True, + ), + Skill( + name="人物拆解专家", + description="阅读小说原文,并将人物进行拆解", + system_prompt=( + "你是文稿分析专家。请仔细阅读用户上传的文件,提取关键信息,分析文风、角色、情节结构,并提供改进建议。" + "分析时调用subagent分段读取大文件并做出分析,注意把握整体脉络。\n" + "单次调用subagent最多查看原文的5000字,并进行工具调用保存,循环处理,直到整个小说都拆解完成。\n" + "阅读过程中逐步完成人物拆解。\n" + "拆解完成后仅输出完成即可,不需要进行任何形式的总结。\n" + "在每次阅读完成后,必须调用工具将人物写入系统。\n" + "注意:\n" + "- 不要漏拆人物\n" + "- 要对人物进行深刻的理解与描写\n" + "- 调用更新和创建人物时,必须先调用查看人物列表,确保没有重复,别名重拆\n" + "- 每次调用工具写入人物后,记录当前进度\n" + "- 每次读取原文前,注意延续上下文中上一次的进度\n" + "- 拆解全本小说完成前不要停止" + ), + allowed_tools=json.dumps([ + "get_novel_info", "list_characters", "get_character_detail", + "create_character", "update_character", "delete_character", + "list_outlines", "get_outline_detail", "get_world_setting", + "list_files", "read_file", + ]), is_builtin=True, ), ] diff --git a/backend/app/routers/chat.py b/backend/app/routers/chat.py index 5094d93..be8417f 100644 --- a/backend/app/routers/chat.py +++ b/backend/app/routers/chat.py @@ -1,3 +1,4 @@ +import asyncio import json from datetime import datetime, timezone @@ -12,7 +13,7 @@ from ..services.ai_provider import ( stream_chat, simple_completion, build_assistant_message, build_tool_results_messages, ToolCall, ) -from ..services.tools import get_openai_tools, get_anthropic_tools, execute_tool, get_tools_description +from ..services.tools import get_openai_tools, get_anthropic_tools, execute_tool, get_tools_description, SPECIAL_TOOLS router = APIRouter(prefix="/api/chat", tags=["chat"]) @@ -24,6 +25,7 @@ TOOL_LABELS: dict[str, str] = { "get_novel_info": "查看小说信息", "update_novel_info": "更新小说信息", "list_outlines": "查询大纲", + "get_outline_detail": "查看大纲详情", "create_outline": "创建大纲", "update_outline": "更新大纲", "delete_outline": "删除大纲", @@ -31,17 +33,23 @@ TOOL_LABELS: dict[str, str] = { "get_world_setting": "查询世界观", "save_world_setting": "保存世界观", "list_characters": "查询角色", + "get_character_detail": "查看角色详情", "create_character": "创建角色", "update_character": "更新角色", "delete_character": "删除角色", "list_chapters": "查询章节", + "get_chapter_detail": "查看章节详情", "create_chapter": "创建章节", "update_chapter": "更新章节", "delete_chapter": "删除章节", "list_files": "查询文件", "read_file": "读取文件", + "dispatch_subagent": "派遣子Agent", } +# 子 Agent 最大轮次 +MAX_SUBAGENT_ROUNDS = 5 + def _build_system_prompt( novel_id: int | None, @@ -78,6 +86,21 @@ def _build_system_prompt( if not novel_id: parts.append("\n注意:工具操作需要关联到具体小说。如果用户需要使用工具,请提示他们先进入某本小说。") + # 注入可用 Skill 列表(用于 dispatch_subagent) + from sqlmodel import or_ + skills = session.exec( + select(Skill).where( + or_(Skill.novel_id == novel_id, Skill.novel_id.is_(None)) # type: ignore + ) + ).all() + if skills: + skill_lines = ["\n## 可用子Agent(Skill)"] + skill_lines.append("你可以使用 dispatch_subagent 工具派遣以下子Agent处理子任务:") + for sk in skills: + skill_lines.append(f"- skill_id={sk.id} {sk.name}: {sk.description}") + skill_lines.append("当任务复杂时,考虑将子任务分派给专门的子Agent处理。") + parts.append("\n".join(skill_lines)) + return "\n".join(parts) @@ -108,12 +131,342 @@ def _save_assistant_message(novel_id: int | None, full_response: str, tool_call_ s.commit() +async def _run_subagent( + queue: asyncio.Queue, + config: AIConfig, + skill: Skill, + task: str, + novel_id: int, + subagent_id: str = "", +) -> str: + """运行子 Agent:独立 LLM 循环,通过 queue 发送事件。返回最终结果文本。 + subagent_id 用于前端区分并行运行的多个子 Agent。""" + # 解析 Skill 的工具白名单 + sub_allowed: list[str] | None = None + if skill.allowed_tools: + try: + parsed = json.loads(skill.allowed_tools) + if isinstance(parsed, list) and len(parsed) > 0: + sub_allowed = [t for t in parsed if t != "dispatch_subagent"] + except (json.JSONDecodeError, TypeError): + pass + + # 构建子 Agent 系统提示词 + sub_system_parts = [ + f"你是子Agent「{skill.name}」。", + f"你的职责:{skill.description}" if skill.description else "", + ] + if skill.system_prompt: + sub_system_parts.append(skill.system_prompt) + + with Session(engine) as s: + novel = s.get(Novel, novel_id) + if novel: + sub_system_parts.append(f"\n当前小说:《{novel.title}》(ID: {novel.id})") + sub_tools_desc = get_tools_description(sub_allowed) + if sub_tools_desc: + sub_system_parts.append(f"\n## 你的工具能力\n{sub_tools_desc}") + + sub_system = "\n".join(p for p in sub_system_parts if p) + sub_tools = _get_tools(config.provider, sub_allowed) + sub_messages: list[dict] = [{"role": "user", "content": task}] + + full_response = "" + sid = subagent_id # 简写 + + for _round in range(MAX_SUBAGENT_ROUNDS): + round_text = "" + round_tool_calls: list[ToolCall] = [] + + async for event in stream_chat(config, sub_messages, sub_system, sub_tools): + if event.text: + round_text += event.text + await queue.put({"subagent_chunk": event.text, "subagent_id": sid}) + if event.tool_calls: + round_tool_calls = event.tool_calls + + if not round_tool_calls: + full_response += round_text + break + + full_response += round_text + + assistant_msg = build_assistant_message( + config.provider, round_text, round_tool_calls + ) + sub_messages.append(assistant_msg) + + tool_results = [] + for tc in round_tool_calls: + label = TOOL_LABELS.get(tc.name, tc.name) + await queue.put({"subagent_tool_call": { + "name": tc.name, "label": label, "arguments": tc.arguments, + }, "subagent_id": sid}) + result = execute_tool(tc.name, tc.arguments, novel_id) + tool_results.append({"id": tc.id, "result": result}) + await queue.put({"subagent_tool_result": { + "name": tc.name, "label": label, "result": result, + }, "subagent_id": sid}) + + result_msgs = build_tool_results_messages(config.provider, tool_results) + sub_messages.extend(result_msgs) + + await queue.put({"subagent_done": full_response, "subagent_id": sid}) + return full_response + + +def _summarize_result(tool_name: str, result_json: str) -> str: + """将工具结果精简为简短摘要,减少上下文 token 占用。""" + try: + data = json.loads(result_json) + except (json.JSONDecodeError, TypeError): + return result_json[:100] + + if "error" in data: + return f"错误: {data['error']}" + + # 列表类工具 → 仅显示数量 + if tool_name == "list_outlines": + return f"{data.get('total', 0)}条大纲" + if tool_name == "list_characters": + return f"{data.get('total', 0)}个角色" + if tool_name == "list_chapters": + return f"{data.get('total', 0)}个章节" + if tool_name == "list_files": + return f"{data.get('total', 0)}个文件" + + # 创建类 → ID + 标题 + if tool_name == "create_outline": + return f"已创建 #{data.get('id')} {data.get('summary', '')[:30]}" + if tool_name == "create_character": + return f"已创建 #{data.get('id')} {data.get('name', '')}" + if tool_name == "create_chapter": + return f"已创建 #{data.get('id')} {data.get('title', '')[:30]}" + + # 更新类 + if tool_name == "update_outline": + return f"已更新 #{data.get('id')}" + if tool_name == "update_character": + return f"已更新 #{data.get('id')} {data.get('name', '')}" + if tool_name == "update_chapter": + return f"已更新 #{data.get('id')}" + if tool_name == "update_novel_info": + return f"已更新小说信息" + + # 删除类 + if tool_name in ("delete_outline", "delete_character", "delete_chapter"): + return f"已删除 #{data.get('id')}" + + # 排序 + if tool_name == "reorder_outlines": + return f"已排序 {data.get('count', 0)}个节点" + + # 世界观 + if tool_name == "save_world_setting": + return f"已保存 ({data.get('content_length', 0)}字)" + if tool_name == "get_world_setting": + return f"{'有内容' if data.get('exists') else '暂无内容'}" + + # 详情查看类 + if tool_name == "get_outline_detail": + return f"大纲 #{data.get('id')} {data.get('summary', '')[:30]}" + if tool_name == "get_character_detail": + return f"角色 #{data.get('id')} {data.get('name', '')}" + if tool_name == "get_chapter_detail": + return f"章节 #{data.get('id')} {data.get('title', '')[:30]}" + if tool_name == "get_novel_info": + return f"小说: {data.get('title', '')}" + + # 文件读取 + if tool_name == "read_file": + return f"读取 {data.get('filename', '')} ({data.get('offset', 0)}-{data.get('offset', 0) + len(data.get('text', ''))}字/{data.get('total_length', 0)}字)" + + # 其他 → 截取前 100 字符 + return result_json[:100] + + +async def _execute_tool_with_queue( + queue: asyncio.Queue, + tc: ToolCall, + config: AIConfig, + novel_id: int, +) -> tuple[str, str]: + """执行单个工具(含子Agent),通过 queue 发事件。返回 (result, log_line)。 + 子 Agent 使用 tc.id 作为 subagent_id,用于前端区分并行的子 Agent。""" + label = TOOL_LABELS.get(tc.name, tc.name) + + if tc.name == "dispatch_subagent": + skill_id = tc.arguments.get("skill_id") + task_desc = tc.arguments.get("task", "") + sub_skill = None + with Session(engine) as sub_s: + sub_skill = sub_s.get(Skill, skill_id) + if not sub_skill: + result = json.dumps({"error": f"Skill {skill_id} 不存在"}, ensure_ascii=False) + else: + await queue.put({"subagent_start": { + "skill_id": skill_id, "skill_name": sub_skill.name, + "task": task_desc, "subagent_id": tc.id, + }}) + result = await _run_subagent( + queue, config, sub_skill, task_desc, novel_id, subagent_id=tc.id, + ) + skill_name = sub_skill.name if sub_skill else "未知" + # 子 Agent 结果也精简(取前 200 字) + brief = result[:200] + "…" if len(result) > 200 else result + log_line = f"[子Agent: {skill_name}] 任务: {task_desc} → {brief}" + else: + result = execute_tool(tc.name, tc.arguments, novel_id) + await queue.put({"tool_result": {"name": tc.name, "label": label, "result": result}}) + # 精简日志:仅记录摘要,避免完整 JSON 结果撑爆上下文 + log_line = f"[工具调用: {label}] 参数: {json.dumps(tc.arguments, ensure_ascii=False)} → {_summarize_result(tc.name, result)}" + + return result, log_line + + +async def _execute_tools_parallel( + queue: asyncio.Queue, + tool_calls: list[ToolCall], + config: AIConfig, + novel_id: int, +) -> tuple[list[dict], list[str]]: + """执行一组工具调用。子 Agent 并行执行,普通工具顺序执行。 + 返回 (tool_results, tool_call_logs)。""" + # 分离子 Agent 和普通工具 + subagent_calls = [tc for tc in tool_calls if tc.name == "dispatch_subagent"] + normal_calls = [tc for tc in tool_calls if tc.name != "dispatch_subagent"] + + results_map: dict[str, tuple[str, str]] = {} # tc.id → (result, log_line) + + # 普通工具顺序执行 + for tc in normal_calls: + result, log_line = await _execute_tool_with_queue(queue, tc, config, novel_id) + results_map[tc.id] = (result, log_line) + + # 子 Agent 并行执行 + if subagent_calls: + async def _run_one(tc: ToolCall) -> tuple[str, tuple[str, str]]: + r, l = await _execute_tool_with_queue(queue, tc, config, novel_id) + return tc.id, (r, l) + + tasks = [_run_one(tc) for tc in subagent_calls] + for coro in asyncio.as_completed(tasks): + tc_id, rl = await coro + results_map[tc_id] = rl + + # 按原始顺序组装结果 + tool_results = [] + tool_call_logs = [] + for tc in tool_calls: + result, log_line = results_map[tc.id] + tool_results.append({"id": tc.id, "result": result}) + tool_call_logs.append(log_line) + + return tool_results, tool_call_logs + + +async def _chat_worker( + queue: asyncio.Queue, + config: AIConfig, + messages: list[dict], + system_prompt: str, + tools: list[dict] | None, + data: ChatRequest, +): + """核心聊天逻辑 —— 运行在独立 Task 中,客户端断开也不会停止。""" + full_response = data.assistant_text or "" + usage_info: dict = {} + tool_call_logs: list[str] = [] + pending_paused = False + + try: + # ── 阶段一:执行待审批的工具调用 ── + if data.pending_tool_calls: + tool_calls = [ + ToolCall(id=tc.id, name=tc.name, arguments=tc.arguments) + for tc in data.pending_tool_calls + ] + assistant_msg = build_assistant_message( + config.provider, data.assistant_text or "", tool_calls + ) + messages.append(assistant_msg) + + tool_results, logs = await _execute_tools_parallel( + queue, tool_calls, config, data.novel_id, + ) + tool_call_logs.extend(logs) + + result_msgs = build_tool_results_messages(config.provider, tool_results) + messages.extend(result_msgs) + + # ── 阶段二:LLM 循环 ── + for _round in range(MAX_TOOL_ROUNDS): + round_text = "" + round_tool_calls: list[ToolCall] = [] + + async for event in stream_chat(config, messages, system_prompt, tools): + if event.text: + round_text += event.text + await queue.put({"content": event.text}) + if event.usage: + for k, v in event.usage.items(): + if v: + usage_info[k] = usage_info.get(k, 0) + v + if event.tool_calls: + round_tool_calls = event.tool_calls + + if not round_tool_calls: + full_response += round_text + break + + full_response += round_text + calls_data = [ + {"id": tc.id, "name": tc.name, + "label": TOOL_LABELS.get(tc.name, tc.name), + "arguments": tc.arguments} + for tc in round_tool_calls + ] + + if data.auto_approve_tools: + await queue.put({"tool_calls_auto": calls_data}) + + assistant_msg = build_assistant_message( + config.provider, full_response, round_tool_calls + ) + messages.append(assistant_msg) + + tool_results, logs = await _execute_tools_parallel( + queue, round_tool_calls, config, data.novel_id, + ) + tool_call_logs.extend(logs) + + result_msgs = build_tool_results_messages(config.provider, tool_results) + messages.extend(result_msgs) + continue + else: + # 审批模式:暂停 + await queue.put({"tool_calls_pending": calls_data, "assistant_text": full_response}) + await queue.put({"done": True, "pending": True, "usage": usage_info or None}) + pending_paused = True + return # 不保存到 DB + + except Exception as e: + await queue.put({"error": str(e)}) + finally: + if not pending_paused: + _save_assistant_message(data.novel_id, full_response, tool_call_logs) + await queue.put({"done": True, "usage": usage_info or None}) + # 哨兵值:通知 SSE 生成器结束 + await queue.put(None) + + @router.post("/stream") async def chat_stream( data: ChatRequest, session: Session = Depends(get_session), ): - """SSE 流式对话端点,支持 tool calling + 用户审批""" + """SSE 流式对话端点,支持 tool calling + 用户审批。 + 核心工作在独立 Task 中运行,客户端断开不会中断。""" config = session.exec(select(AIConfig)).first() if not config or not config.api_key: return StreamingResponse( @@ -157,120 +510,23 @@ async def chat_stream( ) tools = _get_tools(config.provider, allowed_tools) if use_tools else None + # 创建事件队列和后台工作任务 + queue: asyncio.Queue = asyncio.Queue() + asyncio.create_task( + _chat_worker(queue, config, messages, system_prompt, tools, data) + ) + async def generate(): - nonlocal messages - - full_response = data.assistant_text or "" - usage_info: dict = {} - # 记录本次对话中的工具调用摘要(用于保存到 DB) - tool_call_logs: list[str] = [] - + """SSE 生成器:从队列读取事件。客户端断开时后台任务继续运行。""" try: - # ── 阶段一:如果有待审批的工具调用,先执行它们 ── - if data.pending_tool_calls: - tool_calls = [ - ToolCall(id=tc.id, name=tc.name, arguments=tc.arguments) - for tc in data.pending_tool_calls - ] - - # 构建 assistant 消息(含工具调用) - assistant_msg = build_assistant_message( - config.provider, data.assistant_text or "", tool_calls - ) - messages.append(assistant_msg) - - # 执行每个工具,发送结果 - tool_results = [] - for tc in tool_calls: - label = TOOL_LABELS.get(tc.name, tc.name) - result = execute_tool(tc.name, tc.arguments, data.novel_id) - tool_results.append({"id": tc.id, "result": result}) - tool_call_logs.append(f"[工具调用: {label}] 参数: {json.dumps(tc.arguments, ensure_ascii=False)} → 结果: {result}") - yield _sse({"tool_result": { - "name": tc.name, - "label": label, - "result": result, - }}) - - # 追加工具结果消息 - result_msgs = build_tool_results_messages(config.provider, tool_results) - messages.extend(result_msgs) - - # ── 阶段二:LLM 循环 ── - for _round in range(MAX_TOOL_ROUNDS): - round_text = "" - round_tool_calls = [] - - async for event in stream_chat(config, messages, system_prompt, tools): - if event.text: - round_text += event.text - yield _sse({"content": event.text}) - if event.usage: - for k, v in event.usage.items(): - if v: - usage_info[k] = usage_info.get(k, 0) + v - if event.tool_calls: - round_tool_calls = event.tool_calls - - # 没有工具调用 → 完成 - if not round_tool_calls: - full_response += round_text + while True: + event = await queue.get() + if event is None: break - - # 有工具调用 - full_response += round_text - calls_data = [ - { - "id": tc.id, - "name": tc.name, - "label": TOOL_LABELS.get(tc.name, tc.name), - "arguments": tc.arguments, - } - for tc in round_tool_calls - ] - - if data.auto_approve_tools: - # 自动执行模式:直接执行工具,不暂停 - yield _sse({"tool_calls_auto": calls_data}) - - assistant_msg = build_assistant_message( - config.provider, full_response, round_tool_calls - ) - messages.append(assistant_msg) - - tool_results = [] - for tc in round_tool_calls: - label = TOOL_LABELS.get(tc.name, tc.name) - result = execute_tool(tc.name, tc.arguments, data.novel_id) - tool_results.append({"id": tc.id, "result": result}) - tool_call_logs.append(f"[工具调用: {label}] 参数: {json.dumps(tc.arguments, ensure_ascii=False)} → 结果: {result}") - yield _sse({"tool_result": { - "name": tc.name, - "label": label, - "result": result, - }}) - - result_msgs = build_tool_results_messages(config.provider, tool_results) - messages.extend(result_msgs) - # 继续循环,让 LLM 基于工具结果生成回复 - continue - else: - # 审批模式:暂停,发给前端审批 - yield _sse({ - "tool_calls_pending": calls_data, - "assistant_text": full_response, - }) - yield _sse({"done": True, "pending": True, "usage": usage_info or None}) - return # 暂停,不保存到 DB - - except Exception as e: - yield _sse({"error": str(e)}) - finally: - # 无论连接是否断开,都保存已有的回复和工具调用到 DB - # 这确保刷新页面后 loadHistory 能恢复正确状态 - _save_assistant_message(data.novel_id, full_response, tool_call_logs) - - yield _sse({"done": True, "usage": usage_info or None}) + yield _sse(event) + except (asyncio.CancelledError, GeneratorExit): + # 客户端断开连接 — 后台任务继续运行,最终会保存到 DB + pass return StreamingResponse( generate(), @@ -354,18 +610,16 @@ async def compress_context( if len(all_msgs) <= KEEP_RECENT_ROUNDS * 2: return {"compressed": False, "reason": "消息太少,无需压缩"} - # 分割:旧消息(需要压缩)+ 最近消息(保留原文) + # 分割:旧消息(将被删除)+ 最近消息(保留原文) keep_count = KEEP_RECENT_ROUNDS * 2 old_msgs = all_msgs[:-keep_count] recent_msgs = all_msgs[-keep_count:] - # 检查第一条旧消息是否已是摘要(避免重复压缩无效内容) - # 构建需要总结的对话文本 + # 将 **所有消息**(包括最近保留的)都给 LLM 看,确保摘要完整准确 conversation_text = "" - for msg in old_msgs: + for msg in all_msgs: role_label = "用户" if msg.role == "user" else "AI助手" content = msg.content - # 如果是已压缩的摘要,标注出来 if content.startswith("[对话摘要]"): conversation_text += f"[之前的摘要]{content[5:]}\n\n" else: @@ -373,10 +627,11 @@ async def compress_context( # 调用 LLM 生成摘要 summary_prompt = ( - "你是一个对话摘要助手。请将以下对话历史压缩为简洁的摘要,保留关键信息:\n" + "你是一个对话摘要助手。请将以下完整对话历史压缩为简洁的摘要,保留关键信息:\n" "1. 用户提出的核心需求和决策\n" "2. AI 执行的重要操作及结果(如创建/修改了哪些大纲、角色、章节等)\n" - "3. 达成的共识和待办事项\n\n" + "3. 达成的共识和待办事项\n" + "4. 当前工作进展和下一步计划\n\n" "只输出摘要内容,不要加前缀或解释。用简洁的条目列表形式。" ) summary_messages = [ @@ -392,12 +647,14 @@ async def compress_context( for msg in old_msgs: session.delete(msg) - # 插入摘要消息(时间设为最早保留消息之前) + # 插入摘要消息,时间设为保留消息之前(确保排在最前面) + from datetime import timedelta + earliest_kept = recent_msgs[0].created_at summary_msg = ChatMessage( novel_id=novel_id, role="assistant", content=f"[对话摘要]\n{summary}", - created_at=recent_msgs[0].created_at, + created_at=earliest_kept - timedelta(seconds=1), ) session.add(summary_msg) session.commit() diff --git a/backend/app/services/tools.py b/backend/app/services/tools.py index aac2bbb..4e2ec9c 100644 --- a/backend/app/services/tools.py +++ b/backend/app/services/tools.py @@ -34,12 +34,23 @@ TOOL_DEFINITIONS: list[dict] = [ }, { "name": "list_outlines", - "description": "列出当前小说的所有大纲节点,返回每个节点的 id、排序、摘要和详情", + "description": "列出当前小说的所有大纲节点(仅返回 id、排序和摘要标题,不含详情)。如需查看某节点的详情内容,请使用 get_outline_detail 工具。", "parameters": { "type": "object", "properties": {}, }, }, + { + "name": "get_outline_detail", + "description": "查看指定大纲节点的完整详情内容", + "parameters": { + "type": "object", + "properties": { + "outline_id": {"type": "integer", "description": "大纲节点 ID"}, + }, + "required": ["outline_id"], + }, + }, { "name": "create_outline", "description": "为当前小说创建一个新的大纲节点", @@ -118,12 +129,23 @@ TOOL_DEFINITIONS: list[dict] = [ # ── 角色工具 ── { "name": "list_characters", - "description": "列出当前小说的所有角色,返回每个角色的 id、名称和描述", + "description": "列出当前小说的所有角色(仅返回 id 和名称,不含描述详情)。如需查看某角色的完整描述,请使用 get_character_detail 工具。", "parameters": { "type": "object", "properties": {}, }, }, + { + "name": "get_character_detail", + "description": "查看指定角色的完整描述信息", + "parameters": { + "type": "object", + "properties": { + "character_id": {"type": "integer", "description": "角色 ID"}, + }, + "required": ["character_id"], + }, + }, { "name": "create_character", "description": "为当前小说创建一个新角色", @@ -163,12 +185,23 @@ TOOL_DEFINITIONS: list[dict] = [ # ── 章节工具 ── { "name": "list_chapters", - "description": "列出当前小说的所有章节,按排序顺序返回每个章节的 id、标题和内容摘要", + "description": "列出当前小说的所有章节(仅返回 id、排序和标题,不含正文)。如需查看某章节的完整正文,请使用 get_chapter_detail 工具。", "parameters": { "type": "object", "properties": {}, }, }, + { + "name": "get_chapter_detail", + "description": "查看指定章节的完整正文内容", + "parameters": { + "type": "object", + "properties": { + "chapter_id": {"type": "integer", "description": "章节 ID"}, + }, + "required": ["chapter_id"], + }, + }, { "name": "create_chapter", "description": "为当前小说创建一个新章节", @@ -227,8 +260,23 @@ TOOL_DEFINITIONS: list[dict] = [ "required": ["file_id"], }, }, + { + "name": "dispatch_subagent", + "description": "派遣一个子 Agent 执行特定任务。子 Agent 拥有独立的系统提示词和工具集(由 Skill 定义),适合将复杂任务拆分为子任务分别处理。", + "parameters": { + "type": "object", + "properties": { + "skill_id": {"type": "integer", "description": "要使用的 Skill ID(从可用 Skill 列表中选择)"}, + "task": {"type": "string", "description": "交给子 Agent 的具体任务描述"}, + }, + "required": ["skill_id", "task"], + }, + }, ] +# dispatch_subagent 不由 execute_tool 处理,它在 chat.py 中特殊处理 +SPECIAL_TOOLS = {"dispatch_subagent"} + def _filter_defs(allowed: list[str] | None) -> list[dict]: """根据白名单过滤工具定义""" @@ -275,6 +323,7 @@ def execute_tool(name: str, arguments: dict, novel_id: int) -> str: "get_novel_info": _get_novel_info, "update_novel_info": _update_novel_info, "list_outlines": _list_outlines, + "get_outline_detail": _get_outline_detail, "create_outline": _create_outline, "update_outline": _update_outline, "delete_outline": _delete_outline, @@ -282,10 +331,12 @@ def execute_tool(name: str, arguments: dict, novel_id: int) -> str: "get_world_setting": _get_world_setting, "save_world_setting": _save_world_setting, "list_characters": _list_characters, + "get_character_detail": _get_character_detail, "create_character": _create_character, "update_character": _update_character, "delete_character": _delete_character, "list_chapters": _list_chapters, + "get_chapter_detail": _get_chapter_detail, "create_chapter": _create_chapter, "update_chapter": _update_chapter, "delete_chapter": _delete_chapter, @@ -339,13 +390,12 @@ def _update_novel_info(session: Session, novel_id: int, args: dict) -> str: def _list_outlines(session: Session, novel_id: int, _args: dict) -> str: - # 查询顶级节点 + """列表仅返回 id + 排序 + 摘要标题(不含 detail),节省上下文。""" top_items = session.exec( select(Outline) .where(Outline.novel_id == novel_id, Outline.parent_id.is_(None)) # type: ignore .order_by(Outline.sort_order) ).all() - # 查询所有子节点,按 parent_id 分组 all_children = session.exec( select(Outline) .where(Outline.novel_id == novel_id, Outline.parent_id.isnot(None)) # type: ignore @@ -357,17 +407,28 @@ def _list_outlines(session: Session, novel_id: int, _args: dict) -> str: result = [] for o in top_items: - node = {"id": o.id, "sort_order": o.sort_order, "summary": o.summary, "detail": o.detail} + node: dict = {"id": o.id, "sort_order": o.sort_order, "summary": o.summary} kids = children_map.get(o.id, []) if kids: node["children"] = [ - {"id": c.id, "sort_order": c.sort_order, "summary": c.summary, "detail": c.detail, "parent_id": c.parent_id} + {"id": c.id, "sort_order": c.sort_order, "summary": c.summary, "parent_id": c.parent_id} for c in kids ] result.append(node) return json.dumps({"outlines": result, "total": len(result)}, ensure_ascii=False) +def _get_outline_detail(session: Session, novel_id: int, args: dict) -> str: + outline = session.get(Outline, args["outline_id"]) + if not outline or outline.novel_id != novel_id: + return json.dumps({"error": "大纲不存在"}, ensure_ascii=False) + return json.dumps({ + "id": outline.id, "sort_order": outline.sort_order, + "summary": outline.summary, "detail": outline.detail, + "parent_id": outline.parent_id, + }, ensure_ascii=False) + + def _create_outline(session: Session, novel_id: int, args: dict) -> str: parent_id = args.get("parent_id") @@ -498,18 +559,25 @@ def _save_world_setting(session: Session, novel_id: int, args: dict) -> str: def _list_characters(session: Session, novel_id: int, _args: dict) -> str: + """列表仅返回 id + 名称(不含 description),节省上下文。""" items = session.exec( select(Character) .where(Character.novel_id == novel_id) .order_by(Character.created_at) ).all() - result = [ - {"id": c.id, "name": c.name, "description": c.description} - for c in items - ] + result = [{"id": c.id, "name": c.name} for c in items] return json.dumps({"characters": result, "total": len(result)}, ensure_ascii=False) +def _get_character_detail(session: Session, novel_id: int, args: dict) -> str: + char = session.get(Character, args["character_id"]) + if not char or char.novel_id != novel_id: + return json.dumps({"error": "角色不存在"}, ensure_ascii=False) + return json.dumps({ + "id": char.id, "name": char.name, "description": char.description, + }, ensure_ascii=False) + + def _create_character(session: Session, novel_id: int, args: dict) -> str: name = args["name"].strip() if not name or len(name) > 100: @@ -564,23 +632,26 @@ def _delete_character(session: Session, novel_id: int, args: dict) -> str: def _list_chapters(session: Session, novel_id: int, _args: dict) -> str: + """列表仅返回 id + 排序 + 标题(不含正文),节省上下文。""" items = session.exec( select(Chapter) .where(Chapter.novel_id == novel_id) .order_by(Chapter.sort_order) ).all() - result = [ - { - "id": c.id, - "sort_order": c.sort_order, - "title": c.title, - "content_preview": c.content[:100] + "…" if len(c.content) > 100 else c.content, - } - for c in items - ] + result = [{"id": c.id, "sort_order": c.sort_order, "title": c.title} for c in items] return json.dumps({"chapters": result, "total": len(result)}, ensure_ascii=False) +def _get_chapter_detail(session: Session, novel_id: int, args: dict) -> str: + chapter = session.get(Chapter, args["chapter_id"]) + if not chapter or chapter.novel_id != novel_id: + return json.dumps({"error": "章节不存在"}, ensure_ascii=False) + return json.dumps({ + "id": chapter.id, "sort_order": chapter.sort_order, + "title": chapter.title, "content": chapter.content, + }, ensure_ascii=False) + + def _create_chapter(session: Session, novel_id: int, args: dict) -> str: title = args["title"].strip() if not title or len(title) > 200: diff --git a/frontend/src/api/chat.ts b/frontend/src/api/chat.ts index 659a3cb..f1f13d9 100644 --- a/frontend/src/api/chat.ts +++ b/frontend/src/api/chat.ts @@ -70,6 +70,25 @@ export interface PendingToolCallData { arguments: Record } +export interface SubAgentStartInfo { + skill_id: number + skill_name: string + task: string + subagent_id: string +} + +export interface SubAgentToolCallInfo { + name: string + label: string + arguments: Record +} + +export interface SubAgentToolResultInfo { + name: string + label: string + result: string +} + export interface StreamChatOptions { messages: { role: string; content: string }[] novelId?: number @@ -87,6 +106,12 @@ export interface StreamChatOptions { onToolCallsPending?: (calls: PendingToolCallData[], assistantText: string) => void onToolCallsAuto?: (calls: PendingToolCallData[]) => void onToolResult?: (info: ToolResultInfo) => void + // 子 Agent 回调(subagentId 用于区分并行的多个子 Agent) + onSubAgentStart?: (info: SubAgentStartInfo) => void + onSubAgentChunk?: (text: string, subagentId: string) => void + onSubAgentToolCall?: (info: SubAgentToolCallInfo, subagentId: string) => void + onSubAgentToolResult?: (info: SubAgentToolResultInfo, subagentId: string) => void + onSubAgentDone?: (result: string, subagentId: string) => void signal?: AbortSignal } @@ -95,6 +120,7 @@ export async function streamChat(options: StreamChatOptions): Promise { messages, novelId, pageContext, toolsEnabled, autoApproveTools, skillId, pendingToolCalls, assistantText, onChunk, onError, onDone, onToolCallsPending, onToolCallsAuto, onToolResult, + onSubAgentStart, onSubAgentChunk, onSubAgentToolCall, onSubAgentToolResult, onSubAgentDone, signal, } = options @@ -160,6 +186,22 @@ export async function streamChat(options: StreamChatOptions): Promise { if (event.tool_result && onToolResult) { onToolResult(event.tool_result) } + // 子 Agent 事件(所有事件携带 subagent_id 用于区分并行的子 Agent) + if (event.subagent_start && onSubAgentStart) { + onSubAgentStart(event.subagent_start) + } + if (event.subagent_chunk && onSubAgentChunk) { + onSubAgentChunk(event.subagent_chunk, event.subagent_id ?? '') + } + if (event.subagent_tool_call && onSubAgentToolCall) { + onSubAgentToolCall(event.subagent_tool_call, event.subagent_id ?? '') + } + if (event.subagent_tool_result && onSubAgentToolResult) { + onSubAgentToolResult(event.subagent_tool_result, event.subagent_id ?? '') + } + if (event.subagent_done !== undefined && onSubAgentDone) { + onSubAgentDone(event.subagent_done, event.subagent_id ?? '') + } } catch { // 忽略解析错误 } diff --git a/frontend/src/components/chat/ChatPanel.vue b/frontend/src/components/chat/ChatPanel.vue index 2aaa41a..5c503ac 100644 --- a/frontend/src/components/chat/ChatPanel.vue +++ b/frontend/src/components/chat/ChatPanel.vue @@ -1,17 +1,20 @@