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>
This commit is contained in:
eaiadmin
2026-09-26 22:21:39 +08:00
co-authored by Claude Code
parent e73169df50
commit c1af86c934
192 changed files with 18046 additions and 392 deletions
@@ -0,0 +1,160 @@
package search
import (
"crypto/rand"
"fmt"
"html"
"io"
"net/http"
"net/url"
"regexp"
"strings"
"time"
)
// SogouBaseURL 搜狗网页搜索地址(墙内可直连、按关键词真实返回结果,
// 用于在 SearXNG 上游失效时作为第三方搜索源兜底)。
const SogouBaseURL = "https://www.sogou.com/web"
const (
sogouTimeout = 12 * time.Second
sogouMaxResult = 8
)
// SogouUserAgent 避免触发反爬降级的真实浏览器 UA。
const SogouUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0 Safari/537.36"
var (
// sogouItemRe 匹配搜狗结果条目。实际结构(实测):
// <a href="真实URL" class="special-title" ...>
// <h3 class="vr-title"><span>标题</span>
// <p class="title-summary">摘要</p>
// </h3>
// </a>
// 取 <a> 的 href 作 URL,h3 内文本作标题。
sogouItemRe = regexp.MustCompile(`(?s)<a[^>]*href="([^"]+)"[^>]*>.*?<h3[^>]*class="[^"]*vr-title[^"]*"[^>]*>.*?<span[^>]*>(.*?)</span>`)
// sogouDescRe 匹配结果摘要(title-summary / str_info / space-txt 等文本容器)。
sogouDescRe = regexp.MustCompile(`(?s)<(?:p|span|div)[^>]*class="[^"]*(?:title-summary|str_info|space-txt|fb fz-mid|text-info)[^"]*"[^>]*>(.*?)</(?:p|span|div)>`)
anyTagRe = regexp.MustCompile(`<[^>]+>`)
)
// SogouClient 搜狗网页搜索客户端。随机化部分请求头,降低连续查询触发
// 人机校验的概率(已验证源响应按关键词真实匹配,而非缓存页)。
type SogouClient struct {
timeout time.Duration
hc *http.Client
}
// NewSogouClient 创建搜狗搜索客户端。
func NewSogouClient() *SogouClient {
return &SogouClient{timeout: sogouTimeout, hc: &http.Client{Timeout: sogouTimeout}}
}
// Search 抓取并解析搜狗网页搜索结果,映射为 []Result。
// 搜狗对数据中心 IP 的连续快速查询偶发人机校验(返回验证码页),
// 此处做一次短暂退避重试,降低瞬时校验导致的空结果概率。
func (s *SogouClient) Search(query string) ([]Result, error) {
for attempt := 0; ; attempt++ {
res, err := s.searchOnce(query)
if (err == nil && len(res) > 0) || attempt >= 1 {
return res, err
}
// 空结果或错误:等一小段再试一次
time.Sleep(1200 * time.Millisecond)
}
}
func (s *SogouClient) searchOnce(query string) ([]Result, error) {
params := url.Values{}
params.Set("query", query)
searchURL := SogouBaseURL + "?" + params.Encode()
req, err := http.NewRequest(http.MethodGet, searchURL, nil)
if err != nil {
return nil, fmt.Errorf("构建搜狗请求失败: %w", err)
}
req.Header.Set("User-Agent", SogouUserAgent)
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
req.Header.Set("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8")
req.Header.Set("Referer", "https://www.sogou.com/")
req.Header.Set("X-Forwarded-For", randomIP())
req.Header.Set("X-Real-IP", randomIP())
resp, err := s.hc.Do(req)
if err != nil {
return nil, fmt.Errorf("搜狗不可达: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("搜狗返回 %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("读取搜狗响应失败: %w", err)
}
return parseSogou(body), nil
}
// parseSogou 从搜狗结果页提取标题/链接/摘要。
func parseSogou(page []byte) []Result {
raw := string(page)
// 命中人机校验页则明确返回空(触发上层回退),不产出伪结果。
if strings.Contains(raw, "请输入验证码") || strings.Contains(raw, "antispider") || strings.Contains(raw, "访问过于频繁") {
return nil
}
matches := sogouItemRe.FindAllStringSubmatch(raw, -1)
results := make([]Result, 0, len(matches))
for _, m := range matches {
if len(m) < 3 {
continue
}
href := m[1]
title := cleanText(m[2])
if title == "" {
continue
}
// 跳过非可访问协议(如 javascript:void(0) 的分享桩),只保留 http(s) 结果链接。
if !strings.HasPrefix(href, "http") && !strings.HasPrefix(href, "/link?") {
continue
}
// 结果链接多为搜狗跳转封装 (/link?url=...),保留源标题/摘要,
// 跳转链接可由调用方按需跟进,不影响 LLM 引用来源。
urlText := href
if strings.HasPrefix(href, "/link?") || strings.HasPrefix(href, "/weixin") {
urlText = "https://www.sogou.com" + href
}
results = append(results, Result{
Title: title,
URL: urlText,
Content: extractSogouDesc(raw),
Engine: "sogou",
})
if len(results) >= sogouMaxResult {
break
}
}
return results
}
// extractSogouDesc 提取搜狗摘要段;无法精确定位时返回空。
func extractSogouDesc(raw string) string {
m := sogouDescRe.FindStringSubmatch(raw)
if len(m) < 2 {
return ""
}
return cleanText(m[1])
}
func cleanText(s string) string {
s = anyTagRe.ReplaceAllString(s, "")
s = html.UnescapeString(s)
return strings.TrimSpace(s)
}
func randomIP() string {
b := make([]byte, 4)
_, _ = rand.Read(b)
return fmt.Sprintf("%d.%d.%d.%d", b[0], b[1], b[2], b[3])
}