后端: 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. 更新工具结果结构化交接文档,补记第四批切流范围、当前切流点与后续收尾建议。
242 lines
7.5 KiB
Go
242 lines
7.5 KiB
Go
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 ""
|
||
}
|