Version: 0.9.52.dev.260428
后端: 1. 工具结果结构化切流继续推进:schedule 读工具改为“父包 adapter + 子包 view builder”,`queue_pop_head` / `queue_skip_head` 脱离 legacy wrapper,`analyze_health` / `analyze_rhythm` 补齐 `schedule.analysis_result` 诊断卡片。 2. 非 schedule 工具补齐专属结果协议:`web_search` / `web_fetch`、`upsert_task_class`、`context_tools_add` / `context_tools_remove` 全部接入结构化 `ResultView`,注册表继续去 legacy wrapper,同时保持原始 `ObservationText` 供模型链路复用。 3. 工具展示细节继续收口:参数本地化补齐 `domain` / `packs` / `mode` / `all`,deliver 阶段补发段落分隔,避免 execute 与总结正文黏连。 前端: 4. `ToolCardRenderer` 升级为多协议通用渲染器,补齐 read / analysis / web / taskclass / context 卡片渲染、参数折叠区、未知协议兜底与操作明细展示。 5. `AssistantPanel` 修正 `tool_result` 结果回填与卡片布局宽度问题,并新增结构化卡片 fixture / mock 调试入口,便于整体验收。 仓库: 6. 更新工具结果结构化交接文档,补记第四批切流范围、当前切流点与后续收尾建议。
This commit is contained in:
432
backend/newAgent/tools/web_result/common.go
Normal file
432
backend/newAgent/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())
|
||||
}
|
||||
232
backend/newAgent/tools/web_result/fetch.go
Normal file
232
backend/newAgent/tools/web_result/fetch.go
Normal file
@@ -0,0 +1,232 @@
|
||||
package web_result
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
type fetchObservation struct {
|
||||
Tool string `json:"tool"`
|
||||
URL string `json:"url"`
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
Truncated bool `json:"truncated"`
|
||||
Error string `json:"error"`
|
||||
Err string `json:"err"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
// BuildFetchView 负责把 web_fetch observation 构造成前端可直接消费的结果卡片。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 负责解析成功 / 失败 / provider 未启用 / 非 JSON 回退四类场景。
|
||||
// 2. 负责保留 raw_text 与 machine_payload,便于前端调试与后续交互。
|
||||
// 3. 不负责真正抓取网页,也不改写传入 observation 原文。
|
||||
func BuildFetchView(input FetchViewInput) ResultView {
|
||||
payloadMap, ok := parseObservationJSON(input.Observation)
|
||||
if !ok {
|
||||
return buildFetchTextFallbackView(input)
|
||||
}
|
||||
|
||||
payload := fetchObservation{}
|
||||
if err := json.Unmarshal([]byte(strings.TrimSpace(input.Observation)), &payload); err != nil {
|
||||
return buildFetchTextFallbackView(input)
|
||||
}
|
||||
|
||||
rawURL := strings.TrimSpace(payload.URL)
|
||||
if rawURL == "" {
|
||||
rawURL = strings.TrimSpace(input.URL)
|
||||
}
|
||||
|
||||
errorMessage := firstNonEmpty(payload.Error, payload.Err, payload.Reason)
|
||||
if errorMessage == "" {
|
||||
errorMessage = firstString(payloadMap, "message")
|
||||
}
|
||||
if errorMessage != "" {
|
||||
return buildFetchFailureView(input, rawURL, errorMessage, payloadMap)
|
||||
}
|
||||
|
||||
title := strings.TrimSpace(payload.Title)
|
||||
content := strings.TrimSpace(payload.Content)
|
||||
host := hostnameFromURL(rawURL)
|
||||
contentChars := utf8.RuneCountInString(content)
|
||||
if title == "" {
|
||||
title = "网页正文"
|
||||
if host != "" {
|
||||
title = host
|
||||
}
|
||||
}
|
||||
|
||||
itemTags := make([]string, 0, 2)
|
||||
if host != "" {
|
||||
itemTags = append(itemTags, host)
|
||||
}
|
||||
if payload.Truncated {
|
||||
itemTags = append(itemTags, "已截断")
|
||||
}
|
||||
|
||||
items := []ItemView{
|
||||
BuildItem(
|
||||
title,
|
||||
rawURL,
|
||||
itemTags,
|
||||
buildFetchPreviewLines(content),
|
||||
map[string]any{
|
||||
"url": rawURL,
|
||||
"title": strings.TrimSpace(payload.Title),
|
||||
"content_len": contentChars,
|
||||
"truncated": payload.Truncated,
|
||||
},
|
||||
),
|
||||
}
|
||||
|
||||
sections := []map[string]any{
|
||||
BuildKVSection("页面信息", []KVField{
|
||||
BuildKVField("链接", rawURL),
|
||||
BuildKVField("标题", fallbackText(payload.Title, "未提取到标题")),
|
||||
BuildKVField("正文长度", fmt.Sprintf("%d 字", contentChars)),
|
||||
BuildKVField("是否截断", formatBoolCN(payload.Truncated)),
|
||||
}),
|
||||
BuildCalloutSection(
|
||||
"正文预览",
|
||||
previewText(content, 120),
|
||||
"info",
|
||||
buildFetchPreviewLines(content),
|
||||
),
|
||||
}
|
||||
appendSectionIfPresent(§ions, BuildArgsSection("抓取参数", buildFetchArgFields(input)))
|
||||
|
||||
return BuildResultView(BuildResultViewInput{
|
||||
ViewType: ViewTypeFetchResult,
|
||||
Status: StatusDone,
|
||||
Title: buildFetchTitle(title),
|
||||
Subtitle: buildFetchSubtitle(rawURL, host),
|
||||
Metrics: buildFetchMetrics(contentChars, payload.Truncated, host),
|
||||
Items: items,
|
||||
Sections: sections,
|
||||
Observation: input.Observation,
|
||||
MachinePayload: payloadMap,
|
||||
})
|
||||
}
|
||||
|
||||
func buildFetchTextFallbackView(input FetchViewInput) ResultView {
|
||||
subtitle := "抓取结果不是合法 JSON,已回退为文本预览。"
|
||||
if strings.TrimSpace(input.Observation) == "" {
|
||||
subtitle = "抓取工具没有返回结构化结果,已回退为文本预览。"
|
||||
}
|
||||
|
||||
sections := []map[string]any{
|
||||
BuildCalloutSection("结果不可解析", subtitle, "danger", []string{subtitle}),
|
||||
}
|
||||
appendSectionIfPresent(§ions, BuildArgsSection("抓取参数", buildFetchArgFields(input)))
|
||||
appendSectionIfPresent(§ions, buildRawPreviewSection(input.Observation))
|
||||
|
||||
return BuildResultView(BuildResultViewInput{
|
||||
ViewType: ViewTypeFetchResult,
|
||||
Status: StatusFailed,
|
||||
Title: "网页抓取结果不可解析",
|
||||
Subtitle: subtitle,
|
||||
Metrics: buildFetchMetrics(0, false, hostnameFromURL(input.URL)),
|
||||
Items: make([]ItemView, 0),
|
||||
Sections: sections,
|
||||
Observation: input.Observation,
|
||||
})
|
||||
}
|
||||
|
||||
func buildFetchFailureView(
|
||||
input FetchViewInput,
|
||||
rawURL string,
|
||||
errorMessage string,
|
||||
payloadMap map[string]any,
|
||||
) ResultView {
|
||||
status := classifyUnavailableStatus(errorMessage)
|
||||
title := "网页抓取失败"
|
||||
calloutTitle := "抓取执行失败"
|
||||
tone := "danger"
|
||||
if status == StatusBlocked {
|
||||
title = "网页抓取未启用"
|
||||
calloutTitle = "抓取服务未启用"
|
||||
tone = "warning"
|
||||
}
|
||||
|
||||
sections := []map[string]any{
|
||||
BuildCalloutSection(calloutTitle, errorMessage, tone, []string{errorMessage}),
|
||||
}
|
||||
appendSectionIfPresent(§ions, BuildArgsSection("抓取参数", buildFetchArgFields(input)))
|
||||
|
||||
return BuildResultView(BuildResultViewInput{
|
||||
ViewType: ViewTypeFetchResult,
|
||||
Status: status,
|
||||
Title: title,
|
||||
Subtitle: buildFetchFailureSubtitle(rawURL, errorMessage),
|
||||
Metrics: buildFetchMetrics(0, false, hostnameFromURL(rawURL)),
|
||||
Items: make([]ItemView, 0),
|
||||
Sections: sections,
|
||||
Observation: input.Observation,
|
||||
MachinePayload: payloadMap,
|
||||
})
|
||||
}
|
||||
|
||||
func buildFetchMetrics(contentChars int, truncated bool, host string) []MetricField {
|
||||
metrics := []MetricField{
|
||||
BuildMetric("正文长度", fmt.Sprintf("%d 字", contentChars)),
|
||||
BuildMetric("是否截断", formatBoolCN(truncated)),
|
||||
}
|
||||
if strings.TrimSpace(host) != "" {
|
||||
metrics = append(metrics, BuildMetric("来源", host))
|
||||
}
|
||||
return metrics
|
||||
}
|
||||
|
||||
func buildFetchArgFields(input FetchViewInput) []KVField {
|
||||
fields := make([]KVField, 0, 2)
|
||||
if rawURL := strings.TrimSpace(input.URL); rawURL != "" {
|
||||
fields = append(fields, BuildKVField("链接", rawURL))
|
||||
}
|
||||
if input.MaxChars > 0 {
|
||||
fields = append(fields, BuildKVField("截断上限", fmt.Sprintf("%d 字", input.MaxChars)))
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
func buildFetchPreviewLines(content string) []string {
|
||||
lines := previewLines(content, 3, 120)
|
||||
if len(lines) > 0 {
|
||||
return lines
|
||||
}
|
||||
return []string{"正文为空"}
|
||||
}
|
||||
|
||||
func buildFetchTitle(title string) string {
|
||||
title = strings.TrimSpace(title)
|
||||
if title == "" {
|
||||
return "已抓取网页正文"
|
||||
}
|
||||
return fmt.Sprintf("已抓取:%s", previewText(title, 36))
|
||||
}
|
||||
|
||||
func buildFetchSubtitle(rawURL string, host string) string {
|
||||
if strings.TrimSpace(host) != "" {
|
||||
return fmt.Sprintf("来源:%s", host)
|
||||
}
|
||||
if strings.TrimSpace(rawURL) != "" {
|
||||
return fmt.Sprintf("来源:%s", previewText(rawURL, 48))
|
||||
}
|
||||
return "已返回网页正文。"
|
||||
}
|
||||
|
||||
func buildFetchFailureSubtitle(rawURL string, errorMessage string) string {
|
||||
if strings.TrimSpace(rawURL) == "" {
|
||||
return strings.TrimSpace(errorMessage)
|
||||
}
|
||||
return fmt.Sprintf("链接:%s", previewText(rawURL, 48))
|
||||
}
|
||||
|
||||
func fallbackText(text string, fallback string) string {
|
||||
if strings.TrimSpace(text) == "" {
|
||||
return strings.TrimSpace(fallback)
|
||||
}
|
||||
return strings.TrimSpace(text)
|
||||
}
|
||||
241
backend/newAgent/tools/web_result/search.go
Normal file
241
backend/newAgent/tools/web_result/search.go
Normal file
@@ -0,0 +1,241 @@
|
||||
package web_result
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type searchObservation struct {
|
||||
Tool string `json:"tool"`
|
||||
Query string `json:"query"`
|
||||
Count int `json:"count"`
|
||||
Items []searchObservationItem `json:"items"`
|
||||
Error string `json:"error"`
|
||||
Err string `json:"err"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
type searchObservationItem struct {
|
||||
Title string `json:"title"`
|
||||
URL string `json:"url"`
|
||||
Snippet string `json:"snippet"`
|
||||
Domain string `json:"domain"`
|
||||
PublishedAt string `json:"published_at"`
|
||||
}
|
||||
|
||||
// BuildSearchView 负责把 web_search observation 构造成前端可直接消费的结果卡片。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 负责解析成功 / 失败 / provider 未启用 / 非 JSON 回退四类场景。
|
||||
// 2. 负责保留 raw_text 与 machine_payload,方便前端调试与后续交互。
|
||||
// 3. 不负责执行搜索,也不改写传入 observation 原文。
|
||||
func BuildSearchView(input SearchViewInput) ResultView {
|
||||
payloadMap, ok := parseObservationJSON(input.Observation)
|
||||
if !ok {
|
||||
return buildSearchTextFallbackView(input)
|
||||
}
|
||||
|
||||
payload := searchObservation{}
|
||||
if err := json.Unmarshal([]byte(strings.TrimSpace(input.Observation)), &payload); err != nil {
|
||||
return buildSearchTextFallbackView(input)
|
||||
}
|
||||
|
||||
query := strings.TrimSpace(payload.Query)
|
||||
if query == "" {
|
||||
query = strings.TrimSpace(input.Query)
|
||||
}
|
||||
|
||||
errorMessage := firstNonEmpty(payload.Error, payload.Err, payload.Reason)
|
||||
if errorMessage == "" {
|
||||
errorMessage = firstString(payloadMap, "message")
|
||||
}
|
||||
if errorMessage != "" {
|
||||
return buildSearchFailureView(input, query, errorMessage, payloadMap)
|
||||
}
|
||||
|
||||
items := buildSearchItems(payload.Items)
|
||||
count := payload.Count
|
||||
if count < len(items) {
|
||||
count = len(items)
|
||||
}
|
||||
|
||||
title := fmt.Sprintf("找到 %d 条网页结果", count)
|
||||
subtitle := buildSearchSubtitle(query)
|
||||
sections := make([]map[string]any, 0, 3)
|
||||
appendSectionIfPresent(§ions, BuildArgsSection("搜索参数", buildSearchArgFields(input)))
|
||||
if len(items) > 0 {
|
||||
sections = append(sections, BuildItemsSection("搜索结果", items))
|
||||
} else {
|
||||
sections = append(sections, BuildCalloutSection(
|
||||
"没有命中结果",
|
||||
"当前关键词没有返回可展示结果,可以尝试缩短关键词或放宽筛选条件。",
|
||||
"info",
|
||||
[]string{"建议优先调整关键词,再决定是否继续抓取具体页面。"},
|
||||
))
|
||||
}
|
||||
|
||||
return BuildResultView(BuildResultViewInput{
|
||||
ViewType: ViewTypeSearchResult,
|
||||
Status: StatusDone,
|
||||
Title: title,
|
||||
Subtitle: subtitle,
|
||||
Metrics: buildSearchMetrics(count, input.DomainAllow, input.RecencyDays),
|
||||
Items: items,
|
||||
Sections: sections,
|
||||
Observation: input.Observation,
|
||||
MachinePayload: payloadMap,
|
||||
})
|
||||
}
|
||||
|
||||
func buildSearchTextFallbackView(input SearchViewInput) ResultView {
|
||||
subtitle := "搜索结果不是合法 JSON,已回退为文本预览。"
|
||||
if strings.TrimSpace(input.Observation) == "" {
|
||||
subtitle = "搜索工具没有返回结构化结果,已回退为文本预览。"
|
||||
}
|
||||
|
||||
sections := []map[string]any{
|
||||
BuildCalloutSection("结果不可解析", subtitle, "danger", []string{subtitle}),
|
||||
}
|
||||
appendSectionIfPresent(§ions, BuildArgsSection("搜索参数", buildSearchArgFields(input)))
|
||||
appendSectionIfPresent(§ions, buildRawPreviewSection(input.Observation))
|
||||
|
||||
return BuildResultView(BuildResultViewInput{
|
||||
ViewType: ViewTypeSearchResult,
|
||||
Status: StatusFailed,
|
||||
Title: "网页搜索结果不可解析",
|
||||
Subtitle: subtitle,
|
||||
Metrics: buildSearchMetrics(0, input.DomainAllow, input.RecencyDays),
|
||||
Items: make([]ItemView, 0),
|
||||
Sections: sections,
|
||||
Observation: input.Observation,
|
||||
})
|
||||
}
|
||||
|
||||
func buildSearchFailureView(
|
||||
input SearchViewInput,
|
||||
query string,
|
||||
errorMessage string,
|
||||
payloadMap map[string]any,
|
||||
) ResultView {
|
||||
status := classifyUnavailableStatus(errorMessage)
|
||||
title := "网页搜索失败"
|
||||
calloutTitle := "搜索执行失败"
|
||||
tone := "danger"
|
||||
if status == StatusBlocked {
|
||||
title = "网页搜索未启用"
|
||||
calloutTitle = "搜索 Provider 未启用"
|
||||
tone = "warning"
|
||||
}
|
||||
|
||||
sections := []map[string]any{
|
||||
BuildCalloutSection(calloutTitle, errorMessage, tone, []string{errorMessage}),
|
||||
}
|
||||
appendSectionIfPresent(§ions, BuildArgsSection("搜索参数", buildSearchArgFields(input)))
|
||||
|
||||
return BuildResultView(BuildResultViewInput{
|
||||
ViewType: ViewTypeSearchResult,
|
||||
Status: status,
|
||||
Title: title,
|
||||
Subtitle: buildSearchFailureSubtitle(query, errorMessage),
|
||||
Metrics: buildSearchMetrics(0, input.DomainAllow, input.RecencyDays),
|
||||
Items: make([]ItemView, 0),
|
||||
Sections: sections,
|
||||
Observation: input.Observation,
|
||||
MachinePayload: payloadMap,
|
||||
})
|
||||
}
|
||||
|
||||
func buildSearchItems(items []searchObservationItem) []ItemView {
|
||||
if len(items) == 0 {
|
||||
return make([]ItemView, 0)
|
||||
}
|
||||
out := make([]ItemView, 0, len(items))
|
||||
for index, item := range items {
|
||||
title := strings.TrimSpace(item.Title)
|
||||
if title == "" {
|
||||
title = fmt.Sprintf("结果 %d", index+1)
|
||||
}
|
||||
|
||||
subtitle := strings.TrimSpace(item.URL)
|
||||
if domain := strings.TrimSpace(item.Domain); domain != "" {
|
||||
subtitle = domain
|
||||
}
|
||||
|
||||
tags := make([]string, 0, 2)
|
||||
if domain := strings.TrimSpace(item.Domain); domain != "" {
|
||||
tags = append(tags, domain)
|
||||
}
|
||||
if publishedAt := strings.TrimSpace(item.PublishedAt); publishedAt != "" {
|
||||
tags = append(tags, publishedAt)
|
||||
}
|
||||
|
||||
detailLines := make([]string, 0, 2)
|
||||
if snippet := strings.TrimSpace(item.Snippet); snippet != "" {
|
||||
detailLines = append(detailLines, previewText(snippet, 120))
|
||||
}
|
||||
if rawURL := strings.TrimSpace(item.URL); rawURL != "" {
|
||||
detailLines = append(detailLines, rawURL)
|
||||
}
|
||||
|
||||
out = append(out, BuildItem(title, subtitle, tags, detailLines, map[string]any{
|
||||
"url": strings.TrimSpace(item.URL),
|
||||
"domain": strings.TrimSpace(item.Domain),
|
||||
"published_at": strings.TrimSpace(item.PublishedAt),
|
||||
}))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func buildSearchMetrics(count int, domainAllow []string, recencyDays int) []MetricField {
|
||||
metrics := []MetricField{
|
||||
BuildMetric("结果数", fmt.Sprintf("%d", count)),
|
||||
}
|
||||
if len(domainAllow) > 0 {
|
||||
metrics = append(metrics, BuildMetric("域名过滤", formatStringSliceCN(domainAllow, 2)))
|
||||
}
|
||||
if recencyDays > 0 {
|
||||
metrics = append(metrics, BuildMetric("时效", fmt.Sprintf("近 %d 天", recencyDays)))
|
||||
}
|
||||
return metrics
|
||||
}
|
||||
|
||||
func buildSearchArgFields(input SearchViewInput) []KVField {
|
||||
fields := make([]KVField, 0, 4)
|
||||
if query := strings.TrimSpace(input.Query); query != "" {
|
||||
fields = append(fields, BuildKVField("关键词", query))
|
||||
}
|
||||
if input.TopK > 0 {
|
||||
fields = append(fields, BuildKVField("结果上限", fmt.Sprintf("%d", input.TopK)))
|
||||
}
|
||||
if len(input.DomainAllow) > 0 {
|
||||
fields = append(fields, BuildKVField("域名过滤", formatStringSliceCN(input.DomainAllow, 4)))
|
||||
}
|
||||
if input.RecencyDays > 0 {
|
||||
fields = append(fields, BuildKVField("时效范围", fmt.Sprintf("近 %d 天", input.RecencyDays)))
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
func buildSearchSubtitle(query string) string {
|
||||
if strings.TrimSpace(query) == "" {
|
||||
return "已返回网页搜索结果。"
|
||||
}
|
||||
return fmt.Sprintf("关键词:%s", previewText(query, 40))
|
||||
}
|
||||
|
||||
func buildSearchFailureSubtitle(query string, errorMessage string) string {
|
||||
if strings.TrimSpace(query) == "" {
|
||||
return strings.TrimSpace(errorMessage)
|
||||
}
|
||||
return fmt.Sprintf("关键词:%s", previewText(query, 40))
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
169
backend/newAgent/tools/web_result/types.go
Normal file
169
backend/newAgent/tools/web_result/types.go
Normal file
@@ -0,0 +1,169 @@
|
||||
package web_result
|
||||
|
||||
import "strings"
|
||||
|
||||
const (
|
||||
// ViewTypeSearchResult 是 web_search 结果卡片的前端识别类型。
|
||||
ViewTypeSearchResult = "web.search_result"
|
||||
|
||||
// ViewTypeFetchResult 是 web_fetch 结果卡片的前端识别类型。
|
||||
ViewTypeFetchResult = "web.fetch_result"
|
||||
|
||||
// ViewVersionResult 固定为当前 web 结果卡片结构版本。
|
||||
ViewVersionResult = 1
|
||||
|
||||
// 这里不依赖父包状态常量,避免子包反向 import tools 形成循环依赖。
|
||||
StatusDone = "done"
|
||||
StatusFailed = "failed"
|
||||
StatusBlocked = "blocked"
|
||||
)
|
||||
|
||||
// ResultView 是子包暴露给父包 adapter 的纯展示结构。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 负责承载 view_type / version / collapsed / expanded 四段展示数据。
|
||||
// 2. 不负责 ToolExecutionResult、SSE、registry 等父包协议。
|
||||
// 3. collapsed / expanded 保持 map 形态,便于父包直接桥接现有展示协议。
|
||||
type ResultView struct {
|
||||
ViewType string `json:"view_type"`
|
||||
Version int `json:"version"`
|
||||
Collapsed map[string]any `json:"collapsed"`
|
||||
Expanded map[string]any `json:"expanded"`
|
||||
}
|
||||
|
||||
// CollapsedView 表示卡片折叠态数据。
|
||||
type CollapsedView struct {
|
||||
Title string `json:"title"`
|
||||
Subtitle string `json:"subtitle"`
|
||||
Status string `json:"status"`
|
||||
StatusLabel string `json:"status_label"`
|
||||
Metrics []MetricField `json:"metrics"`
|
||||
}
|
||||
|
||||
// ExpandedView 表示卡片展开态数据。
|
||||
type ExpandedView struct {
|
||||
Items []ItemView `json:"items"`
|
||||
Sections []map[string]any `json:"sections"`
|
||||
RawText string `json:"raw_text"`
|
||||
MachinePayload map[string]any `json:"machine_payload"`
|
||||
}
|
||||
|
||||
// MetricField 是 collapsed.metrics 的轻量键值结构。
|
||||
type MetricField struct {
|
||||
Label string `json:"label"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
// KVField 是 section.type=kv 的轻量键值结构。
|
||||
type KVField struct {
|
||||
Label string `json:"label"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
// ItemView 是 expanded.items / section.items 的通用结构。
|
||||
type ItemView struct {
|
||||
Title string `json:"title"`
|
||||
Subtitle string `json:"subtitle"`
|
||||
Tags []string `json:"tags"`
|
||||
DetailLines []string `json:"detail_lines"`
|
||||
Meta map[string]any `json:"meta,omitempty"`
|
||||
}
|
||||
|
||||
// BuildResultViewInput 是通用 web 结果视图 builder 的输入。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 负责承载已经计算好的标题、副标题、指标、列表、分区。
|
||||
// 2. 不负责执行 web 工具;observation 必须由父包 adapter 传入。
|
||||
// 3. observation 会原样写入 raw_text,不能在这里改写给模型的观察文本。
|
||||
type BuildResultViewInput struct {
|
||||
ViewType string
|
||||
Status string
|
||||
Title string
|
||||
Subtitle string
|
||||
Metrics []MetricField
|
||||
Items []ItemView
|
||||
Sections []map[string]any
|
||||
Observation string
|
||||
MachinePayload map[string]any
|
||||
}
|
||||
|
||||
// SearchViewInput 是 web_search 视图构造输入。
|
||||
type SearchViewInput struct {
|
||||
Observation string
|
||||
Query string
|
||||
TopK int
|
||||
DomainAllow []string
|
||||
RecencyDays int
|
||||
}
|
||||
|
||||
// FetchViewInput 是 web_fetch 视图构造输入。
|
||||
type FetchViewInput struct {
|
||||
Observation string
|
||||
URL string
|
||||
MaxChars int
|
||||
}
|
||||
|
||||
func (view CollapsedView) Map() map[string]any {
|
||||
metrics := make([]map[string]any, 0, len(view.Metrics))
|
||||
for _, metric := range view.Metrics {
|
||||
label := strings.TrimSpace(metric.Label)
|
||||
value := strings.TrimSpace(metric.Value)
|
||||
if label == "" || value == "" {
|
||||
continue
|
||||
}
|
||||
metrics = append(metrics, map[string]any{
|
||||
"label": label,
|
||||
"value": value,
|
||||
})
|
||||
}
|
||||
if len(metrics) == 0 {
|
||||
metrics = make([]map[string]any, 0)
|
||||
}
|
||||
return map[string]any{
|
||||
"title": strings.TrimSpace(view.Title),
|
||||
"subtitle": strings.TrimSpace(view.Subtitle),
|
||||
"status": normalizeStatus(view.Status),
|
||||
"status_label": strings.TrimSpace(view.StatusLabel),
|
||||
"metrics": metrics,
|
||||
}
|
||||
}
|
||||
|
||||
func (view ExpandedView) Map() map[string]any {
|
||||
items := make([]map[string]any, 0, len(view.Items))
|
||||
for _, item := range view.Items {
|
||||
items = append(items, item.Map())
|
||||
}
|
||||
if len(items) == 0 {
|
||||
items = make([]map[string]any, 0)
|
||||
}
|
||||
|
||||
sections := cloneSectionList(view.Sections)
|
||||
if len(sections) == 0 {
|
||||
sections = make([]map[string]any, 0)
|
||||
}
|
||||
|
||||
machinePayload := cloneAnyMap(view.MachinePayload)
|
||||
if machinePayload == nil {
|
||||
machinePayload = make(map[string]any)
|
||||
}
|
||||
|
||||
return map[string]any{
|
||||
"items": items,
|
||||
"sections": sections,
|
||||
"raw_text": view.RawText,
|
||||
"machine_payload": machinePayload,
|
||||
}
|
||||
}
|
||||
|
||||
func (view ItemView) Map() map[string]any {
|
||||
out := map[string]any{
|
||||
"title": strings.TrimSpace(view.Title),
|
||||
"subtitle": strings.TrimSpace(view.Subtitle),
|
||||
"tags": normalizeStringSlice(view.Tags),
|
||||
"detail_lines": normalizeStringSlice(view.DetailLines),
|
||||
}
|
||||
if len(view.Meta) > 0 {
|
||||
out["meta"] = cloneAnyMap(view.Meta)
|
||||
}
|
||||
return out
|
||||
}
|
||||
Reference in New Issue
Block a user