Эх сурвалжийг харах

feat:AI助手的tool_call工具不显示原始命令,改为显示描述标签

Jerry Liang 2 сар өмнө
parent
commit
f9c4b2ab03

+ 26 - 8
service/services/aiChatServices.go

@@ -14,12 +14,14 @@ import (
 
 // AIChatEvent SSE 事件(输出给前端)
 type AIChatEvent struct {
-	Type       string `json:"type"`                // text, tool_call, tool_update, done, error
-	Content    string `json:"content,omitempty"`   // 文本内容
-	ToolName   string `json:"name,omitempty"`      // 工具名
-	Status     string `json:"status,omitempty"`    // 工具状态
-	SessionId  string `json:"sessionId,omitempty"` // 会话ID
-	StopReason string `json:"stopReason,omitempty"`
+	Type        string `json:"type"`                  // text, tool_call, tool_update, done, error
+	Content     string `json:"content,omitempty"`     // 文本内容
+	ToolCallId  string `json:"toolCallId,omitempty"`  // 工具调用唯一ID(前端按 id 关联状态)
+	ToolName    string `json:"name,omitempty"`        // 工具原始 title(前端作 hover tooltip)
+	Description string `json:"description,omitempty"` // 工具简要描述(前端展示)
+	Status      string `json:"status,omitempty"`      // 工具状态(已归一化:running/completed/error)
+	SessionId   string `json:"sessionId,omitempty"`   // 会话ID
+	StopReason  string `json:"stopReason,omitempty"`
 }
 
 // ACPSession 单个用户的 ACP 会话
@@ -354,9 +356,25 @@ func (m *ACPManager) Prompt(sess *ACPSession, message string, eventCh chan<- AIC
 		case "text":
 			eventCh <- AIChatEvent{Type: "text", Content: event.Content}
 		case "tool_call":
-			eventCh <- AIChatEvent{Type: "tool_call", ToolName: event.ToolName, Status: event.ToolStatus}
+			eventCh <- AIChatEvent{
+				Type:        "tool_call",
+				ToolCallId:  event.ToolCallId,
+				ToolName:    event.ToolName,
+				Description: acp.DescribeTool(event.ToolName),
+				Status:      acp.NormalizeStatus(event.ToolStatus),
+			}
 		case "tool_update":
-			eventCh <- AIChatEvent{Type: "tool_update", ToolName: event.ToolName, Status: event.ToolStatus}
+			// update 一般只变更状态;若本次携带了新 title 才重新映射简述
+			ev := AIChatEvent{
+				Type:       "tool_update",
+				ToolCallId: event.ToolCallId,
+				Status:     acp.NormalizeStatus(event.ToolStatus),
+			}
+			if event.ToolName != "" {
+				ev.ToolName = event.ToolName
+				ev.Description = acp.DescribeTool(event.ToolName)
+			}
+			eventCh <- ev
 		case "permission":
 			eventCh <- AIChatEvent{Type: "permission", Content: toJSON(event.Permission)}
 		case "metadata":

+ 3 - 2
service/utils/acp/acp.go

@@ -189,6 +189,7 @@ type ToolCallUpdate struct {
 type Event struct {
 	Type       string // "text", "tool_call", "tool_update", "done", "error", "permission"
 	Content    string
+	ToolCallId string // 工具调用唯一ID(用于前端按 id 关联状态更新)
 	ToolName   string
 	ToolStatus string
 	SessionId  string
@@ -827,14 +828,14 @@ func (c *Client) handleSessionUpdate(params json.RawMessage, eventCh chan<- Even
 		var tc ToolCall
 		if err := json.Unmarshal(notif.Update, &tc); err == nil {
 			zap.L().Debug("[ACP] → tool_call", zap.String("title", tc.Title), zap.String("status", tc.Status), zap.String("id", tc.ToolCallId))
-			eventCh <- Event{Type: "tool_call", ToolName: tc.Title, ToolStatus: tc.Status}
+			eventCh <- Event{Type: "tool_call", ToolCallId: tc.ToolCallId, ToolName: tc.Title, ToolStatus: tc.Status}
 		}
 
 	case "tool_call_update":
 		var tcu ToolCallUpdate
 		if err := json.Unmarshal(notif.Update, &tcu); err == nil {
 			zap.L().Debug("[ACP] → tool_call_update", zap.String("title", tcu.Title), zap.String("status", tcu.Status), zap.String("id", tcu.ToolCallId))
-			eventCh <- Event{Type: "tool_update", ToolName: tcu.Title, ToolStatus: tcu.Status}
+			eventCh <- Event{Type: "tool_update", ToolCallId: tcu.ToolCallId, ToolName: tcu.Title, ToolStatus: tcu.Status}
 		}
 
 	case "tool_call_chunk":

+ 192 - 0
service/utils/acp/toolLabel.go

@@ -0,0 +1,192 @@
+package acp
+
+import "regexp"
+
+// DefaultToolLabel 未命中任何规则时的兜底简述
+const DefaultToolLabel = "调用工具"
+
+// mcpIdentRe 判断 title 是否为 MCP 工具标识名(service_/data_/storage_/plane_/util_ 前缀)。
+// 仅在标识名形态下才走「动作 + 对象」组合,避免误伤内置工具的自然语言 title。
+var mcpIdentRe = regexp.MustCompile(`(?i)(^|[^a-z])(service|data|storage|plane|util)_[a-z]`)
+
+// domainNoiseRe 域名中会误触发动作识别的噪声片段(其中的 update 属于对象名而非动作)。
+// 在动作识别前先剔除,避免 auto_update / update_log 等被误判为“更新”动作。
+var domainNoiseRe = regexp.MustCompile(`(?i)auto_update|system_update|ahub_update|update_log`)
+
+// objectRule 对象词识别规则(title 片段 → 对象中文名)
+type objectRule struct {
+	re   *regexp.Regexp
+	noun string
+}
+
+// objectRules 顺序即优先级,更具体的对象必须排在更宽泛的之前。
+var objectRules = []objectRule{
+	// —— Plane 项目管理相关对象 ——
+	{regexp.MustCompile(`(?i)fault_record`), "故障工单"},
+	{regexp.MustCompile(`(?i)intake_issue`), "收件箱工作项"},
+	{regexp.MustCompile(`(?i)sub_issue`), "子工作项"},
+	{regexp.MustCompile(`(?i)issue_relation|_relation`), "工作项关联"},
+	{regexp.MustCompile(`(?i)issue_link`), "工作项链接"},
+	{regexp.MustCompile(`(?i)work_item|_issues?(_|$)|_issue(_|$)|issue`), "工作项"},
+	{regexp.MustCompile(`(?i)cycle`), "周期"},
+	{regexp.MustCompile(`(?i)module`), "模块"},
+	{regexp.MustCompile(`(?i)page`), "页面"},
+	{regexp.MustCompile(`(?i)workspace_member`), "工作空间成员"},
+	{regexp.MustCompile(`(?i)project_member`), "项目成员"},
+	{regexp.MustCompile(`(?i)project_asset`), "项目资源"},
+	{regexp.MustCompile(`(?i)workspace`), "工作空间"},
+	{regexp.MustCompile(`(?i)project`), "项目"},
+	{regexp.MustCompile(`(?i)label`), "标签"},
+	{regexp.MustCompile(`(?i)state`), "状态"},
+	{regexp.MustCompile(`(?i)comment`), "评论"},
+	{regexp.MustCompile(`(?i)sticky|stickies`), "便签"},
+	{regexp.MustCompile(`(?i)assets?|entity`), "资源"},
+
+	// —— 站点 / 监控 / 数据相关对象 ——
+	{regexp.MustCompile(`(?i)alarm_snapshot`), "告警快照"},
+	{regexp.MustCompile(`(?i)alarm_statistic`), "告警统计"},
+	{regexp.MustCompile(`(?i)event_alarm`), "事件告警"},
+	{regexp.MustCompile(`(?i)alarm`), "告警"},
+	{regexp.MustCompile(`(?i)shutdown`), "停机"},
+	{regexp.MustCompile(`(?i)power_flow`), "功率流"},
+	{regexp.MustCompile(`(?i)mode_info`), "运行模式"},
+	{regexp.MustCompile(`(?i)base_info`), "基础信息"},
+	{regexp.MustCompile(`(?i)tou_template`), "电价模板"},
+	{regexp.MustCompile(`(?i)apower_version`), "APower版本"},
+	{regexp.MustCompile(`(?i)auto_update|_package|升级包`), "升级包"},
+	{regexp.MustCompile(`(?i)batch_download`), "批量下载"},
+	{regexp.MustCompile(`(?i)background_task`), "后台任务"},
+	{regexp.MustCompile(`(?i)site_analysis`), "站点分析"},
+	{regexp.MustCompile(`(?i)site_monitor`), "站点监控"},
+	{regexp.MustCompile(`(?i)_tag|tags_`), "站点标签"},
+	{regexp.MustCompile(`(?i)site`), "站点"},
+	{regexp.MustCompile(`(?i)data_pool`), "数据池"},
+	{regexp.MustCompile(`(?i)_card|_sim|sim_`), "SIM卡"},
+	{regexp.MustCompile(`(?i)dashboard`), "看板"},
+	{regexp.MustCompile(`(?i)feedback`), "反馈"},
+	{regexp.MustCompile(`(?i)monitor`), "监控"},
+	{regexp.MustCompile(`(?i)snapshot`), "快照数据"},
+	{regexp.MustCompile(`(?i)system_update_log|ahub_update_log|update_log`), "升级日志"},
+	{regexp.MustCompile(`(?i)ems_log|mqtt_log|_log(_|$)`), "日志"},
+	{regexp.MustCompile(`(?i)pid_info|vid_info|emspara`), "EMS参数"},
+
+	// —— 数据库 / 存储 ——
+	{regexp.MustCompile(`(?i)mongo`), "MongoDB"},
+	{regexp.MustCompile(`(?i)redis`), "Redis缓存"},
+	{regexp.MustCompile(`(?i)pgsql`), "PostgreSQL"},
+	{regexp.MustCompile(`(?i)file|minio|object`), "文件"},
+}
+
+// actionRule 动作词识别规则(title 片段 → 动词 + 后缀)
+type actionRule struct {
+	re     *regexp.Regexp
+	verb   string
+	suffix string
+}
+
+// actionRules 顺序即优先级。变更类动作先于查询类;unarchive 先于 archive;list 先于 get。
+var actionRules = []actionRule{
+	{regexp.MustCompile(`(?i)unarchive`), "取消归档", ""},
+	{regexp.MustCompile(`(?i)archive`), "归档", ""},
+	{regexp.MustCompile(`(?i)create`), "创建", ""},
+	{regexp.MustCompile(`(?i)update|mark`), "更新", ""},
+	{regexp.MustCompile(`(?i)delete`), "删除", ""},
+	{regexp.MustCompile(`(?i)remove`), "移除", ""},
+	{regexp.MustCompile(`(?i)accept`), "受理", ""},
+	{regexp.MustCompile(`(?i)reject`), "驳回", ""},
+	{regexp.MustCompile(`(?i)add`), "添加", ""},
+	{regexp.MustCompile(`(?i)bind`), "绑定", ""},
+	{regexp.MustCompile(`(?i)trigger`), "触发", ""},
+	{regexp.MustCompile(`(?i)upload`), "上传", ""},
+	{regexp.MustCompile(`(?i)write`), "写入", ""},
+	{regexp.MustCompile(`(?i)read`), "读取", ""},
+	{regexp.MustCompile(`(?i)search`), "搜索", ""},
+	{regexp.MustCompile(`(?i)_list(_|$)|list_|_tree|name_list`), "查询", "列表"},
+	// 查询类兜底动词(get/detail/info/find/query/count/exists 等)
+	{regexp.MustCompile(`(?i)get|detail|info|find|query|count|exists|options|config|summary|activit`), "查询", ""},
+}
+
+// fallbackRules 非 MCP 标识名(内置工具 / 中文描述 / 通用关键词)的兜底映射表。
+type toolLabelRule struct {
+	re    *regexp.Regexp
+	label string
+}
+
+var fallbackRules = []toolLabelRule{
+	{regexp.MustCompile(`(?i)web_search|联网搜索`), "联网搜索"},
+	{regexp.MustCompile(`(?i)web_fetch|fetch|抓取|网页`), "抓取网页"},
+	{regexp.MustCompile(`(?i)knowledge|知识库|检索`), "知识库检索"},
+	{regexp.MustCompile(`(?i)subagent|子任务`), "子任务处理"},
+	{regexp.MustCompile(`(?i)todo|任务清单`), "任务清单"},
+	{regexp.MustCompile(`(?i)introspect`), "功能查询"},
+	{regexp.MustCompile(`(?i)\bcode\b|代码`), "代码分析"},
+	{regexp.MustCompile(`(?i)grep|搜索|查找`), "搜索内容"},
+	{regexp.MustCompile(`(?i)glob|查找文件`), "查找文件"},
+	{regexp.MustCompile(`(?i)read|cat|查看|读取`), "读取文件"},
+	{regexp.MustCompile(`(?i)write|create|edit|写入|修改`), "写入文件"},
+	{regexp.MustCompile(`(?i)util_|parse_sn`), "解析序列号"},
+	{regexp.MustCompile(`(?i)execut|bash|shell|command|执行|运行`), "执行命令"},
+}
+
+// DescribeTool 根据工具调用 title 返回详细描述(动作 + 对象)。
+//
+//	MCP 标识名形态:组合「动作 + 对象」,如 create_issue -> “创建工作项”,
+//	                list_work_items -> “查询工作项列表”。
+//	其他形态:回退到关键词兜底表;无命中返回 DefaultToolLabel。
+func DescribeTool(title string) string {
+	if title == "" {
+		return DefaultToolLabel
+	}
+
+	// MCP 标识名 → 动作 + 对象 组合
+	if mcpIdentRe.MatchString(title) {
+		obj := ""
+		for _, r := range objectRules {
+			if r.re.MatchString(title) {
+				obj = r.noun
+				break
+			}
+		}
+		if obj != "" {
+			// 默认动作为“查询”(大多数无动词的工具是读取类)
+			verb, suffix := "查询", ""
+			// 剔除域名噪声后再识别动作,避免 auto_update / update_log 误判为“更新”
+			actionSrc := domainNoiseRe.ReplaceAllString(title, "_")
+			for _, a := range actionRules {
+				if a.re.MatchString(actionSrc) {
+					verb, suffix = a.verb, a.suffix
+					break
+				}
+			}
+			return verb + obj + suffix
+		}
+	}
+
+	// 回退:内置工具 / 中文描述 / 通用关键词
+	for _, r := range fallbackRules {
+		if r.re.MatchString(title) {
+			return r.label
+		}
+	}
+	return DefaultToolLabel
+}
+
+// NormalizeStatus 将 ACP / 各来源的工具状态归一化为前端使用的三态:
+// running / completed / error。
+//
+//	pending, in_progress, running -> running
+//	completed                     -> completed
+//	failed, error                 -> error
+//	其他(未知)                    -> running(先按运行中处理,避免误判为错误)
+func NormalizeStatus(raw string) string {
+	switch raw {
+	case "pending", "in_progress", "running":
+		return "running"
+	case "completed":
+		return "completed"
+	case "failed", "error":
+		return "error"
+	default:
+		return "running"
+	}
+}

+ 3 - 2
src/layout/header/Actions/AiChat/MessageItem.vue

@@ -92,14 +92,15 @@ function formatSize(bytes: number): string {
       <div v-if="message.toolCalls?.length" class="mb-2 flex flex-wrap gap-1">
         <NTag
           v-for="tc in message.toolCalls"
-          :key="tc.name"
+          :key="tc.id"
           size="small"
+          :title="tc.title"
           :type="tc.status === 'running' ? 'warning' : tc.status === 'completed' ? 'success' : 'error'"
         >
           <template #icon>
             <NSpin v-if="tc.status === 'running'" :size="10" />
           </template>
-          {{ tc.name }}
+          {{ tc.description }}
         </NTag>
       </div>
 

+ 3 - 1
src/layout/header/Actions/AiChat/interface.ts

@@ -9,7 +9,9 @@ export interface ChatMessage {
 }
 
 export interface ToolCallInfo {
-  name: string
+  id: string // toolCallId,用于按 id 关联状态更新与作为渲染 key
+  description: string // 展示用简要描述
+  title?: string // 工具原始 title,作 hover tooltip
   status: 'running' | 'completed' | 'error'
 }
 

+ 11 - 3
src/layout/header/Actions/AiChat/useAiChat.ts

@@ -570,13 +570,21 @@ export function useAiChat() {
         case 'tool_call':
           if (!aiMessage.toolCalls) aiMessage.toolCalls = []
           aiMessage.toolCalls.push({
-            name: payload.name || '',
+            id: payload.toolCallId || '',
+            description: payload.description || '调用工具',
+            title: payload.name || '',
             status: 'running',
           })
           break
         case 'tool_update': {
-          const tc = aiMessage.toolCalls?.find((t: ToolCallInfo) => t.name === payload.name)
-          if (tc) tc.status = payload.status || 'completed'
+          // 按 toolCallId 关联对应的工具调用
+          const tc = aiMessage.toolCalls?.find((t: ToolCallInfo) => t.id === payload.toolCallId)
+          if (tc) {
+            if (payload.status) tc.status = payload.status
+            // update 携带新 title/description 时一并更新
+            if (payload.description) tc.description = payload.description
+            if (payload.name) tc.title = payload.name
+          }
           break
         }
         case 'permission': {