- 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>
373 lines
13 KiB
Python
373 lines
13 KiB
Python
"""
|
||
工具结果流式美化引擎 — 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()
|