Files
smartmate/backend/newAgent/tools/web_result/fetch.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

233 lines
6.7 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"
"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(&sections, 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(&sections, BuildArgsSection("抓取参数", buildFetchArgFields(input)))
appendSectionIfPresent(&sections, 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(&sections, 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)
}