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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user