新增子 Agent 与技能系统管理:
All checks were successful
Deploy Noval to K3s / deploy (push) Successful in 54s

- **子 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:
2026-03-23 02:23:22 +08:00
parent b39377e487
commit 0525c8217e
12 changed files with 2028 additions and 209 deletions

View File

@@ -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,
),
]

View File

@@ -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## 可用子AgentSkill"]
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()

View File

@@ -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:

View File

@@ -70,6 +70,25 @@ export interface PendingToolCallData {
arguments: Record<string, unknown>
}
export interface SubAgentStartInfo {
skill_id: number
skill_name: string
task: string
subagent_id: string
}
export interface SubAgentToolCallInfo {
name: string
label: string
arguments: Record<string, unknown>
}
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<void> {
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<void> {
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 {
// 忽略解析错误
}

View File

@@ -1,17 +1,20 @@
<script setup lang="ts">
import { ref, computed, nextTick, watch, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { marked } from 'marked'
import { useChatAgent } from '@/composables/useChatAgent'
import { useToast } from '@/composables/useToast'
import { uploadFile } from '@/api/files'
import { isToolCallGroup } from '@/types/chat'
import { listSkills } from '@/api/skills'
import type { Skill } from '@/api/skills'
import { isToolCallGroup, isSubAgentEntry } from '@/types/chat'
import type { ToolCallGroup } from '@/types/chat'
import ChatMessageComp from './ChatMessage.vue'
import ChatToolCalls from './ChatToolCalls.vue'
import ChatSubAgent from './ChatSubAgent.vue'
import ChatConfigModal from './ChatConfigModal.vue'
import ChatFiles from './ChatFiles.vue'
import SkillSelector from './SkillSelector.vue'
import SkillManageModal from './SkillManageModal.vue'
const props = defineProps<{
open: boolean
@@ -28,16 +31,123 @@ const {
stopStreaming, clearChat, compressChat,
} = useChatAgent()
const router = useRouter()
const input = ref('')
const toast = useToast()
const messagesRef = ref<HTMLElement>()
const showConfig = ref(false)
const showFiles = ref(false)
const showSkillManage = ref(false)
const skillSelectorRef = ref<InstanceType<typeof SkillSelector>>()
const fileInputRef = ref<HTMLInputElement>()
const isUploading = ref(false)
// ── 斜杠命令 ──
const showSlashMenu = ref(false)
const slashFilter = ref('')
const slashSelectedIndex = ref(0)
const cachedSkills = ref<Skill[]>([])
interface SlashCommand {
id: string
label: string
desc: string
icon: string
type: 'action' | 'skill'
skillId?: number
}
// 内置命令
const BUILTIN_COMMANDS: SlashCommand[] = [
{ id: 'clear', label: '清空对话', desc: '清除当前对话历史', icon: 'M2 4h12M5 4V2.5h6V4M3.5 4v9.5h9V4', type: 'action' },
{ id: 'compress', label: '压缩上下文', desc: '总结旧消息释放空间', icon: 'M4 2v4l4-2-4-2zM12 14v-4l-4 2 4 2zM2 8h12', type: 'action' },
{ id: 'skills', label: '管理技能', desc: '打开技能管理页面', icon: 'M8 1l2.5 5H14l-4 3.5 1.5 5.5L8 12l-3.5 3 1.5-5.5L2 6h3.5z', type: 'action' },
{ id: 'files', label: '文件工作区', desc: '管理上传的文件', icon: 'M13.5 7.5l-5.8 5.8a3.2 3.2 0 01-4.5-4.5l5.8-5.8a2.1 2.1 0 013 3L6.2 11.8', type: 'action' },
{ id: 'config', label: 'AI 配置', desc: '修改 AI 服务设置', icon: 'M8 8m-2.5 0a2.5 2.5 0 105 0 2.5 2.5 0 10-5 0M8 1v2M8 13v2M1 8h2M13 8h2', type: 'action' },
]
const slashCommands = computed<SlashCommand[]>(() => {
const skillCmds: SlashCommand[] = cachedSkills.value.map(s => ({
id: `skill-${s.id}`,
label: s.name,
desc: s.description || '切换到此技能',
icon: 'M8 1l2.5 5H14l-4 3.5 1.5 5.5L8 12l-3.5 3 1.5-5.5L2 6h3.5z',
type: 'skill' as const,
skillId: s.id,
}))
const all = [...BUILTIN_COMMANDS, ...skillCmds]
if (!slashFilter.value) return all
const q = slashFilter.value.toLowerCase()
return all.filter(c => c.label.toLowerCase().includes(q) || c.id.toLowerCase().includes(q))
})
function onInputChange() {
const val = input.value
if (val.startsWith('/')) {
slashFilter.value = val.slice(1)
showSlashMenu.value = true
slashSelectedIndex.value = 0
} else {
showSlashMenu.value = false
}
}
function executeCommand(cmd: SlashCommand) {
showSlashMenu.value = false
input.value = ''
if (cmd.type === 'skill') {
activeSkillId.value = cmd.skillId ?? null
skillSelectorRef.value?.loadSkills()
toast.success(`已切换技能:${cmd.label}`)
return
}
switch (cmd.id) {
case 'clear':
clearChat()
toast.success('对话已清空')
break
case 'compress':
handleCompress()
break
case 'skills':
router.push('/skills')
break
case 'files':
if (currentNovelId.value) showFiles.value = true
else toast.info('请先进入一部小说')
break
case 'config':
showConfig.value = true
break
}
}
function handleSlashKeydown(e: KeyboardEvent) {
if (!showSlashMenu.value) return
const cmds = slashCommands.value
if (e.key === 'ArrowDown') {
e.preventDefault()
slashSelectedIndex.value = Math.min(slashSelectedIndex.value + 1, cmds.length - 1)
} else if (e.key === 'ArrowUp') {
e.preventDefault()
slashSelectedIndex.value = Math.max(slashSelectedIndex.value - 1, 0)
} else if (e.key === 'Enter' && cmds.length > 0) {
e.preventDefault()
executeCommand(cmds[slashSelectedIndex.value])
} else if (e.key === 'Escape') {
showSlashMenu.value = false
}
}
// 加载技能列表用于斜杠命令
async function loadCachedSkills() {
try {
const result = await listSkills(currentNovelId.value)
cachedSkills.value = result.items
} catch { /* 静默 */ }
}
// 允许的文件扩展名
const ALLOWED_EXTS = ['.txt', '.md', '.docx', '.pdf']
const MAX_SIZE = 10 * 1024 * 1024 // 10MB
@@ -102,6 +212,11 @@ async function handleSend() {
}
function handleKeydown(e: KeyboardEvent) {
// 斜杠命令键盘导航
if (showSlashMenu.value) {
handleSlashKeydown(e)
return
}
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSend()
@@ -164,7 +279,11 @@ watch(() => props.open, (isOpen) => {
onMounted(() => {
loadHistory()
loadCachedSkills()
})
// 小说切换时刷新技能缓存
watch(currentNovelId, () => loadCachedSkills())
</script>
<template>
@@ -234,7 +353,7 @@ onMounted(() => {
ref="skillSelectorRef"
v-model="activeSkillId"
:novel-id="currentNovelId"
@manage="showSkillManage = true"
@manage="router.push('/skills')"
/>
<button class="icon-btn" title="AI配置" @click="showConfig = true">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
@@ -277,6 +396,11 @@ onMounted(() => {
@approve="handleApprove(entry as ToolCallGroup)"
@skip="handleSkip(entry as ToolCallGroup)"
/>
<!-- Agent -->
<ChatSubAgent
v-else-if="isSubAgentEntry(entry)"
:entry="entry"
/>
<!-- 对话摘要压缩后的历史总结 -->
<div
v-else-if="entry.role === 'assistant' && entry.content.startsWith('[对话摘要]')"
@@ -310,6 +434,14 @@ onMounted(() => {
<div v-if="isStreaming && !streamingContent" class="typing-indicator">
<span /><span /><span />
</div>
<!-- 压缩中提示 -->
<div v-if="isCompressing" class="compress-indicator">
<svg class="compress-spinner" width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M4 2v4l4-2-4-2zM12 14v-4l-4 2 4 2zM2 8h12" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
<span>正在压缩上下文总结旧消息中...</span>
</div>
</div>
<!-- 输入区 -->
@@ -334,14 +466,43 @@ onMounted(() => {
style="display: none"
@change="handleFileSelect"
/>
<textarea
v-model="input"
class="input-field"
placeholder="输入消息..."
rows="1"
:disabled="isStreaming || hasPendingTools"
@keydown="handleKeydown"
/>
<div class="input-wrapper">
<!-- 斜杠命令菜单 -->
<Transition name="slash">
<div v-if="showSlashMenu && slashCommands.length > 0" class="slash-menu">
<div class="slash-header">命令</div>
<div
v-for="(cmd, idx) in slashCommands"
:key="cmd.id"
class="slash-item"
:class="{
'slash-item--active': idx === slashSelectedIndex,
'slash-item--skill': cmd.type === 'skill',
}"
@mouseenter="slashSelectedIndex = idx"
@click="executeCommand(cmd)"
>
<svg class="slash-icon" width="14" height="14" viewBox="0 0 16 16" fill="none">
<path :d="cmd.icon" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
<div class="slash-info">
<span class="slash-label">{{ cmd.type === 'skill' ? '' : '/' }}{{ cmd.label }}</span>
<span class="slash-desc">{{ cmd.desc }}</span>
</div>
<span v-if="cmd.type === 'skill'" class="slash-badge">技能</span>
</div>
</div>
</Transition>
<textarea
v-model="input"
class="input-field"
placeholder="输入消息... 输入 / 查看命令"
rows="1"
:disabled="isStreaming || hasPendingTools"
@keydown="handleKeydown"
@input="onInputChange"
/>
</div>
<button
v-if="!isStreaming"
class="send-btn"
@@ -365,12 +526,6 @@ onMounted(() => {
<ChatConfigModal :show="showConfig" @close="showConfig = false" />
<ChatFiles v-if="currentNovelId" :show="showFiles" :novel-id="currentNovelId" @close="showFiles = false" />
<SkillManageModal
:show="showSkillManage"
:novel-id="currentNovelId"
@close="showSkillManage = false"
@updated="skillSelectorRef?.loadSkills()"
/>
</template>
<style scoped>
@@ -584,6 +739,124 @@ onMounted(() => {
30% { transform: translateY(-6px); }
}
.compress-indicator {
display: flex;
align-items: center;
gap: var(--space-sm);
padding: var(--space-sm) var(--space-md);
background: rgba(91, 140, 90, 0.08);
border: 1px solid rgba(91, 140, 90, 0.2);
border-radius: var(--radius-md);
color: var(--bamboo);
font-size: 0.78rem;
animation: fadeInUp var(--duration-fast) var(--ease-out-smooth) both;
}
.compress-spinner {
animation: compressSpin 1.5s linear infinite;
flex-shrink: 0;
}
@keyframes compressSpin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
/* 输入区包装 */
.input-wrapper {
flex: 1;
position: relative;
min-width: 0;
}
/* 斜杠命令菜单 */
.slash-menu {
position: absolute;
bottom: calc(100% + 8px);
left: 0;
right: 0;
background: var(--paper-50);
border: 1px solid var(--paper-200);
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
max-height: 280px;
overflow-y: auto;
z-index: 1100;
}
.slash-header {
padding: 6px 12px;
font-size: 0.68rem;
font-weight: 600;
color: var(--ink-400);
text-transform: uppercase;
letter-spacing: 0.5px;
border-bottom: 1px solid var(--paper-200);
}
.slash-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
cursor: pointer;
transition: background var(--duration-fast) ease;
}
.slash-item:hover,
.slash-item--active {
background: var(--paper-100);
}
.slash-item--skill .slash-icon {
color: var(--bamboo);
}
.slash-icon {
flex-shrink: 0;
color: var(--ink-400);
}
.slash-info {
flex: 1;
min-width: 0;
}
.slash-label {
font-size: 0.82rem;
font-weight: 600;
color: var(--ink-800);
display: block;
}
.slash-desc {
font-size: 0.7rem;
color: var(--ink-400);
display: block;
margin-top: 1px;
}
.slash-badge {
font-size: 0.6rem;
padding: 1px 5px;
border-radius: 8px;
background: rgba(91, 140, 90, 0.12);
color: var(--bamboo);
font-weight: 500;
flex-shrink: 0;
}
.slash-enter-active,
.slash-leave-active {
transition: all var(--duration-fast) ease;
}
.slash-enter-from,
.slash-leave-to {
opacity: 0;
transform: translateY(4px);
}
/* 附件上传按钮 */
.attach-btn {
flex-shrink: 0;
@@ -606,7 +879,7 @@ onMounted(() => {
}
.input-field {
flex: 1;
width: 100%;
padding: var(--space-sm) var(--space-md);
font-size: 0.9rem;
font-family: var(--font-body);

View File

@@ -0,0 +1,279 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import type { SubAgentEntry } from '@/types/chat'
const props = defineProps<{
entry: SubAgentEntry
}>()
const isRunning = computed(() => props.entry.status === 'running')
const isDone = computed(() => props.entry.status === 'done')
const expanded = ref(true)
// 截取输出文本预览
const outputPreview = computed(() => {
const text = props.entry.chunks
if (!text) return ''
if (text.length <= 200) return text
return text.slice(0, 200) + '...'
})
const hasToolCalls = computed(() => props.entry.toolCalls.length > 0)
const completedTools = computed(() =>
props.entry.toolCalls.filter(c => c.result).length
)
</script>
<template>
<div class="subagent-card" :class="{ 'subagent-card--running': isRunning }">
<!-- 头部 -->
<div class="subagent-header" @click="expanded = !expanded">
<div class="subagent-icon">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<circle cx="8" cy="5" r="3" stroke="currentColor" stroke-width="1.3" />
<path d="M2 14c0-3.3 2.7-6 6-6s6 2.7 6 6" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" />
<path d="M12 3l2 2-2 2" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</div>
<div class="subagent-info">
<span class="subagent-name">{{ entry.skillName }}</span>
<span v-if="isRunning" class="subagent-badge badge--running">运行中</span>
<span v-else-if="isDone" class="subagent-badge badge--done">已完成</span>
</div>
<button class="subagent-toggle" :title="expanded ? '收起' : '展开'">
<svg width="12" height="12" viewBox="0 0 12 12" fill="none">
<path
:d="expanded ? 'M3 7.5l3-3 3 3' : 'M3 4.5l3 3 3-3'"
stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"
/>
</svg>
</button>
</div>
<!-- 任务描述 -->
<div class="subagent-task">{{ entry.task }}</div>
<!-- 展开内容 -->
<div v-if="expanded" class="subagent-body">
<!-- 工具调用列表 -->
<div v-if="hasToolCalls" class="subagent-tools">
<div class="subagent-tools-header">
<span class="tools-label">工具调用</span>
<span class="tools-count">{{ completedTools }}/{{ entry.toolCalls.length }}</span>
</div>
<div
v-for="call in entry.toolCalls"
:key="call.id"
class="subagent-tool-item"
>
<span class="tool-dot" :class="{ 'tool-dot--done': call.result }" />
<span class="tool-name">{{ call.label }}</span>
<span v-if="call.result" class="tool-check"></span>
</div>
</div>
<!-- Agent 输出 -->
<div v-if="entry.chunks" class="subagent-output">
<div class="output-label">输出</div>
<div class="output-text">{{ isDone ? entry.chunks : outputPreview }}</div>
</div>
<!-- 运行中指示 -->
<div v-if="isRunning" class="subagent-running">
<span class="dot" /><span class="dot" /><span class="dot" />
</div>
</div>
</div>
</template>
<style scoped>
.subagent-card {
background: linear-gradient(135deg, var(--paper-50) 0%, rgba(91, 140, 90, 0.04) 100%);
border: 1px solid rgba(91, 140, 90, 0.2);
border-radius: var(--radius-md);
padding: var(--space-sm) var(--space-md);
margin-bottom: var(--space-md);
animation: fadeInUp var(--duration-fast) var(--ease-out-smooth) both;
}
.subagent-card--running {
border-color: rgba(91, 140, 90, 0.35);
box-shadow: 0 0 0 1px rgba(91, 140, 90, 0.08);
}
.subagent-header {
display: flex;
align-items: center;
gap: var(--space-sm);
cursor: pointer;
user-select: none;
}
.subagent-icon {
color: var(--bamboo);
flex-shrink: 0;
}
.subagent-info {
display: flex;
align-items: center;
gap: var(--space-xs);
flex: 1;
min-width: 0;
}
.subagent-name {
font-size: 0.8rem;
font-weight: 600;
color: var(--bamboo);
}
.subagent-badge {
font-size: 0.65rem;
padding: 1px 6px;
border-radius: 8px;
font-weight: 500;
}
.badge--running {
background: rgba(91, 140, 90, 0.12);
color: var(--bamboo);
animation: toolPulse 1.5s ease-in-out infinite;
}
.badge--done {
background: rgba(91, 140, 90, 0.12);
color: var(--bamboo);
}
.subagent-toggle {
background: none;
border: none;
color: var(--ink-300);
cursor: pointer;
padding: 2px;
flex-shrink: 0;
}
.subagent-task {
font-size: 0.75rem;
color: var(--ink-400);
margin: var(--space-xs) 0;
padding-left: calc(16px + var(--space-sm));
line-height: 1.5;
}
.subagent-body {
padding-left: calc(16px + var(--space-sm));
margin-top: var(--space-xs);
}
.subagent-tools {
margin-bottom: var(--space-sm);
}
.subagent-tools-header {
display: flex;
align-items: center;
gap: var(--space-xs);
margin-bottom: var(--space-xs);
}
.tools-label {
font-size: 0.68rem;
color: var(--ink-300);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.tools-count {
font-size: 0.65rem;
color: var(--ink-300);
background: var(--paper-100);
padding: 0 4px;
border-radius: 4px;
}
.subagent-tool-item {
display: flex;
align-items: center;
gap: var(--space-xs);
padding: 2px 0;
}
.tool-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--paper-300);
flex-shrink: 0;
transition: background var(--duration-fast);
}
.tool-dot--done {
background: var(--bamboo);
}
.tool-name {
font-size: 0.72rem;
color: var(--ink-500);
}
.tool-check {
font-size: 0.68rem;
color: var(--bamboo);
}
.subagent-output {
margin-top: var(--space-xs);
}
.output-label {
font-size: 0.68rem;
color: var(--ink-300);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 2px;
}
.output-text {
font-size: 0.78rem;
color: var(--ink-600);
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
max-height: 200px;
overflow-y: auto;
background: var(--paper-100);
border-radius: var(--radius-sm);
padding: var(--space-xs) var(--space-sm);
}
/* 运行中动画 */
.subagent-running {
display: flex;
gap: 3px;
padding: var(--space-xs) 0;
}
.subagent-running .dot {
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--bamboo);
animation: toolDotBounce 1.4s infinite ease-in-out both;
}
.subagent-running .dot:nth-child(2) { animation-delay: 0.16s; }
.subagent-running .dot:nth-child(3) { animation-delay: 0.32s; }
@keyframes toolPulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
@keyframes toolDotBounce {
0%, 80%, 100% { transform: scale(0); }
40% { transform: scale(1); }
}
</style>

View File

@@ -20,6 +20,7 @@ const isSkipped = computed(() => props.group.status === 'skipped')
const TOOL_ICONS: Record<string, string> = {
// 大纲
list_outlines: 'M3 4h10M3 8h10M3 12h6',
get_outline_detail: 'M4 2h8v12H4zM6 5h4M6 8h4',
create_outline: 'M8 3v10M3 8h10',
update_outline: 'M3 12l2-2 4 4-2 2H3v-4zM9 6l2-2 4 4-2 2-4-4z',
delete_outline: 'M3 5h10M5 5V3h6v2M4 5v8h8V5',
@@ -29,11 +30,13 @@ const TOOL_ICONS: Record<string, string> = {
save_world_setting: 'M8 1a7 7 0 100 14A7 7 0 008 1zM4 8l3 3 5-5',
// 角色
list_characters: 'M5.5 5a2.5 2.5 0 105 0 2.5 2.5 0 00-5 0zM3 13c0-2.5 2-4 5-4s5 1.5 5 4',
get_character_detail: 'M5.5 5a2.5 2.5 0 105 0 2.5 2.5 0 00-5 0zM3 13c0-2.5 2-4 5-4s5 1.5 5 4M11 3h3v3',
create_character: 'M5 5a2.5 2.5 0 105 0 2.5 2.5 0 00-5 0zM2 13c0-2.5 2-4 5-4M12 6v5M9.5 8.5h5',
update_character: 'M5 5a2.5 2.5 0 105 0 2.5 2.5 0 00-5 0zM3 13c0-2.5 2-4 5-4M10 10l2-2 2 2-2 2z',
delete_character: 'M5 5a2.5 2.5 0 105 0 2.5 2.5 0 00-5 0zM3 13c0-2.5 2-4 5-4M10 8l4 4M14 8l-4 4',
// 章节
list_chapters: 'M4 2h8v12H4zM6 5h4M6 8h4M6 11h2',
get_chapter_detail: 'M4 2h8v12H4zM6 5h4M6 8h4M6 11h4',
create_chapter: 'M3 2h7v12H3zM12 5v5M9.5 7.5h5',
update_chapter: 'M4 2h8v12H4zM7 7l2-2 2 2-2 2z',
delete_chapter: 'M4 2h8v12H4zM6 6l4 4M10 6l-4 4',
@@ -46,6 +49,7 @@ const TOOL_ICONS: Record<string, string> = {
const TOOL_COLORS: Record<string, string> = {
// 大纲
list_outlines: 'var(--bamboo)',
get_outline_detail: 'var(--bamboo)',
create_outline: 'var(--bamboo)',
update_outline: '#d4a017',
delete_outline: 'var(--danger)',
@@ -55,11 +59,13 @@ const TOOL_COLORS: Record<string, string> = {
save_world_setting: 'var(--bamboo)',
// 角色
list_characters: '#7c6bc4',
get_character_detail: '#7c6bc4',
create_character: '#7c6bc4',
update_character: '#d4a017',
delete_character: 'var(--danger)',
// 章节
list_chapters: '#4a90d9',
get_chapter_detail: '#4a90d9',
create_chapter: '#4a90d9',
update_chapter: '#d4a017',
delete_chapter: 'var(--danger)',
@@ -82,6 +88,8 @@ function formatArgs(call: ToolCallEntry): string {
if (!args || Object.keys(args).length === 0) return ''
switch (call.name) {
case 'get_outline_detail':
return `#${args.outline_id}`
case 'create_outline':
return args.summary as string ?? ''
case 'update_outline':
@@ -97,6 +105,8 @@ function formatArgs(call: ToolCallEntry): string {
return content.length > 80 ? content.slice(0, 80) + '…' : content
}
// 角色
case 'get_character_detail':
return `#${args.character_id}`
case 'create_character':
return args.name as string ?? ''
case 'update_character':
@@ -104,6 +114,8 @@ function formatArgs(call: ToolCallEntry): string {
case 'delete_character':
return `#${args.character_id}`
// 章节
case 'get_chapter_detail':
return `#${args.chapter_id}`
case 'create_chapter':
return args.title as string ?? ''
case 'update_chapter':

View File

@@ -37,10 +37,10 @@ const selectedSkill = computed(() =>
// 工具分组
const TOOL_GROUPS: { label: string; tools: string[] }[] = [
{ label: '小说信息', tools: ['get_novel_info', 'update_novel_info'] },
{ label: '大纲管理', tools: ['list_outlines', 'create_outline', 'update_outline', 'delete_outline', 'reorder_outlines'] },
{ label: '大纲管理', tools: ['list_outlines', 'get_outline_detail', 'create_outline', 'update_outline', 'delete_outline', 'reorder_outlines'] },
{ label: '世界观', tools: ['get_world_setting', 'save_world_setting'] },
{ label: '角色管理', tools: ['list_characters', 'create_character', 'update_character', 'delete_character'] },
{ label: '章节管理', tools: ['list_chapters', 'create_chapter', 'update_chapter', 'delete_chapter'] },
{ label: '角色管理', tools: ['list_characters', 'get_character_detail', 'create_character', 'update_character', 'delete_character'] },
{ label: '章节管理', tools: ['list_chapters', 'get_chapter_detail', 'create_chapter', 'update_chapter', 'delete_chapter'] },
{ label: '文件管理', tools: ['list_files', 'read_file'] },
]

View File

@@ -1,14 +1,15 @@
import { ref, computed, watch } from 'vue'
import { useRoute } from 'vue-router'
import { streamChat, fetchMessages, clearMessages as apiClearMessages, compressContext } from '@/api/chat'
import type { ChatMessage, ChatEntry, ToolCallGroup } from '@/types/chat'
import type { TokenUsage, PendingToolCallData } from '@/api/chat'
import type { ChatMessage, ChatEntry, ToolCallGroup, SubAgentEntry } from '@/types/chat'
import type { TokenUsage, PendingToolCallData, SubAgentStartInfo, SubAgentToolCallInfo, SubAgentToolResultInfo } from '@/api/chat'
// 中文标签 → 英文工具名(用于解析历史消息中的工具调用日志)
const LABEL_TO_TOOL_NAME: Record<string, string> = {
'查看小说信息': 'get_novel_info',
'更新小说信息': 'update_novel_info',
'查询大纲': 'list_outlines',
'查看大纲详情': 'get_outline_detail',
'创建大纲': 'create_outline',
'更新大纲': 'update_outline',
'删除大纲': 'delete_outline',
@@ -16,20 +17,24 @@ const LABEL_TO_TOOL_NAME: Record<string, string> = {
'查询世界观': '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',
'派遣子Agent': 'dispatch_subagent',
}
/**
* 解析历史 assistant 消息中的工具调用日志,拆分为 ToolCallGroup + 纯文本消息
* 格式:[以下操作已执行]\n[工具调用: 标签] 参数: {...} → 结果: {...}\n\n回复文本
* 解析历史 assistant 消息中的工具调用日志,拆分为 ToolCallGroup / SubAgentEntry + 纯文本消息
* 工具格式:[工具调用: 标签] 参数: {...} → 结果: {...}
* 子Agent格式[子Agent: 技能名] 任务: ... → 结果: ...
*/
function parseHistoryMessage(msg: ChatMessage): ChatEntry[] {
const content = msg.content
@@ -37,54 +42,86 @@ function parseHistoryMessage(msg: ChatMessage): ChatEntry[] {
return [msg]
}
const entries: ChatEntry[] = []
const resultEntries: ChatEntry[] = []
// 用双换行分隔工具调用块和后续回复
const firstBlankLine = content.indexOf('\n\n')
const toolSection = firstBlankLine >= 0 ? content.substring(0, firstBlankLine) : content
const replyText = firstBlankLine >= 0 ? content.substring(firstBlankLine + 2).trim() : ''
// 解析工具调用
// 解析每一
const toolLines = toolSection.split('\n').slice(1) // 跳过 [以下操作已执行]
const calls = toolLines
.map(line => {
const match = line.match(/^\[工具调用: (.+?)\] 参数: (.+?) → 结果: (.+)$/)
if (!match) return null
const label = match[1]
const toolCalls: ToolCallGroup['calls'] = []
for (const line of toolLines) {
// 尝试匹配子 Agent 格式
const subMatch = line.match(/^\[子Agent: (.+?)\] 任务: (.+?) → 结果: (.+)$/)
if (subMatch) {
// 先把之前累积的普通工具调用推入
if (toolCalls.length > 0) {
resultEntries.push({
id: idCounter++,
type: 'tool_calls',
calls: [...toolCalls],
status: 'done',
assistantText: '',
} as ToolCallGroup)
toolCalls.length = 0
}
// 创建 SubAgentEntry
resultEntries.push({
id: idCounter++,
type: 'subagent',
subagentId: `hist-${idCounter}`,
skillId: 0,
skillName: subMatch[1],
task: subMatch[2],
status: 'done',
chunks: subMatch[3],
toolCalls: [],
} as SubAgentEntry)
continue
}
// 尝试匹配普通工具调用格式
const toolMatch = line.match(/^\[工具调用: (.+?)\] 参数: (.+?) → 结果: (.+)$/)
if (toolMatch) {
const label = toolMatch[1]
const name = LABEL_TO_TOOL_NAME[label] || label
let args: Record<string, unknown> = {}
try { args = JSON.parse(match[2]) } catch { /* ok */ }
const resultStr = match[3]
try { args = JSON.parse(toolMatch[2]) } catch { /* ok */ }
const resultStr = toolMatch[3]
let parsedResult: unknown = undefined
try { parsedResult = JSON.parse(resultStr) } catch { /* ok */ }
return {
toolCalls.push({
id: `hist-${idCounter++}`,
name,
label,
arguments: args,
result: resultStr,
parsedResult,
}
})
.filter(Boolean) as ToolCallGroup['calls']
})
}
}
if (calls.length > 0) {
entries.push({
// 推入剩余的普通工具调用
if (toolCalls.length > 0) {
resultEntries.push({
id: idCounter++,
type: 'tool_calls',
calls,
calls: toolCalls,
status: 'done',
assistantText: '',
} as ToolCallGroup)
}
if (replyText) {
entries.push({
resultEntries.push({
...msg,
content: replyText,
})
}
return entries
return resultEntries
}
// 全局状态(跨组件共享)
@@ -210,6 +247,18 @@ export function useChatAgent() {
/** 核心:发起流式请求 */
async function _doStreamRequest(novelId?: number, approvedGroup?: ToolCallGroup) {
// ── 预检:发送前检查上次 token 用量,超限先压缩 ──
if (lastUsage.value && !isCompressing.value) {
const total = lastUsage.value.total_tokens
|| (lastUsage.value.prompt_tokens + lastUsage.value.completion_tokens)
if (total > DEFAULT_CONTEXT_WINDOW * AUTO_COMPRESS_THRESHOLD) {
const result = await compressChat()
if (result.success) {
// 压缩成功lastUsage 已清空,继续发送
}
}
}
isStreaming.value = true
streamingContent.value = ''
abortController = new AbortController()
@@ -225,6 +274,13 @@ export function useChatAgent() {
: undefined
const assistantText = approvedGroup?.assistantText
/** 按 subagentId 查找子 Agent entry */
function _findSubAgent(subagentId: string): SubAgentEntry | undefined {
return [...entries.value].reverse().find(
(e): e is SubAgentEntry => 'type' in e && e.type === 'subagent' && e.subagentId === subagentId
)
}
await streamChat({
messages: recentMessages,
novelId,
@@ -306,7 +362,76 @@ export function useChatAgent() {
}))
},
// ── 子 Agent 事件(通过 subagentId 区分并行的子 Agent──
onSubAgentStart(info: SubAgentStartInfo) {
const subEntry: SubAgentEntry = {
id: idCounter++,
type: 'subagent',
subagentId: info.subagent_id,
skillId: info.skill_id,
skillName: info.skill_name,
task: info.task,
status: 'running',
chunks: '',
toolCalls: [],
}
entries.value.push(subEntry)
},
onSubAgentChunk(text: string, subagentId: string) {
const sub = _findSubAgent(subagentId)
if (sub) sub.chunks += text
},
onSubAgentToolCall(info: SubAgentToolCallInfo, subagentId: string) {
const sub = _findSubAgent(subagentId)
if (sub) {
sub.toolCalls.push({
id: `sub-${idCounter++}`,
name: info.name,
label: info.label,
arguments: info.arguments,
})
}
},
onSubAgentToolResult(info: SubAgentToolResultInfo, subagentId: string) {
const sub = _findSubAgent(subagentId)
if (sub) {
const call = sub.toolCalls.find(c => c.name === info.name && !c.result)
if (call) {
call.result = info.result
try { call.parsedResult = JSON.parse(info.result) } catch { /* ok */ }
}
}
window.dispatchEvent(new CustomEvent('tool-executed', {
detail: { name: info.name },
}))
},
onSubAgentDone(_result: string, subagentId: string) {
const sub = _findSubAgent(subagentId)
if (sub) sub.status = 'done'
},
onError(error) {
// 检测上下文溢出错误 — 自动压缩并续接
const isContextOverflow = /context|token|length|too long|maximum|exceeded|limit|过长|超出|上限/i.test(error)
if (isContextOverflow) {
entries.value.push({
id: idCounter++,
novel_id: novelId ?? null,
role: 'assistant',
content: '⚠️ 上下文已超出限制,正在自动压缩...',
created_at: new Date().toISOString(),
})
compressChat().then(result => {
if (result.success) {
sendMessage('上下文已压缩,请基于摘要继续之前未完成的工作。')
}
})
return
}
entries.value.push({
id: idCounter++,
novel_id: novelId ?? null,
@@ -337,14 +462,7 @@ export function useChatAgent() {
streamingContent.value = ''
isStreaming.value = false
abortController = null
// 自动压缩检测:超过 85% 上下文窗口时触发
if (usage) {
const total = usage.total_tokens || (usage.prompt_tokens + usage.completion_tokens)
if (total > DEFAULT_CONTEXT_WINDOW * AUTO_COMPRESS_THRESHOLD) {
compressChat()
}
}
// 注:自动压缩已移至 _doStreamRequest 预检阶段(发送前检查)
},
})
}

View File

@@ -23,6 +23,11 @@ const router = createRouter({
name: 'novel-edit',
component: () => import('@/views/NovelFormView.vue'),
},
{
path: '/skills',
name: 'skills',
component: () => import('@/views/SkillEditorView.vue'),
},
// 工作区:大纲/章节/角色/世界观共享父布局(导航栏持久化)
{
path: '/novels/:id',

View File

@@ -24,13 +24,30 @@ export interface ToolCallEntry {
parsedResult?: unknown
}
/** 对话条目:普通消息或工具调用组 */
export type ChatEntry = ChatMessage | ToolCallGroup
/** 子 Agent 执行组(在对话中显示) */
export interface SubAgentEntry {
id: number
type: 'subagent'
subagentId: string // 后端分配的唯一标识tc.id用于区分并行子 Agent
skillId: number
skillName: string
task: string
status: 'running' | 'done'
chunks: string // 子 Agent 输出文本(实时累积)
toolCalls: ToolCallEntry[] // 子 Agent 的工具调用
}
/** 对话条目:普通消息、工具调用组或子 Agent */
export type ChatEntry = ChatMessage | ToolCallGroup | SubAgentEntry
export function isToolCallGroup(entry: ChatEntry): entry is ToolCallGroup {
return 'type' in entry && entry.type === 'tool_calls'
}
export function isSubAgentEntry(entry: ChatEntry): entry is SubAgentEntry {
return 'type' in entry && entry.type === 'subagent'
}
export interface ChatMessageList {
items: ChatMessage[]
total: number

View File

@@ -0,0 +1,662 @@
<script setup lang="ts">
import { ref, watch, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { listSkills, createSkill, updateSkill, deleteSkill, getAvailableTools } from '@/api/skills'
import type { Skill, ToolInfo } from '@/api/skills'
import { useToast } from '@/composables/useToast'
import BaseButton from '@/components/ui/BaseButton.vue'
const router = useRouter()
const toast = useToast()
const skills = ref<Skill[]>([])
const tools = ref<ToolInfo[]>([])
const selectedId = ref<number | null>(null)
const saving = ref(false)
const isNew = ref(false)
// 编辑表单
const formName = ref('')
const formDesc = ref('')
const formPrompt = ref('')
const formTools = ref<string[]>([])
const selectedSkill = computed(() =>
skills.value.find(s => s.id === selectedId.value) ?? null
)
const hasChanges = computed(() => {
if (isNew.value) return formName.value.trim().length > 0
if (!selectedSkill.value) return false
const s = selectedSkill.value
return (
formName.value !== s.name ||
formDesc.value !== s.description ||
formPrompt.value !== s.system_prompt ||
JSON.stringify(formTools.value.sort()) !== JSON.stringify([...s.allowed_tools].sort())
)
})
// 工具分组
const TOOL_GROUPS: { label: string; icon: string; tools: string[] }[] = [
{ label: '小说信息', icon: 'M4 2h8v12H4z', tools: ['get_novel_info', 'update_novel_info'] },
{ label: '大纲管理', icon: 'M3 4h10M3 8h10M3 12h6', tools: ['list_outlines', 'get_outline_detail', 'create_outline', 'update_outline', 'delete_outline', 'reorder_outlines'] },
{ label: '世界观', icon: 'M8 1a7 7 0 100 14A7 7 0 008 1z', tools: ['get_world_setting', 'save_world_setting'] },
{ label: '角色管理', icon: 'M5.5 5a2.5 2.5 0 105 0 2.5 2.5 0 00-5 0zM3 13c0-2.5 2-4 5-4s5 1.5 5 4', tools: ['list_characters', 'get_character_detail', 'create_character', 'update_character', 'delete_character'] },
{ label: '章节管理', icon: 'M4 2h8v12H4zM6 5h4M6 8h4', tools: ['list_chapters', 'get_chapter_detail', 'create_chapter', 'update_chapter', 'delete_chapter'] },
{ label: '文件管理', icon: 'M4 2h5l3 3v9H4V2z', tools: ['list_files', 'read_file'] },
]
// 工具描述映射
function getToolDesc(name: string): string {
return tools.value.find(t => t.name === name)?.description ?? name
}
// 工具显示名
function getToolLabel(name: string): string {
const labels: Record<string, string> = {
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: '读取',
}
return labels[name] ?? name
}
// 分组全选/取消
function isGroupAllSelected(groupTools: string[]): boolean {
return groupTools.every(t => formTools.value.includes(t))
}
function toggleGroup(groupTools: string[]) {
if (isGroupAllSelected(groupTools)) {
formTools.value = formTools.value.filter(t => !groupTools.includes(t))
} else {
const toAdd = groupTools.filter(t => !formTools.value.includes(t))
formTools.value.push(...toAdd)
}
}
onMounted(async () => {
await Promise.all([loadSkills(), loadTools()])
if (skills.value.length > 0) {
selectSkill(skills.value[0])
}
})
async function loadSkills() {
try {
const result = await listSkills()
skills.value = result.items
} catch { /* 静默 */ }
}
async function loadTools() {
try {
tools.value = await getAvailableTools()
} catch { /* 静默 */ }
}
function selectSkill(skill: Skill) {
selectedId.value = skill.id
formName.value = skill.name
formDesc.value = skill.description
formPrompt.value = skill.system_prompt
formTools.value = [...skill.allowed_tools]
isNew.value = false
}
function startNew() {
selectedId.value = null
formName.value = ''
formDesc.value = ''
formPrompt.value = ''
formTools.value = []
isNew.value = true
}
async function handleSave() {
if (!formName.value.trim()) {
toast.error('请输入技能名称')
return
}
saving.value = true
try {
if (isNew.value) {
const created = await createSkill({
name: formName.value.trim(),
description: formDesc.value.trim(),
system_prompt: formPrompt.value,
allowed_tools: formTools.value,
})
toast.success(`已创建:${created.name}`)
await loadSkills()
selectSkill(created)
} else if (selectedId.value) {
const updated = await updateSkill(selectedId.value, {
name: formName.value.trim(),
description: formDesc.value.trim(),
system_prompt: formPrompt.value,
allowed_tools: formTools.value,
})
toast.success(`已保存:${updated.name}`)
await loadSkills()
selectSkill(updated)
}
} catch {
toast.error('保存失败')
} finally {
saving.value = false
}
}
async function handleDelete() {
if (!selectedId.value || !selectedSkill.value) return
const name = selectedSkill.value.name
try {
await deleteSkill(selectedId.value)
toast.success(`已删除:${name}`)
await loadSkills()
if (skills.value.length > 0) {
selectSkill(skills.value[0])
} else {
startNew()
}
} catch {
toast.error('删除失败')
}
}
</script>
<template>
<div class="skill-editor">
<!-- 头部 -->
<div class="editor-header">
<button class="back-btn" @click="router.back()"> 返回</button>
<h1 class="editor-title">技能管理</h1>
<p class="editor-subtitle">自定义 AI 助手的行为模式和可用工具</p>
</div>
<div class="editor-body">
<!-- 左栏技能列表 -->
<aside class="skill-sidebar">
<div class="sidebar-header">
<span class="sidebar-title">技能列表</span>
<button class="new-btn" @click="startNew" title="新建技能">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M8 3v10M3 8h10" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
</svg>
</button>
</div>
<div class="skill-list">
<div
v-for="skill in skills"
:key="skill.id"
class="skill-item"
:class="{ 'skill-item--active': selectedId === skill.id && !isNew }"
@click="selectSkill(skill)"
>
<div class="item-top">
<span class="item-name">{{ skill.name }}</span>
<span v-if="skill.is_builtin" class="item-badge">预置</span>
</div>
<span class="item-desc">{{ skill.description || '暂无描述' }}</span>
</div>
<!-- 新建占位 -->
<div v-if="isNew" class="skill-item skill-item--active skill-item--new">
<div class="item-top">
<span class="item-name">新建技能</span>
</div>
<span class="item-desc">正在编辑...</span>
</div>
</div>
</aside>
<!-- 右栏编辑区 -->
<main class="edit-area">
<template v-if="selectedId || isNew">
<!-- 基本信息 -->
<section class="edit-section">
<h2 class="section-title">基本信息</h2>
<div class="form-row">
<div class="form-field">
<label class="field-label">名称 <span class="required">*</span></label>
<input v-model="formName" class="field-input" placeholder="例如:大纲规划师" />
</div>
<div class="form-field">
<label class="field-label">描述</label>
<input v-model="formDesc" class="field-input" placeholder="简短描述技能的用途" />
</div>
</div>
</section>
<!-- 系统提示词 -->
<section class="edit-section edit-section--grow">
<h2 class="section-title">系统提示词</h2>
<p class="section-hint">定义 AI 在此技能下的角色定位专业领域和行为规范</p>
<textarea
v-model="formPrompt"
class="prompt-editor"
placeholder="例如:你是大纲规划专家。请帮助用户构建清晰的故事骨架,包括起承转合、情节节点和分章结构..."
/>
</section>
<!-- 可用工具 -->
<section class="edit-section">
<h2 class="section-title">可用工具</h2>
<p class="section-hint">限定此技能可以使用的工具范围不选择任何工具 = 使用全部工具</p>
<div class="tool-grid">
<div v-for="group in TOOL_GROUPS" :key="group.label" class="tool-group-card">
<div class="group-header" @click="toggleGroup(group.tools)">
<svg width="14" height="14" viewBox="0 0 16 16" fill="none">
<path :d="group.icon" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
<span class="group-name">{{ group.label }}</span>
<input
type="checkbox"
:checked="isGroupAllSelected(group.tools)"
:indeterminate="group.tools.some(t => formTools.includes(t)) && !isGroupAllSelected(group.tools)"
class="group-check"
@click.stop="toggleGroup(group.tools)"
/>
</div>
<div class="group-body">
<label
v-for="toolName in group.tools"
:key="toolName"
class="tool-label"
:title="getToolDesc(toolName)"
>
<input type="checkbox" :value="toolName" v-model="formTools" />
<span>{{ getToolLabel(toolName) }}</span>
</label>
</div>
</div>
</div>
</section>
<!-- 操作栏 -->
<div class="edit-actions">
<BaseButton
v-if="selectedId && !isNew"
variant="ghost"
class="delete-action"
@click="handleDelete"
>
删除技能
</BaseButton>
<div class="actions-right">
<BaseButton
:loading="saving"
:disabled="!hasChanges"
@click="handleSave"
>
{{ isNew ? '创建' : '保存修改' }}
</BaseButton>
</div>
</div>
</template>
<div v-else class="edit-empty">
<svg width="48" height="48" viewBox="0 0 16 16" fill="none">
<path d="M8 1l2.5 5H14l-4 3.5 1.5 5.5L8 12l-3.5 3 1.5-5.5L2 6h3.5z" stroke="var(--ink-200)" stroke-width="0.8" />
</svg>
<p>选择一个技能进行编辑</p>
<p class="edit-empty-sub">或点击 + 创建新技能</p>
</div>
</main>
</div>
</div>
</template>
<style scoped>
.skill-editor {
max-width: 1100px;
margin: 0 auto;
padding: var(--space-lg);
min-height: 100vh;
}
/* 头部 */
.editor-header {
margin-bottom: var(--space-xl);
}
.back-btn {
background: none;
border: none;
color: var(--ink-400);
cursor: pointer;
font-size: 0.85rem;
padding: 0;
margin-bottom: var(--space-sm);
transition: color var(--duration-fast) ease;
}
.back-btn:hover {
color: var(--vermilion);
}
.editor-title {
font-family: var(--font-display);
font-size: 1.8rem;
font-weight: 700;
color: var(--ink-900);
margin-bottom: var(--space-xs);
}
.editor-subtitle {
font-size: 0.88rem;
color: var(--ink-400);
}
/* 主体布局 */
.editor-body {
display: flex;
gap: var(--space-xl);
align-items: flex-start;
}
/* 左栏 */
.skill-sidebar {
width: 240px;
flex-shrink: 0;
position: sticky;
top: var(--space-lg);
}
.sidebar-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--space-md);
}
.sidebar-title {
font-size: 0.78rem;
font-weight: 600;
color: var(--ink-500);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.new-btn {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
background: none;
border: 1px solid var(--paper-300);
border-radius: var(--radius-sm);
color: var(--ink-400);
cursor: pointer;
transition: all var(--duration-fast) ease;
}
.new-btn:hover {
border-color: var(--vermilion);
color: var(--vermilion);
}
.skill-list {
display: flex;
flex-direction: column;
gap: var(--space-xs);
}
.skill-item {
padding: var(--space-sm) var(--space-md);
border-radius: var(--radius-md);
cursor: pointer;
border: 1px solid transparent;
transition: all var(--duration-fast) ease;
}
.skill-item:hover {
background: var(--paper-100);
}
.skill-item--active {
background: rgba(91, 140, 90, 0.08);
border-color: rgba(91, 140, 90, 0.2);
}
.skill-item--new {
border-style: dashed;
border-color: var(--vermilion);
background: rgba(199, 62, 29, 0.03);
}
.item-top {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 2px;
}
.item-name {
font-size: 0.85rem;
font-weight: 600;
color: var(--ink-800);
}
.item-badge {
font-size: 0.6rem;
padding: 1px 5px;
border-radius: 8px;
background: rgba(91, 140, 90, 0.12);
color: var(--bamboo);
font-weight: 500;
}
.item-desc {
font-size: 0.72rem;
color: var(--ink-400);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* 右栏 */
.edit-area {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: var(--space-lg);
}
.edit-section {
background: white;
border: 1px solid var(--paper-200);
border-radius: var(--radius-lg);
padding: var(--space-lg);
}
.section-title {
font-family: var(--font-display);
font-size: 1rem;
font-weight: 700;
color: var(--ink-800);
margin-bottom: var(--space-xs);
}
.section-hint {
font-size: 0.78rem;
color: var(--ink-400);
margin-bottom: var(--space-md);
}
/* 表单 */
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-md);
}
.form-field {
display: flex;
flex-direction: column;
gap: var(--space-xs);
}
.field-label {
font-size: 0.78rem;
font-weight: 600;
color: var(--ink-600);
}
.required {
color: var(--vermilion);
}
.field-input {
padding: var(--space-sm) var(--space-md);
font-size: 0.88rem;
font-family: var(--font-body);
color: var(--ink-900);
background: var(--paper-50);
border: 1px solid var(--paper-200);
border-radius: var(--radius-md);
outline: none;
transition: border-color var(--duration-fast) ease;
}
.field-input:focus {
border-color: var(--vermilion);
}
/* 提示词编辑器 */
.prompt-editor {
width: 100%;
min-height: 200px;
padding: var(--space-md);
font-size: 0.88rem;
font-family: var(--font-body);
color: var(--ink-900);
background: var(--paper-50);
border: 1px solid var(--paper-200);
border-radius: var(--radius-md);
outline: none;
resize: vertical;
line-height: 1.7;
transition: border-color var(--duration-fast) ease;
}
.prompt-editor:focus {
border-color: var(--vermilion);
}
/* 工具网格 */
.tool-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: var(--space-sm);
}
.tool-group-card {
background: var(--paper-50);
border: 1px solid var(--paper-200);
border-radius: var(--radius-md);
overflow: hidden;
}
.group-header {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
background: var(--paper-100);
cursor: pointer;
font-size: 0.78rem;
font-weight: 600;
color: var(--ink-600);
transition: background var(--duration-fast) ease;
}
.group-header:hover {
background: var(--paper-200);
}
.group-check {
margin-left: auto;
accent-color: var(--bamboo);
}
.group-body {
padding: 6px 12px;
display: flex;
flex-direction: column;
gap: 2px;
}
.tool-label {
display: flex;
align-items: center;
gap: 6px;
font-size: 0.78rem;
color: var(--ink-600);
cursor: pointer;
padding: 2px 0;
}
.tool-label input {
accent-color: var(--bamboo);
}
/* 操作栏 */
.edit-actions {
display: flex;
align-items: center;
justify-content: space-between;
padding-top: var(--space-md);
border-top: 1px solid var(--paper-200);
}
.actions-right {
margin-left: auto;
}
.delete-action {
color: var(--danger) !important;
}
/* 空状态 */
.edit-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--space-xxl) 0;
color: var(--ink-300);
font-size: 0.92rem;
gap: var(--space-sm);
}
.edit-empty-sub {
font-size: 0.8rem;
color: var(--ink-200);
}
@media (max-width: 768px) {
.editor-body {
flex-direction: column;
}
.skill-sidebar {
width: 100%;
position: static;
}
.form-row {
grid-template-columns: 1fr;
}
.tool-grid {
grid-template-columns: 1fr;
}
}
</style>