Files
cloudpods/pkg/llm/models/mcp_agent_progress.go

625 lines
16 KiB
Go
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.

// Copyright 2019 Yunion
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package models
import (
"encoding/json"
"fmt"
"strings"
)
// progressLabelCache 缓存 list 结果中的 id→可读名称供后续进度文案使用。
type progressLabelCache struct {
regions map[string]string
}
func newProgressLabelCache() *progressLabelCache {
return &progressLabelCache{regions: make(map[string]string)}
}
func (c *progressLabelCache) rememberFromTool(toolName, resultText string) {
if c == nil || isToolResultError(resultText) {
return
}
name := strings.TrimPrefix(toolName, "climc_")
if strings.Contains(name, "cloud_region_list") {
c.rememberRegions(resultText)
}
}
func (c *progressLabelCache) rememberRegions(resultText string) {
if c.regions == nil {
c.regions = make(map[string]string)
}
items, _ := extractListItems(resultText)
for _, item := range items {
id := jsonString(item, "id")
name := jsonString(item, "name")
if id == "" || name == "" {
continue
}
ext := jsonString(item, "external_id")
label := name
if ext != "" && !strings.EqualFold(ext, name) {
label = fmt.Sprintf("%s%s", name, ext)
}
c.regions[id] = label
c.regions[name] = label
if ext != "" {
c.regions[ext] = label
}
}
}
func (c *progressLabelCache) regionLabel(idOrName string) string {
if idOrName == "" {
return ""
}
if c != nil && c.regions != nil {
if v := c.regions[idOrName]; v != "" {
return v
}
}
return idOrName
}
// summarizeToolProgress 将工具调用结果整理成面向用户的进度文案(逐项展示资源,而非工具名)。
func summarizeToolProgress(toolName string, args map[string]interface{}, resultText string, labels *progressLabelCache) string {
name := strings.TrimPrefix(toolName, "climc_")
if isToolResultError(resultText) {
return fmt.Sprintf("✗ %s失败%s\n", progressLabel(name), truncateRunes(stripMCPHint(resultText), 180))
}
switch {
case name == "docs_search" || strings.HasSuffix(name, "docs_search"):
return formatResourceListProgress("文档", resultText, []string{"name", "title", "path"})
case name == "docs_get" || strings.HasSuffix(name, "docs_get"):
path := firstArg(args, "path", "PATH")
if path != "" {
return fmt.Sprintf("✓ 已阅读文档:%s\n", path)
}
return "✓ 已阅读文档\n"
case strings.Contains(name, "cloud_region_capability"):
id := firstArg(args, "id", "ID", "name")
region := labels.regionLabel(id)
types := extractStorageTypeHints(resultText)
if region != "" && types != "" {
return fmt.Sprintf("✓ 区域 %s 可用磁盘类型:%s\n", region, types)
}
if types != "" {
return fmt.Sprintf("✓ 可用磁盘类型:%s\n", types)
}
return fmt.Sprintf("✓ 已查询区域能力%s\n", paren(region))
case strings.Contains(name, "cloud_region_list"):
return formatResourceListProgress("区域", resultText, []string{"name", "external_id", "id"})
case strings.Contains(name, "cached_image_list"), strings.Contains(name, "image_list"):
return formatResourceListProgress("镜像", resultText, []string{"name", "os_type", "os_distribution", "id"})
case strings.Contains(name, "server_sku_list"):
return formatResourceListProgress("套餐", resultText, []string{"name", "instance_type_category", "cpu_core_count", "memory_size_mb", "id"})
case strings.Contains(name, "network_list"):
return formatResourceListProgress("网络", resultText, []string{"name", "guest_ip_prefix", "vpc", "id"})
case strings.Contains(name, "vpc_list"):
return formatResourceListProgress("VPC", resultText, []string{"name", "cidr_block", "id"})
case strings.Contains(name, "storage_list"):
return formatResourceListProgress("存储", resultText, []string{"name", "storage_type", "capacity", "id"})
case strings.Contains(name, "server_list"):
return formatResourceListProgress("虚拟机", resultText, []string{"name", "status", "id"})
case strings.Contains(name, "server_create"):
return formatServerCreateProgress(args, resultText, labels)
case strings.Contains(name, "server_show"):
return formatSingleResourceProgress("虚拟机详情", resultText, []string{"name", "status", "id"})
case strings.HasPrefix(name, "server_"):
id := firstArg(args, "id", "ID", "name")
action := strings.TrimPrefix(name, "server_")
return fmt.Sprintf("✓ 虚拟机%s%s\n", actionLabel(action), paren(id))
default:
return formatGenericProgress(name, args, resultText)
}
}
func isToolResultError(resultText string) bool {
s := strings.TrimSpace(resultText)
return strings.Contains(s, "调用失败") ||
strings.Contains(s, "返回错误") ||
strings.HasPrefix(s, "工具 ") && strings.Contains(s, "失败")
}
func progressLabel(toolName string) string {
switch {
case strings.Contains(toolName, "cloud_region_list"):
return "查询区域"
case strings.Contains(toolName, "cloud_region_capability"):
return "查询区域能力"
case strings.Contains(toolName, "cached_image"):
return "查询镜像"
case strings.Contains(toolName, "image_list"):
return "查询镜像"
case strings.Contains(toolName, "server_sku"):
return "查询套餐"
case strings.Contains(toolName, "network_list"):
return "查询网络"
case strings.Contains(toolName, "vpc_list"):
return "查询 VPC"
case strings.Contains(toolName, "server_create"):
return "创建虚拟机"
default:
return toolName
}
}
func actionLabel(action string) string {
switch action {
case "start":
return "已启动"
case "stop":
return "已停止"
case "restart":
return "已重启"
case "delete":
return "已删除"
case "set_password", "set-password":
return "已重置密码"
default:
return "操作完成"
}
}
func formatServerCreateProgress(args map[string]interface{}, resultText string, labels *progressLabelCache) string {
var b strings.Builder
b.WriteString("✓ 选用资源创建虚拟机:")
parts := make([]string, 0, 6)
if v := firstArg(args, "name", "NAME"); v != "" {
if isTruthy(args["generate-name"]) || isTruthy(args["generate_name"]) || isTruthy(args["GenerateName"]) {
parts = append(parts, "名称模板="+v+"(自动去重)")
} else {
parts = append(parts, "名称="+v)
}
}
if v := firstArg(args, "hypervisor"); v != "" {
parts = append(parts, "平台="+v)
}
if v := firstArg(args, "prefer-region", "prefer_region", "region"); v != "" {
parts = append(parts, "区域="+labels.regionLabel(v))
}
if v := firstArg(args, "instance-type", "instance_type", "sku"); v != "" {
parts = append(parts, "规格="+v)
}
if v := firstArg(args, "ncpu"); v != "" {
parts = append(parts, "CPU="+v)
}
if v := firstArg(args, "mem-spec", "mem_spec"); v != "" {
parts = append(parts, "内存="+v)
}
if disks := argStringSlice(args, "disk"); len(disks) > 0 {
parts = append(parts, "磁盘="+truncateRunes(disks[0], 80))
}
if nets := argStringSlice(args, "net"); len(nets) > 0 {
parts = append(parts, "网络="+strings.Join(nets, ","))
} else {
parts = append(parts, "网络=自动调度")
}
b.WriteString(strings.Join(parts, ""))
b.WriteByte('\n')
body := stripMCPHint(resultText)
if obj := parseJSONObject(body); obj != nil {
status := jsonString(obj, "final_status")
sid := jsonString(obj, "server_id")
sname := ""
if sid == "" {
if srv, ok := obj["server"].(map[string]interface{}); ok {
sid = jsonString(srv, "id")
sname = jsonString(srv, "name")
if status == "" {
status = jsonString(srv, "status")
}
}
} else if srv, ok := obj["server"].(map[string]interface{}); ok {
sname = jsonString(srv, "name")
}
if waitErr := jsonString(obj, "wait_error"); waitErr != "" {
b.WriteString(fmt.Sprintf(" 创建未完成:%s\n", truncateRunes(waitErr, 160)))
if hint := jsonString(obj, "hint"); hint != "" {
b.WriteString(fmt.Sprintf(" 提示:%s\n", truncateRunes(hint, 160)))
}
return b.String()
}
if sid != "" || status != "" || sname != "" {
b.WriteString(" 结果:")
bits := make([]string, 0, 3)
if sname != "" {
bits = append(bits, "名称="+sname)
}
if sid != "" {
bits = append(bits, "id="+sid)
}
if status != "" {
bits = append(bits, "状态="+status)
}
b.WriteString(strings.Join(bits, ""))
b.WriteByte('\n')
}
}
return b.String()
}
func isTruthy(v interface{}) bool {
switch x := v.(type) {
case bool:
return x
case string:
s := strings.ToLower(strings.TrimSpace(x))
return s == "true" || s == "1" || s == "yes" || s == "on"
case float64:
return x != 0
case int:
return x != 0
default:
return false
}
}
func formatResourceListProgress(kind, resultText string, fields []string) string {
items, total := extractListItems(resultText)
if len(items) == 0 {
if total == 0 {
return fmt.Sprintf("✓ 未找到可用%s\n", kind)
}
return fmt.Sprintf("✓ 已查询%s共 %d 条)\n", kind, total)
}
if total <= 0 {
total = len(items)
}
labels := make([]string, 0, 5)
for i, item := range items {
if i >= 5 {
break
}
labels = append(labels, formatItemLabel(item, fields))
}
more := ""
if total > len(labels) {
more = fmt.Sprintf("等共 %d 个", total)
} else {
more = fmt.Sprintf("共 %d 个", total)
}
return fmt.Sprintf("✓ 已找到%s%s%s\n", kind, strings.Join(labels, "、"), more)
}
func formatSingleResourceProgress(kind, resultText string, fields []string) string {
obj := parseJSONObject(stripMCPHint(resultText))
if obj == nil {
return fmt.Sprintf("✓ 已获取%s\n", kind)
}
return fmt.Sprintf("✓ %s%s\n", kind, formatItemLabel(obj, fields))
}
func formatGenericProgress(toolName string, args map[string]interface{}, resultText string) string {
id := firstArg(args, "id", "ID", "name", "NAME")
items, total := extractListItems(resultText)
if len(items) > 0 {
return formatResourceListProgress(progressLabel(toolName), resultText, []string{"name", "id"})
}
if id != "" {
return fmt.Sprintf("✓ %s完成%s\n", progressLabel(toolName), paren(id))
}
_ = resultText
if total > 0 {
return fmt.Sprintf("✓ %s完成%d 条)\n", progressLabel(toolName), total)
}
return fmt.Sprintf("✓ %s完成\n", progressLabel(toolName))
}
func formatItemLabel(item map[string]interface{}, fields []string) string {
parts := make([]string, 0, 3)
seen := map[string]bool{}
for _, f := range fields {
v := jsonString(item, f)
if v == "" || seen[v] {
continue
}
seen[v] = true
parts = append(parts, v)
if len(parts) >= 2 {
break
}
}
if len(parts) == 0 {
return "(未命名)"
}
if len(parts) == 1 {
return parts[0]
}
return fmt.Sprintf("%s(%s)", parts[0], parts[1])
}
func extractListItems(resultText string) ([]map[string]interface{}, int) {
body := stripMCPHint(resultText)
obj := parseJSONObject(body)
if obj != nil {
total := jsonInt(obj, "total")
if total <= 0 {
total = jsonInt(obj, "count")
}
for _, key := range []string{"data", "hits"} {
data, ok := obj[key].([]interface{})
if !ok {
continue
}
items := make([]map[string]interface{}, 0, len(data))
for _, d := range data {
if m, ok := d.(map[string]interface{}); ok {
items = append(items, m)
}
}
if total <= 0 {
total = len(items)
}
return items, total
}
// 单对象结果
if jsonString(obj, "id") != "" || jsonString(obj, "name") != "" {
return []map[string]interface{}{obj}, 1
}
}
if arr := parseJSONArray(body); len(arr) > 0 {
items := make([]map[string]interface{}, 0, len(arr))
for _, d := range arr {
if m, ok := d.(map[string]interface{}); ok {
items = append(items, m)
}
}
return items, len(items)
}
return nil, 0
}
func extractStorageTypeHints(resultText string) string {
obj := parseJSONObject(stripMCPHint(resultText))
if obj == nil {
return ""
}
for _, key := range []string{"storage_types2", "StorageTypes2"} {
raw, ok := obj[key]
if !ok {
continue
}
m, ok := raw.(map[string]interface{})
if !ok {
continue
}
set := make([]string, 0, 8)
seen := map[string]bool{}
for _, v := range m {
arr, ok := v.([]interface{})
if !ok {
continue
}
for _, x := range arr {
s, ok := x.(string)
if !ok || s == "" || seen[s] {
continue
}
seen[s] = true
if i := strings.Index(s, "/"); i > 0 {
s = s[:i]
}
set = append(set, s)
if len(set) >= 6 {
return strings.Join(set, "、")
}
}
}
if len(set) > 0 {
return strings.Join(set, "、")
}
}
return ""
}
func stripMCPHint(s string) string {
s = strings.TrimSpace(s)
if i := strings.Index(s, "[MCP下一步]"); i >= 0 {
s = strings.TrimSpace(s[:i])
}
return s
}
func parseJSONObject(s string) map[string]interface{} {
s = strings.TrimSpace(s)
if s == "" || s[0] != '{' {
// 可能前后有非 JSON 文本,尝试截取第一个对象
start := strings.Index(s, "{")
end := strings.LastIndex(s, "}")
if start < 0 || end <= start {
return nil
}
s = s[start : end+1]
}
var obj map[string]interface{}
if err := json.Unmarshal([]byte(s), &obj); err != nil {
return nil
}
return obj
}
func parseJSONArray(s string) []interface{} {
s = strings.TrimSpace(s)
if s == "" {
return nil
}
if s[0] != '[' {
start := strings.Index(s, "[")
end := strings.LastIndex(s, "]")
if start < 0 || end <= start {
return nil
}
s = s[start : end+1]
}
var arr []interface{}
if err := json.Unmarshal([]byte(s), &arr); err != nil {
return nil
}
return arr
}
func firstArg(args map[string]interface{}, keys ...string) string {
if args == nil {
return ""
}
normalize := func(k string) string {
return strings.ReplaceAll(strings.ToLower(k), "_", "-")
}
for _, key := range keys {
if v, ok := args[key]; ok {
if s := stringifyArg(v); s != "" {
return s
}
}
want := normalize(key)
for k, v := range args {
if normalize(k) == want {
if s := stringifyArg(v); s != "" {
return s
}
}
}
}
return ""
}
func argStringSlice(args map[string]interface{}, key string) []string {
if args == nil {
return nil
}
v, ok := args[key]
if !ok {
alt := strings.ReplaceAll(key, "-", "_")
v, ok = args[alt]
if !ok {
return nil
}
}
switch x := v.(type) {
case []string:
return x
case []interface{}:
out := make([]string, 0, len(x))
for _, item := range x {
if s := stringifyArg(item); s != "" {
out = append(out, s)
}
}
return out
case string:
if x != "" {
return []string{x}
}
}
return nil
}
func stringifyArg(v interface{}) string {
switch x := v.(type) {
case string:
return strings.TrimSpace(x)
case float64:
if x == float64(int64(x)) {
return fmt.Sprintf("%d", int64(x))
}
return fmt.Sprintf("%v", x)
case int:
return fmt.Sprintf("%d", x)
case int64:
return fmt.Sprintf("%d", x)
case bool:
return fmt.Sprintf("%v", x)
case []interface{}:
if len(x) == 0 {
return ""
}
return stringifyArg(x[0])
case []string:
if len(x) == 0 {
return ""
}
return x[0]
default:
return strings.TrimSpace(fmt.Sprint(x))
}
}
func jsonString(m map[string]interface{}, key string) string {
if m == nil {
return ""
}
v, ok := m[key]
if !ok {
return ""
}
return stringifyArg(v)
}
func jsonInt(m map[string]interface{}, key string) int {
if m == nil {
return 0
}
v, ok := m[key]
if !ok {
return 0
}
switch x := v.(type) {
case float64:
return int(x)
case int:
return x
case int64:
return int(x)
case json.Number:
n, _ := x.Int64()
return int(n)
default:
return 0
}
}
func paren(s string) string {
if s == "" {
return ""
}
return "" + s + ""
}
func truncateRunes(s string, max int) string {
rs := []rune(strings.TrimSpace(s))
if max <= 0 || len(rs) <= max {
return string(rs)
}
return string(rs[:max]) + "…"
}