Version: 0.9.75.dev.260505
后端: 1.收口阶段 6 agent 结构迁移,将 newAgent 内核与 agentsvc 编排层迁入 services/agent - 切换 Agent 启动装配与 HTTP handler 直连 agent sv,移除旧 service agent bridge - 补齐 Agent 对 memory、task、task-class、schedule 的 RPC 适配与契约字段 - 扩展 schedule、task、task-class RPC/contract 支撑 Agent 查询、写入与 provider 切流 - 更新迁移文档、README 与相关注释,明确 agent 当前切流点和剩余 memory 迁移面
This commit is contained in:
432
backend/services/agent/tools/web_result/common.go
Normal file
432
backend/services/agent/tools/web_result/common.go
Normal file
@@ -0,0 +1,432 @@
|
||||
package web_result
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 设计说明:
|
||||
// 1. 本轮只处理 web 工具卡片,按 AGENTS.md 的迁移约束避免同一轮跨多个能力域抽公共 toolview 层。
|
||||
// 2. 因此这里先在 web_result 包内保留最小公共 helper,保证 web_search / web_fetch 先完成切流。
|
||||
// 3. 若后续 taskclass / context 也出现同类卡片 helper,再由主代理统一评估是否下沉成公共层。
|
||||
|
||||
// BuildResultView 统一封装 web 结果卡片结构。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 负责把已经计算好的折叠态、展开态内容组装成标准视图。
|
||||
// 2. 负责在子包内补齐 status / status_label,避免依赖父包状态常量。
|
||||
// 3. 不负责 ToolExecutionResult 外层协议,也不改写 observation 原文。
|
||||
func BuildResultView(input BuildResultViewInput) ResultView {
|
||||
status := normalizeStatus(input.Status)
|
||||
if status == "" {
|
||||
status = StatusDone
|
||||
}
|
||||
|
||||
collapsed := CollapsedView{
|
||||
Title: input.Title,
|
||||
Subtitle: input.Subtitle,
|
||||
Status: status,
|
||||
StatusLabel: resolveStatusLabelCN(status),
|
||||
Metrics: appendMetricCopy(input.Metrics),
|
||||
}
|
||||
expanded := ExpandedView{
|
||||
Items: appendItemCopy(input.Items),
|
||||
Sections: cloneSectionList(input.Sections),
|
||||
RawText: input.Observation,
|
||||
MachinePayload: cloneAnyMap(input.MachinePayload),
|
||||
}
|
||||
|
||||
return ResultView{
|
||||
ViewType: normalizeViewType(input.ViewType),
|
||||
Version: ViewVersionResult,
|
||||
Collapsed: collapsed.Map(),
|
||||
Expanded: expanded.Map(),
|
||||
}
|
||||
}
|
||||
|
||||
func BuildMetric(label string, value string) MetricField {
|
||||
return MetricField{
|
||||
Label: strings.TrimSpace(label),
|
||||
Value: strings.TrimSpace(value),
|
||||
}
|
||||
}
|
||||
|
||||
func BuildKVField(label string, value string) KVField {
|
||||
return KVField{
|
||||
Label: strings.TrimSpace(label),
|
||||
Value: strings.TrimSpace(value),
|
||||
}
|
||||
}
|
||||
|
||||
func BuildItem(title string, subtitle string, tags []string, detailLines []string, meta map[string]any) ItemView {
|
||||
return ItemView{
|
||||
Title: strings.TrimSpace(title),
|
||||
Subtitle: strings.TrimSpace(subtitle),
|
||||
Tags: normalizeStringSlice(tags),
|
||||
DetailLines: normalizeStringSlice(detailLines),
|
||||
Meta: cloneAnyMap(meta),
|
||||
}
|
||||
}
|
||||
|
||||
func BuildKVSection(title string, fields []KVField) map[string]any {
|
||||
rows := make([]map[string]any, 0, len(fields))
|
||||
for _, field := range fields {
|
||||
label := strings.TrimSpace(field.Label)
|
||||
value := strings.TrimSpace(field.Value)
|
||||
if label == "" || value == "" {
|
||||
continue
|
||||
}
|
||||
rows = append(rows, map[string]any{
|
||||
"label": label,
|
||||
"value": value,
|
||||
})
|
||||
}
|
||||
return map[string]any{
|
||||
"type": "kv",
|
||||
"title": strings.TrimSpace(title),
|
||||
"fields": rows,
|
||||
}
|
||||
}
|
||||
|
||||
func BuildItemsSection(title string, items []ItemView) map[string]any {
|
||||
rows := make([]map[string]any, 0, len(items))
|
||||
for _, item := range items {
|
||||
rows = append(rows, item.Map())
|
||||
}
|
||||
return map[string]any{
|
||||
"type": "items",
|
||||
"title": strings.TrimSpace(title),
|
||||
"items": rows,
|
||||
}
|
||||
}
|
||||
|
||||
func BuildCalloutSection(title string, subtitle string, tone string, detailLines []string) map[string]any {
|
||||
return map[string]any{
|
||||
"type": "callout",
|
||||
"title": strings.TrimSpace(title),
|
||||
"subtitle": strings.TrimSpace(subtitle),
|
||||
"tone": strings.TrimSpace(tone),
|
||||
"detail_lines": normalizeStringSlice(detailLines),
|
||||
}
|
||||
}
|
||||
|
||||
func BuildArgsSection(title string, fields []KVField) map[string]any {
|
||||
if len(fields) == 0 {
|
||||
return nil
|
||||
}
|
||||
valid := make([]KVField, 0, len(fields))
|
||||
for _, field := range fields {
|
||||
label := strings.TrimSpace(field.Label)
|
||||
value := strings.TrimSpace(field.Value)
|
||||
if label == "" || value == "" {
|
||||
continue
|
||||
}
|
||||
valid = append(valid, BuildKVField(label, value))
|
||||
}
|
||||
if len(valid) == 0 {
|
||||
return nil
|
||||
}
|
||||
return BuildKVSection(title, valid)
|
||||
}
|
||||
|
||||
func appendSectionIfPresent(target *[]map[string]any, section map[string]any) {
|
||||
if section == nil {
|
||||
return
|
||||
}
|
||||
*target = append(*target, section)
|
||||
}
|
||||
|
||||
func appendMetricCopy(metrics []MetricField) []MetricField {
|
||||
if len(metrics) == 0 {
|
||||
return make([]MetricField, 0)
|
||||
}
|
||||
out := make([]MetricField, 0, len(metrics))
|
||||
for _, metric := range metrics {
|
||||
label := strings.TrimSpace(metric.Label)
|
||||
value := strings.TrimSpace(metric.Value)
|
||||
if label == "" || value == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, MetricField{Label: label, Value: value})
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return make([]MetricField, 0)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func appendItemCopy(items []ItemView) []ItemView {
|
||||
if len(items) == 0 {
|
||||
return make([]ItemView, 0)
|
||||
}
|
||||
out := make([]ItemView, 0, len(items))
|
||||
for _, item := range items {
|
||||
out = append(out, BuildItem(item.Title, item.Subtitle, item.Tags, item.DetailLines, item.Meta))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func normalizeViewType(viewType string) string {
|
||||
switch strings.TrimSpace(viewType) {
|
||||
case ViewTypeFetchResult:
|
||||
return ViewTypeFetchResult
|
||||
case ViewTypeSearchResult:
|
||||
return ViewTypeSearchResult
|
||||
default:
|
||||
return ViewTypeSearchResult
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeStatus(status string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(status)) {
|
||||
case StatusDone:
|
||||
return StatusDone
|
||||
case StatusBlocked:
|
||||
return StatusBlocked
|
||||
case StatusFailed:
|
||||
return StatusFailed
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func resolveStatusLabelCN(status string) string {
|
||||
switch normalizeStatus(status) {
|
||||
case StatusDone:
|
||||
return "已完成"
|
||||
case StatusBlocked:
|
||||
return "已阻断"
|
||||
default:
|
||||
return "失败"
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeStringSlice(values []string) []string {
|
||||
if len(values) == 0 {
|
||||
return make([]string, 0)
|
||||
}
|
||||
out := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
text := strings.TrimSpace(value)
|
||||
if text == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, text)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return make([]string, 0)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func parseObservationJSON(observation string) (map[string]any, bool) {
|
||||
trimmed := strings.TrimSpace(observation)
|
||||
if trimmed == "" || !strings.HasPrefix(trimmed, "{") {
|
||||
return nil, false
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal([]byte(trimmed), &payload); err != nil {
|
||||
return nil, false
|
||||
}
|
||||
return payload, true
|
||||
}
|
||||
|
||||
func cloneSectionList(sections []map[string]any) []map[string]any {
|
||||
if len(sections) == 0 {
|
||||
return make([]map[string]any, 0)
|
||||
}
|
||||
out := make([]map[string]any, 0, len(sections))
|
||||
for _, section := range sections {
|
||||
out = append(out, cloneAnyMap(section))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneAnyMap(input map[string]any) map[string]any {
|
||||
if len(input) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]any, len(input))
|
||||
for key, value := range input {
|
||||
out[key] = cloneAnyValue(value)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneAnyValue(value any) any {
|
||||
switch typed := value.(type) {
|
||||
case map[string]any:
|
||||
return cloneAnyMap(typed)
|
||||
case []map[string]any:
|
||||
out := make([]map[string]any, 0, len(typed))
|
||||
for _, item := range typed {
|
||||
out = append(out, cloneAnyMap(item))
|
||||
}
|
||||
return out
|
||||
case []any:
|
||||
out := make([]any, 0, len(typed))
|
||||
for _, item := range typed {
|
||||
out = append(out, cloneAnyValue(item))
|
||||
}
|
||||
return out
|
||||
case []string:
|
||||
out := make([]string, len(typed))
|
||||
copy(out, typed)
|
||||
return out
|
||||
default:
|
||||
return typed
|
||||
}
|
||||
}
|
||||
|
||||
func firstString(input map[string]any, keys ...string) string {
|
||||
for _, key := range keys {
|
||||
if value := readString(input, key); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func readString(input map[string]any, key string) string {
|
||||
if len(input) == 0 {
|
||||
return ""
|
||||
}
|
||||
value, exists := input[key]
|
||||
if !exists || value == nil {
|
||||
return ""
|
||||
}
|
||||
switch typed := value.(type) {
|
||||
case string:
|
||||
return strings.TrimSpace(typed)
|
||||
default:
|
||||
text := strings.TrimSpace(fmt.Sprintf("%v", typed))
|
||||
if text == "" || text == "<nil>" {
|
||||
return ""
|
||||
}
|
||||
return text
|
||||
}
|
||||
}
|
||||
|
||||
func readBool(input map[string]any, key string) (bool, bool) {
|
||||
if len(input) == 0 {
|
||||
return false, false
|
||||
}
|
||||
value, exists := input[key]
|
||||
if !exists {
|
||||
return false, false
|
||||
}
|
||||
typed, ok := value.(bool)
|
||||
return typed, ok
|
||||
}
|
||||
|
||||
func readInt(input map[string]any, key string) int {
|
||||
if len(input) == 0 {
|
||||
return 0
|
||||
}
|
||||
value, exists := input[key]
|
||||
if !exists || value == nil {
|
||||
return 0
|
||||
}
|
||||
switch typed := value.(type) {
|
||||
case int:
|
||||
return typed
|
||||
case int32:
|
||||
return int(typed)
|
||||
case int64:
|
||||
return int(typed)
|
||||
case float64:
|
||||
return int(typed)
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func previewText(text string, limit int) string {
|
||||
trimmed := strings.TrimSpace(text)
|
||||
if trimmed == "" {
|
||||
return ""
|
||||
}
|
||||
runes := []rune(trimmed)
|
||||
if limit <= 0 || len(runes) <= limit {
|
||||
return string(runes)
|
||||
}
|
||||
return string(runes[:limit]) + "..."
|
||||
}
|
||||
|
||||
func previewLines(text string, maxLines int, maxChars int) []string {
|
||||
trimmed := strings.TrimSpace(text)
|
||||
if trimmed == "" {
|
||||
return make([]string, 0)
|
||||
}
|
||||
|
||||
lines := strings.Split(trimmed, "\n")
|
||||
out := make([]string, 0, maxLines)
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, previewText(line, maxChars))
|
||||
if maxLines > 0 && len(out) >= maxLines {
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
out = append(out, previewText(trimmed, maxChars))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func formatStringSliceCN(items []string, limit int) string {
|
||||
normalized := normalizeStringSlice(items)
|
||||
if len(normalized) == 0 {
|
||||
return ""
|
||||
}
|
||||
if limit <= 0 || len(normalized) <= limit {
|
||||
return strings.Join(normalized, "、")
|
||||
}
|
||||
return fmt.Sprintf("%s 等 %d 个", strings.Join(normalized[:limit], "、"), len(normalized))
|
||||
}
|
||||
|
||||
func formatBoolCN(value bool) string {
|
||||
if value {
|
||||
return "是"
|
||||
}
|
||||
return "否"
|
||||
}
|
||||
|
||||
func classifyUnavailableStatus(message string) string {
|
||||
trimmed := strings.TrimSpace(message)
|
||||
lower := strings.ToLower(trimmed)
|
||||
switch {
|
||||
case strings.Contains(trimmed, "暂未启用"),
|
||||
strings.Contains(trimmed, "未启用"),
|
||||
strings.Contains(trimmed, "暂未初始化"),
|
||||
strings.Contains(trimmed, "未初始化"),
|
||||
strings.Contains(trimmed, "未配置"),
|
||||
strings.Contains(lower, "not enabled"),
|
||||
strings.Contains(lower, "not configured"),
|
||||
strings.Contains(lower, "unavailable"):
|
||||
return StatusBlocked
|
||||
default:
|
||||
return StatusFailed
|
||||
}
|
||||
}
|
||||
|
||||
func buildRawPreviewSection(rawText string) map[string]any {
|
||||
preview := previewText(rawText, 160)
|
||||
if preview == "" {
|
||||
return nil
|
||||
}
|
||||
return BuildCalloutSection("原始结果预览", preview, "info", previewLines(rawText, 3, 120))
|
||||
}
|
||||
|
||||
func hostnameFromURL(rawURL string) string {
|
||||
parsed, err := url.Parse(strings.TrimSpace(rawURL))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(parsed.Hostname())
|
||||
}
|
||||
Reference in New Issue
Block a user