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,105 @@
package search
import (
"fmt"
"strings"
)
// Result 单个搜索结果。
type Result struct {
Title string `json:"title"`
URL string `json:"url"`
Content string `json:"content"`
Engine string `json:"engine"`
}
// Response SearXNG /search?format=json 的标准响应(兼容旧响应结构)。
type Response struct {
Query string `json:"query"`
Results []Result `json:"results"`
}
// Client 搜索引擎聚合编排器。
//
// 持有一组按优先级排序的 Engine,Search 按顺序逐个调用,命中(成功且有结果)即返回;
// 单个引擎失败/无结果时降级到下一引擎,全部失败再汇总根因。启用的引擎与顺序由
// search_config.json 的 active 列表声明,替代此前写死代码里的硬编码回退链。
type Client struct {
engines []Engine
}
// NewConfiguredClient 按配置构建聚合 Client。
//
// - cfg 为 nil(无配置文件)时使用默认硬编码链:SearXNG → 搜狗 HTTP → 真实浏览器,
// 保证旧行为不因缺配置而改变。
// - cfg 非 nil 时按 active 列表顺序构建引擎,跳过 disabled 或未知类型者。
func NewConfiguredClient(cfg *SearchConfig) *Client {
if cfg == nil {
return &Client{engines: defaultEngines()}
}
c := &Client{}
for _, name := range cfg.Active {
ec, ok := cfg.Engines[name]
if !ok || !ec.Enabled {
continue
}
if e, err := engineFromConfig(name, ec); err != nil {
// 未知类型直接跳过,不阻塞整链。
continue
} else {
c.engines = append(c.engines, e)
}
}
// 配置全被跳过时回退默认链,避免空组织。
if len(c.engines) == 0 {
c.engines = defaultEngines()
}
return c
}
// defaultEngines 无配置时的默认硬编码链(保持旧行为)。
func defaultEngines() []Engine {
return []Engine{
&searxngEngine{client: NewSearXNGClient("")},
&sogouEngine{client: NewSogouClient()},
&browserEngine{searcher: GetBrowserSearcher()},
}
}
// EngineNames 返回当前生效的引擎名(有序),供诊断/展示。
func (c *Client) EngineNames() []string {
names := make([]string, 0, len(c.engines))
for _, e := range c.engines {
names = append(names, e.Name())
}
return names
}
// Search 按配置顺序逐个调用引擎,命中即返回首个有结果的引擎。
// 全部引擎失败时聚合各引擎错误。
func (c *Client) Search(query string) ([]Result, error) {
var errParts []string
for _, e := range c.engines {
res, err := e.Search(query)
if err == nil && len(res) > 0 {
return res, nil
}
errParts = append(errParts, fmt.Sprintf("%s:%v", e.Name(), orNil(err)))
}
return nil, fmt.Errorf("全部搜索引擎无结果:[%s]", strings.Join(errParts, ";"))
}
func orNil(err error) any {
if err == nil {
return "无结果"
}
return err
}
func truncate(s string, n int) string {
r := []rune(s)
if len(r) <= n {
return s
}
return string(r[:n]) + "..."
}