Files
eaiadminandClaude Code c1af86c934 feat(asr): 本地语音转写接入为一级路由 + 并行工作流合并提交
按用户指示做**一包提交**,不按工作流拆分。本提交刻意混合了多条并行线:

  · 本地 ASR 接管:audio 成为与 chat/embed/image/video 同等的路由类别
    (IsLocalRoute 单一判据、audio 健康探测、default_audio_route、
    auto 占位、GET /api/ai/routes/audio、回退云端时界面明示「音频已出网」)
  · LLM 调用层:ctx 贯穿、ToolCall/ToolSchema、EmptyCompletionError /
    TransientUpstreamError(按错误类型而非文案判重试)
  · 编排 Agent:general_assistant orchestrate/persistence/spec_driver
  · 联网搜索:internal/search(playwright)
  · 网盘:backend + 前端
  · 前端 UI:导航/路由/工作台若干页
  · 交付文档:DELIVERY.md / AR04 / 部署文档的「无 Python」表述据实改写,
    新增 eai_agentplatform-asr.service、asr.env、clonezilla-cleanup 清 ~/asr-poc

不分拆的原因:dev 早期,粒度不该打断工作节奏。且实测过——这些改动
**在编译上是同一个单元**(llm.go 的 ctx 签名变更牵动 12 个调用点,
chat_message.go 的 ctx 改动又与编排重写同处一个 hunk),拆出来的中间态编不过。
详见 TOP_CODING_RULES.md G14.5 与 bugs_and_errors.md E09。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-26 22:21:39 +08:00

125 lines
3.6 KiB
Go
Raw Permalink 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 search
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strings"
"time"
)
// SerpApiClient 调用付费的 SerpApi(https://serpapi.com)获取搜索结果。
//
// 背景:SearXNG / 搜狗 HTTP / 真实浏览器都会被百度和搜狗对数据中心 IP 的反爬拦截,
// 无法稳定取得按关键词匹配的真实结果。SerpApi 是商业搜索 API,服务端负责抓取
// (本机只连 serpapi.com,连通性好),返回结构化 JSON,无验证码,是「通用可搜」
// 闭环的稳定主源。
//
// 配置:API key 从环境变量 SERPAPI_API_KEY 读取。未配置 key 时 Search 返回
// (nil, errSkipSerp) 让上层回退到既有的 SearXNG→搜狗→浏览器链,保证无 key 也能跑。
//
// 设计说明:engine 用 google(SerpApi 默认),检索在 SerpApi 服务端完成,不受本机
// 出口 IP 反爬影响;hl=zh-cn 让中文结果更相关。
type SerpApiClient struct {
apiKey string
maxResults int
timeout time.Duration
hc *http.Client
}
// errSkipSerp 表示未配置 SerpApi key,调用方应跳过本源继续回退。
type skipSerpError struct{}
func (skipSerpError) Error() string { return "SerpApi 未配置 API key,跳过该源" }
// NewSerpApiClient 创建 SerpApi 客户端;key 从指定环境变量读取(空串时默认 SERPAPI_API_KEY)。
func NewSerpApiClient(apiKeyEnv ...string) *SerpApiClient {
envName := "SERPAPI_API_KEY"
if len(apiKeyEnv) > 0 && apiKeyEnv[0] != "" {
envName = apiKeyEnv[0]
}
c := &SerpApiClient{
apiKey: strings.TrimSpace(os.Getenv(envName)),
maxResults: 8,
timeout: 15 * time.Second,
}
c.hc = &http.Client{Timeout: c.timeout}
return c
}
// serpAPIURL SerpApi 搜索端点。
const serpAPIURL = "https://serpapi.com/search.json"
// serpAPIResponse SerpApi /search.json 的有机结果片段。
type serpAPIResponse struct {
OrganicResults []struct {
Title string `json:"title"`
Link string `json:"link"`
Snippet string `json:"snippet"`
} `json:"organic_results"`
Error string `json:"error"`
}
// Search 调用 SerpApi 搜索。未配置 key 时返回 skipSerpError。
func (c *SerpApiClient) Search(query string) ([]Result, error) {
if c.apiKey == "" {
return nil, skipSerpError{}
}
params := url.Values{}
params.Set("q", query)
params.Set("engine", "google")
params.Set("hl", "zh-cn")
params.Set("num", fmt.Sprintf("%d", c.maxResults))
params.Set("api_key", c.apiKey)
req, err := http.NewRequest(http.MethodGet, serpAPIURL+"?"+params.Encode(), nil)
if err != nil {
return nil, fmt.Errorf("构建 SerpApi 请求失败: %w", err)
}
req.Header.Set("Accept", "application/json")
resp, err := c.hc.Do(req)
if err != nil {
return nil, fmt.Errorf("SerpApi 不可达: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("读取 SerpApi 响应失败: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("SerpApi 返回 %d: %s", resp.StatusCode, truncate(string(body), 200))
}
var out serpAPIResponse
if err := json.Unmarshal(body, &out); err != nil {
return nil, fmt.Errorf("SerpApi 响应解析失败: %w", err)
}
if out.Error != "" {
return nil, fmt.Errorf("SerpApi 业务错误: %s", out.Error)
}
var res []Result
for _, r := range out.OrganicResults {
title := strings.TrimSpace(r.Title)
link := strings.TrimSpace(r.Link)
if title == "" || link == "" {
continue
}
res = append(res, Result{
Title: title,
URL: link,
Content: strings.TrimSpace(r.Snippet),
Engine: "serpapi_google",
})
if len(res) >= c.maxResults {
break
}
}
return res, nil
}