Files
aiagent/backend/app/core/streamlined_output.py
renjianbo beff3fac8d fix: delete agent 500 error + dynamic personality + deployment guide
- Fix delete agent 500: clean up FK records (agent_llm_logs, permissions,
  schedules, executions, team_members) and unbind goals/tasks before delete
- Remove hardcoded personality templates in Android, replace with dynamic
  system prompt generation from name + description
- Set promptSectionsEnabled=false to bypass PromptComposer for personality
- Add Tencent Cloud Linux deployment guide (Docker Compose)
- Accumulated backend service updates, frontend UI fixes, Android app changes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-29 01:17:21 +08:00

373 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
工具结果流式美化引擎 — Streamlined Output
参考 Claude Code:
- src/utils/streamlinedTransform.ts — 累积计数 + 文本断点
- src/utils/collapseReadSearch.ts — 搜索/读取折叠分组
- src/utils/groupToolUses.ts — 同类型工具归并
将工具调用过程转译为自然语言描述,让用户看到简洁摘要而非原始 JSON。
"""
from __future__ import annotations
import logging
from enum import Enum
from typing import Any, Callable, Dict, List, Optional, Set, Tuple
logger = logging.getLogger(__name__)
# ──────────────────────────── 工具分类 ────────────────────────────
class ToolCategory(str, Enum):
SEARCH = "search" # 搜索类 (grep, glob, web_search)
READ = "read" # 读取类 (file_read, list_files)
WRITE = "write" # 写入类 (file_write, file_edit, deploy)
COMMAND = "command" # 执行类 (bash, code_execute, database)
OTHER = "other" # 其他
# 参考 Claude Code streamlinedTransform.ts L38-50
SEARCH_TOOLS: Set[str] = {
"grep", "search_content", "search_files", "search_code",
"web_search", "web_fetch", "find_files", "rg", "rg_search",
}
READ_TOOLS: Set[str] = {
"file_read", "list_files", "read_file", "read_dir",
"system_info", "entity_search", "knowledge_graph_search",
"browser_use", "image_ocr", "image_vision",
"url_parse", "http_request",
}
WRITE_TOOLS: Set[str] = {
"file_write", "file_edit", "write_file", "edit_file",
"deploy_push", "docker_manage", "notebook_edit",
"pdf_generate", "excel_process",
}
COMMAND_TOOLS: Set[str] = {
"code_execute", "database_query", "git_operation",
"agent_create", "agent_call", "create_task", "task_plan",
"adb_log", "crypto_util", "schedule_create", "schedule_delete",
"project_scaffold", "tool_register",
"feishu_create_doc", "feishu_create_sheet", "feishu_send_approval",
"feishu_upload_file", "send_email",
"bash", "shell", "cmd",
}
def categorize_tool(tool_name: str) -> ToolCategory:
"""根据工具名称分类。"""
if not tool_name:
return ToolCategory.OTHER
name = tool_name.lower().strip()
if name in SEARCH_TOOLS:
return ToolCategory.SEARCH
if name in READ_TOOLS:
return ToolCategory.READ
if name in WRITE_TOOLS:
return ToolCategory.WRITE
if name in COMMAND_TOOLS:
return ToolCategory.COMMAND
return ToolCategory.OTHER
# ──────────────────────────── 计数器 ────────────────────────────
class ToolCounts:
"""累积工具调用计数。"""
__slots__ = ("searches", "reads", "writes", "commands", "other")
def __init__(self):
self.searches: int = 0
self.reads: int = 0
self.writes: int = 0
self.commands: int = 0
self.other: int = 0
def add(self, category: ToolCategory) -> None:
if category == ToolCategory.SEARCH:
self.searches += 1
elif category == ToolCategory.READ:
self.reads += 1
elif category == ToolCategory.WRITE:
self.writes += 1
elif category == ToolCategory.COMMAND:
self.commands += 1
else:
self.other += 1
def has_any(self) -> bool:
return any((self.searches, self.reads, self.writes, self.commands, self.other))
def reset(self) -> None:
self.searches = 0
self.reads = 0
self.writes = 0
self.commands = 0
self.other = 0
# ──────────────────────────── 摘要生成 ────────────────────────────
def _plural_en(n: int, singular: str, plural: str = "") -> str:
"""英文复数(用于工具名)。"""
if n == 1:
return singular
return plural or singular + "s"
def get_tool_summary_text(counts: ToolCounts) -> Optional[str]:
"""生成工具调用累计摘要文本(中文)。
参考 Claude Code streamlinedTransform.ts L73-104
"""
parts: List[str] = []
if counts.searches > 0:
parts.append(f"搜索了 {counts.searches} 个模式")
if counts.reads > 0:
parts.append(f"读取了 {counts.reads} 个文件")
if counts.writes > 0:
parts.append(f"写入了 {counts.writes} 个文件")
if counts.commands > 0:
parts.append(f"执行了 {counts.commands} 条命令")
if counts.other > 0:
parts.append(f"调用了 {counts.other} 个工具")
if not parts:
return None
return "".join(parts)
def get_search_read_summary(
search_count: int,
read_count: int,
is_active: bool = False,
list_count: int = 0,
) -> str:
"""生成搜索/读取操作的摘要文本(用于折叠组)。
参考 Claude Code collapseReadSearch.ts L961-1066
"""
parts: List[str] = []
if search_count > 0:
if is_active:
verb = "正在搜索" if len(parts) == 0 else "搜索"
else:
verb = "已搜索" if len(parts) == 0 else "搜索了"
parts.append(f"{verb} {search_count} 个模式")
if read_count > 0:
if is_active:
verb = "正在读取" if len(parts) == 0 else "读取"
else:
verb = "已读取" if len(parts) == 0 else "读取了"
parts.append(f"{verb} {read_count} 个文件")
if list_count > 0:
if is_active:
verb = "正在列出" if len(parts) == 0 else "列出"
else:
verb = "已列出" if len(parts) == 0 else "列出了"
parts.append(f"{verb} {list_count} 个目录")
text = "".join(parts)
if is_active:
text += ""
return text
# ──────────────────────────── 流式转换器 ────────────────────────────
class StreamlinedTransformer:
"""有状态的流式转换器:在文本消息之间累积工具计数。
参考 Claude Code streamlinedTransform.ts L130-193
用法:
transformer = StreamlinedTransformer()
for event in stream:
transformed = transformer.transform(event)
if transformed:
yield transformed
"""
def __init__(self, enabled: bool = True):
self.enabled = enabled
self._counts = ToolCounts()
self._pending_tool_results: List[Dict[str, Any]] = []
def transform(self, event: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""转换单个 SSE 事件。返回 None 表示该事件应被过滤。"""
if not self.enabled:
return event
event_type = event.get("type", "")
# ── 文本消息:直接输出,重置计数 ──
if event_type in ("message", "final"):
self._counts.reset()
self._pending_tool_results.clear()
return event
# ── 思考/推理:保留(可配置是否过滤) ──
if event_type == "think":
# 保留 think 事件供前端展示推理过程
return event
# ── 工具调用:累积计数 ──
if event_type == "tool_call":
tool_name = event.get("name", "") or event.get("tool_name", "")
category = categorize_tool(tool_name)
self._counts.add(category)
# 转发工具调用事件(带有分类信息)
return {
**event,
"tool_category": category.value,
}
# ── 工具结果:暂存,等下次文本消息时清除 ──
if event_type == "tool_result":
self._pending_tool_results.append(event)
# 发出累计摘要而非原始结果
summary = get_tool_summary_text(self._counts)
if summary:
return {
"type": "streamlined_summary",
"summary": summary,
"counts": {
"searches": self._counts.searches,
"reads": self._counts.reads,
"writes": self._counts.writes,
"commands": self._counts.commands,
"other": self._counts.other,
},
}
return None
# ── 错误 ──
if event_type == "error":
return event
# ── 其他事件类型:保留 ──
return event
def flush(self) -> Optional[Dict[str, Any]]:
"""刷新最终的累计摘要。"""
summary = get_tool_summary_text(self._counts)
if summary:
return {
"type": "streamlined_summary",
"summary": summary,
"counts": {
"searches": self._counts.searches,
"reads": self._counts.reads,
"writes": self._counts.writes,
"commands": self._counts.commands,
"other": self._counts.other,
},
}
return None
def reset(self) -> None:
"""重置所有累积状态。"""
self._counts.reset()
self._pending_tool_results.clear()
# ──────────────────────────── 批量折叠器 ────────────────────────────
class ReadSearchCollapser:
"""折叠连续的搜索/读取操作为单个摘要组。
参考 Claude Code collapseReadSearch.ts — 按消息流折叠
连续 search/read 类型的工具使用和结果。
规则:
- 连续的 search/read 工具调用会合并为一个组
- writer/command 工具调用会打断组
- 文本消息会打断组
"""
def __init__(self):
self._group: List[Dict[str, Any]] = []
self._search_count = 0
self._read_count = 0
self._list_count = 0
self._is_active = False
def feed_tool_call(self, tool_name: str, tool_input: Optional[Dict] = None) -> Optional[Dict[str, Any]]:
"""处理一个工具调用,如果组被打断则返回 flushed 的组摘要。"""
cat = categorize_tool(tool_name)
if cat in (ToolCategory.SEARCH, ToolCategory.READ):
# 可折叠:加入当前组
if cat == ToolCategory.SEARCH:
self._search_count += 1
else:
self._read_count += 1
self._group.append({"name": tool_name, "input": tool_input})
self._is_active = True
# 返回进行中的摘要
return {
"type": "collapsed_progress",
"summary": get_search_read_summary(
self._search_count, self._read_count,
is_active=True, list_count=self._list_count,
),
"search_count": self._search_count,
"read_count": self._read_count,
"list_count": self._list_count,
}
else:
# 不可折叠:先 flush 组,再返回 None调用方自行处理
flushed = self.flush()
self._group = []
self._is_active = False
return flushed
def flush(self) -> Optional[Dict[str, Any]]:
"""输出当前折叠组的最终摘要。"""
if self._search_count == 0 and self._read_count == 0 and self._list_count == 0:
return None
result = {
"type": "collapsed_group",
"summary": get_search_read_summary(
self._search_count, self._read_count,
is_active=False, list_count=self._list_count,
),
"search_count": self._search_count,
"read_count": self._read_count,
"list_count": self._list_count,
"tool_count": len(self._group),
}
self._search_count = 0
self._read_count = 0
self._list_count = 0
self._group = []
self._is_active = False
return result
def reset(self) -> None:
self._group = []
self._search_count = 0
self._read_count = 0
self._list_count = 0
self._is_active = False
# ──────────────────────────── 工厂函数 ────────────────────────────
def create_streamlined_transformer(enabled: bool = True) -> StreamlinedTransformer:
"""创建 StreamlinedTransformer 实例。"""
return StreamlinedTransformer(enabled=enabled)
def create_read_search_collapser() -> ReadSearchCollapser:
"""创建 ReadSearchCollapser 实例。"""
return ReadSearchCollapser()