Files
smartmate/backend/newAgent/tools/web_result/search.go
Losita d89e2830a9 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. 更新工具结果结构化交接文档,补记第四批切流范围、当前切流点与后续收尾建议。
2026-04-28 20:22:22 +08:00

242 lines
7.5 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.
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(&sections, 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(&sections, BuildArgsSection("搜索参数", buildSearchArgFields(input)))
appendSectionIfPresent(&sections, 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(&sections, 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 ""
}