import asyncio import json from datetime import datetime, timezone from fastapi import APIRouter, Depends, Query from fastapi.responses import StreamingResponse from sqlmodel import Session, select, func from ..database import get_session, engine from ..models import AIConfig, ChatMessage, Novel, Skill from ..schemas import ChatRequest, ChatMessageRead, ChatMessageList 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, SPECIAL_TOOLS router = APIRouter(prefix="/api/chat", tags=["chat"]) # 最大工具调用轮次 MAX_TOOL_ROUNDS = 8 # 工具名称到中文描述 TOOL_LABELS: dict[str, str] = { "get_novel_info": "查看小说信息", "update_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": "查看角色详情", "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, session: Session, page_context: str | None = None, tools_enabled: bool = False, skill: Skill | None = None, allowed_tools: list[str] | None = None, ) -> str: """根据小说上下文、Skill 和工具配置构建 system prompt""" parts = [ "你是 Writing Red Dot 写作助手,专注于小说创作领域。", "你擅长:构思情节、塑造角色、打磨文笔、设计世界观、规划大纲结构。", ] # 注入 Skill 提示词 if skill and skill.system_prompt: parts.append(f"\n## 当前技能:{skill.name}\n{skill.system_prompt}") if novel_id: novel = session.get(Novel, novel_id) if novel: parts.append(f"\n当前小说:《{novel.title}》(ID: {novel.id})") parts.append(f"小说简介:{novel.description or '暂无'}") if page_context: parts.append(f"\n用户当前正在查看:{page_context}。请结合当前页面场景提供针对性建议。") if tools_enabled: # 动态生成工具能力描述(根据 allowed_tools 过滤) tools_desc = get_tools_description(allowed_tools) if tools_desc: parts.append(f"\n## 你的工具能力\n{tools_desc}") 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) def _get_tools(provider: str, allowed: list[str] | None = None) -> list[dict]: if provider == "anthropic": return get_anthropic_tools(allowed) return get_openai_tools(allowed) def _sse(data: dict) -> str: return f"data: {json.dumps(data, ensure_ascii=False)}\n\n" def _save_assistant_message(novel_id: int | None, full_response: str, tool_call_logs: list[str]): """保存 AI 回复到 DB(含工具调用摘要)。在 finally 中调用,确保断连也能保存。""" save_content = full_response if tool_call_logs: tool_summary = "\n".join(tool_call_logs) save_content = f"[以下操作已执行]\n{tool_summary}\n\n{full_response}" if save_content.strip(): with Session(engine) as s: ai_msg = ChatMessage( novel_id=novel_id, role="assistant", content=save_content, ) s.add(ai_msg) 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 + 用户审批。 核心工作在独立 Task 中运行,客户端断开不会中断。""" config = session.exec(select(AIConfig)).first() if not config or not config.api_key: return StreamingResponse( _error_stream("请先配置AI服务"), media_type="text/event-stream", ) # 保存用户消息(仅首次请求,续传审批时不保存) if not data.pending_tool_calls: user_msg = data.messages[-1] if data.messages else None if user_msg and user_msg.role == "user": db_msg = ChatMessage( novel_id=data.novel_id, role="user", content=user_msg.content, ) session.add(db_msg) session.commit() # 构建消息列表 messages = [{"role": m.role, "content": m.content} for m in data.messages] # 读取 Skill 配置 skill: Skill | None = None allowed_tools: list[str] | None = None if data.skill_id: skill = session.get(Skill, data.skill_id) if skill and skill.allowed_tools: try: parsed = json.loads(skill.allowed_tools) if isinstance(parsed, list) and len(parsed) > 0: allowed_tools = parsed except (json.JSONDecodeError, TypeError): pass # 构建上下文 use_tools = bool(data.tools_enabled) system_prompt = _build_system_prompt( data.novel_id, session, data.page_context, use_tools, skill=skill, allowed_tools=allowed_tools, ) 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(): """SSE 生成器:从队列读取事件。客户端断开时后台任务继续运行。""" try: while True: event = await queue.get() if event is None: break yield _sse(event) except (asyncio.CancelledError, GeneratorExit): # 客户端断开连接 — 后台任务继续运行,最终会保存到 DB pass return StreamingResponse( generate(), media_type="text/event-stream", headers={ "Cache-Control": "no-cache", "X-Accel-Buffering": "no", }, ) async def _error_stream(message: str): yield _sse({"error": message}) yield _sse({"done": True}) @router.get("/messages", response_model=ChatMessageList) def list_messages( novel_id: int | None = Query(default=None), limit: int = Query(default=50, ge=1, le=200), session: Session = Depends(get_session), ): query = select(ChatMessage) count_query = select(func.count(ChatMessage.id)) if novel_id is not None: query = query.where(ChatMessage.novel_id == novel_id) count_query = count_query.where(ChatMessage.novel_id == novel_id) else: query = query.where(ChatMessage.novel_id.is_(None)) # type: ignore count_query = count_query.where(ChatMessage.novel_id.is_(None)) # type: ignore total = session.exec(count_query).one() items = session.exec( query.order_by(ChatMessage.created_at.desc()).limit(limit) ).all() items = list(reversed(items)) return ChatMessageList( items=[ChatMessageRead.model_validate(m, from_attributes=True) for m in items], total=total, ) @router.delete("/messages", status_code=204) def clear_messages( novel_id: int | None = Query(default=None), session: Session = Depends(get_session), ): query = select(ChatMessage) if novel_id is not None: query = query.where(ChatMessage.novel_id == novel_id) else: query = query.where(ChatMessage.novel_id.is_(None)) # type: ignore messages = session.exec(query).all() for msg in messages: session.delete(msg) session.commit() # 保留最近对话轮次数(每轮 = 1 user + 1 assistant) KEEP_RECENT_ROUNDS = 2 @router.post("/compress") async def compress_context( novel_id: int | None = Query(default=None), session: Session = Depends(get_session), ): """压缩对话上下文:用 LLM 总结旧消息,仅保留最近几轮对话。""" config = session.exec(select(AIConfig)).first() if not config or not config.api_key: return {"error": "请先配置AI服务"} # 查询所有消息(按时间正序) query = select(ChatMessage) if novel_id is not None: query = query.where(ChatMessage.novel_id == novel_id) else: query = query.where(ChatMessage.novel_id.is_(None)) # type: ignore all_msgs = list(session.exec(query.order_by(ChatMessage.created_at)).all()) 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 all_msgs: role_label = "用户" if msg.role == "user" else "AI助手" content = msg.content if content.startswith("[对话摘要]"): conversation_text += f"[之前的摘要]{content[5:]}\n\n" else: conversation_text += f"{role_label}: {content}\n\n" # 调用 LLM 生成摘要 summary_prompt = ( "你是一个对话摘要助手。请将以下完整对话历史压缩为简洁的摘要,保留关键信息:\n" "1. 用户提出的核心需求和决策\n" "2. AI 执行的重要操作及结果(如创建/修改了哪些大纲、角色、章节等)\n" "3. 达成的共识和待办事项\n" "4. 当前工作进展和下一步计划\n\n" "只输出摘要内容,不要加前缀或解释。用简洁的条目列表形式。" ) summary_messages = [ {"role": "user", "content": f"请压缩以下对话历史:\n\n{conversation_text}"} ] try: summary = await simple_completion(config, summary_messages, summary_prompt) except Exception as e: return {"error": f"摘要生成失败: {str(e)}"} # 删除旧消息 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=earliest_kept - timedelta(seconds=1), ) session.add(summary_msg) session.commit() return { "compressed": True, "removed_count": len(old_msgs), "kept_count": len(recent_msgs), "summary_length": len(summary), }