按用户指示做**一包提交**,不按工作流拆分。本提交刻意混合了多条并行线:
· 本地 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>
186 lines
5.7 KiB
Go
186 lines
5.7 KiB
Go
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
|
||
}
|