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,185 @@
package search
import (
"bytes"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
)
// DtsAIClient 调用阿里云 DTS-AI 服务(DtsAI, Version 2026-04-01)的 WebSearch 获取搜索结果。
//
// 背景:SerpApi 是境外付费源,个别企业网络或跨境场景下连通性/合规受限。阿里云 DTS-AI
// 是部署在国内(cn-beijing)的官方搜索服务,服务器端完成检索,返回结构化 JSON、无验证码,
// 公测期间免费。可作为「通用可搜」闭环在国内的稳定补充源。
//
// 认证:DTS-AI 支持两种凭据,本实现采用官方推荐的 API Key 方式——通过请求头
// `X-Acs-ApiKey` 透传,无需计算 OpenAPI 签名,便于轻量集成。key 从环境变量
// `DTS_AI_API_KEY` 读取(可在 search_config.json 的 api_key_env 覆盖)。未配置 key 时
// Search 返回 (nil, skipDtsAIError) 让上层回退到下一引擎,保证无 key 也能跑。
//
// 调用方式(curl 实测对等):
//
// POST https://dtsai.cn-beijing.aliyuncs.com?Action=WebSearch&Version=2026-04-01&SignatureNonce=<uuid>
// Header: X-Acs-ApiKey: <key> Content-Type: application/json
// Body: {"RegionId":"cn-beijing","Query":"...","MaxResults":N}
type DtsAIClient struct {
apiKey string
region string
endpoint string
maxResults int
timeout time.Duration
hc *http.Client
}
// skipDtsAIError 表示未配置 DTS-AI API key,调用方应跳过本源继续回退。
type skipDtsAIError struct{}
func (skipDtsAIError) Error() string { return "DTS-AI 未配置 API key,跳过该源" }
// NewDtsAIClient 创建 DTS-AI 客户端。
// - apiKeyEnv: 读取 API key 的环境变量名(空串时默认 DTS_AI_API_KEY)
// - region: 地域 ID(默认 cn-beijing,官方另支持 ap-southeast-1)
// - endpoint: 服务入口;推荐 dtsai.<region>.aliyuncs.com,空串时按 region 推导
// - maxResults: 单次最多返回条数(官方默认 10,最大 50)
// - timeoutSec: 单次请求超时秒数
func NewDtsAIClient(apiKeyEnv, region, endpoint string, maxResults, timeoutSec int) *DtsAIClient {
envName := "DTS_AI_API_KEY"
if apiKeyEnv != "" {
envName = apiKeyEnv
}
if region == "" {
region = "cn-beijing"
}
if endpoint == "" {
endpoint = fmt.Sprintf("https://dtsai.%s.aliyuncs.com", region)
}
if maxResults <= 0 {
maxResults = 10
}
if timeoutSec <= 0 {
timeoutSec = 15
}
c := &DtsAIClient{
apiKey: strings.TrimSpace(os.Getenv(envName)),
region: region,
endpoint: strings.TrimRight(endpoint, "/"),
maxResults: maxResults,
}
c.hc = &http.Client{Timeout: time.Duration(timeoutSec) * time.Second}
return c
}
// randNonce 生成请求签名随机数(SignatureNonce),保持请求唯一性。
func randNonce() string {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return fmt.Sprintf("%d", time.Now().UnixNano())
}
return hex.EncodeToString(b)
}
// dtsAIRequest DTS-AI WebSearch 请求体(对齐 cargo 各字段部分可选)。
type dtsAIRequest struct {
RegionId string `json:"RegionId"`
Query string `json:"Query"`
MaxResults int `json:"MaxResults"`
}
// dtsAIItem 单个搜索结果。
type dtsAIItem struct {
Title string `json:"Title"`
Url string `json:"Url"`
Snippet string `json:"Snippet"`
}
// dtsAIResponse DTS-AI WebSearch 响应体。
type dtsAIResponse struct {
RequestId string `json:"RequestId"`
Query string `json:"Query"`
HttpStatusCode int `json:"HttpStatusCode"`
TotalResults int `json:"TotalResults"`
Success bool `json:"Success"`
ErrorMessage string `json:"ErrorMessage"`
Code string `json:"Code"`
SearchResult []dtsAIItem `json:"SearchResult"`
}
// Search 调用 DTS-AI WebSearch 搜索。未配置 key 时返回 skipDtsAIError。
func (c *DtsAIClient) Search(query string) ([]Result, error) {
if c.apiKey == "" {
return nil, skipDtsAIError{}
}
payload, err := json.Marshal(dtsAIRequest{
RegionId: c.region,
Query: query,
MaxResults: c.maxResults,
})
if err != nil {
return nil, fmt.Errorf("构建 DTS-AI 请求体失败: %w", err)
}
endpoint := fmt.Sprintf("%s?Action=WebSearch&Version=2026-04-01&SignatureNonce=%s",
c.endpoint, randNonce())
req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(payload))
if err != nil {
return nil, fmt.Errorf("构建 DTS-AI 请求失败: %w", err)
}
req.Header.Set("X-Acs-ApiKey", c.apiKey)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
resp, err := c.hc.Do(req)
if err != nil {
return nil, fmt.Errorf("DTS-AI 不可达: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("读取 DTS-AI 响应失败: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("DTS-AI 返回 %d: %s", resp.StatusCode, truncate(string(body), 200))
}
var out dtsAIResponse
if err := json.Unmarshal(body, &out); err != nil {
return nil, fmt.Errorf("DTS-AI 响应解析失败: %w", err)
}
if !out.Success {
if out.ErrorMessage != "" {
return nil, fmt.Errorf("DTS-AI 业务错误: %s", out.ErrorMessage)
}
if out.Code != "" {
return nil, fmt.Errorf("DTS-AI 业务错误 code=%s", out.Code)
}
return nil, fmt.Errorf("DTS-AI 业务错误: Success=false")
}
var res []Result
for _, r := range out.SearchResult {
title := strings.TrimSpace(r.Title)
link := strings.TrimSpace(r.Url)
if title == "" || link == "" {
continue
}
res = append(res, Result{
Title: title,
URL: link,
Content: strings.TrimSpace(r.Snippet),
Engine: "dtsai",
})
if len(res) >= c.maxResults {
break
}
}
return res, nil
}