- **子 Agent 支持**: - 新增 `dispatch_subagent` 工具,支持分配特定任务给子 Agent,由其独立操作。 - 前端新增 `ChatSubAgent` 组件,支持子 Agent 任务状态展示(运行中/已完成)、工具调用进度及输出预览等。 - **技能管理扩展**: - 新增 Skill 编辑器页面(`SkillEditorView.vue`),支持创建、编辑、删除技能。 - 完善技能工具权限分组选择功能,按类型灵活分配工具。 - **工具增强**: - 新增工具接口:`get_outline_detail`、`get_character_detail`、`get_chapter_detail`,用于获取详情内容。 - 优化部分工具返回数据,仅输出基础信息以减少上下文体积。 - 后端添加 `SPECIAL_TOOLS` 支持,增强 `dispatch_subagent` 的特殊处理能力。 同时优化部分前后端交互,提升代码维护性与可读性。
This commit is contained in:
@@ -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,
|
||||
),
|
||||
]
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user