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
+180 -40
View File
@@ -3,7 +3,9 @@ package ai
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
@@ -18,9 +20,37 @@ import (
// ──────────────────────────────────────────────
// Message OpenAI 兼容消息
// Role: system / user / assistant / tool。
// 普通对话仅需 Role+Content;工具调用场景可带 ToolCalls(assistant 发起)
// 与 ToolCallID(回填 tool 结果时使用)。
type Message struct {
Role string `json:"role"`
Content string `json:"content"`
Role string `json:"role"`
Content string `json:"content"`
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
ToolCallID string `json:"tool_call_id,omitempty"`
}
// ToolCall 模型发起的工具调用。
type ToolCall struct {
ID string `json:"id"`
Type string `json:"type,omitempty"`
Function ToolFuncCall `json:"function"`
}
// ToolFuncCall 工具调用的函数名与参数(json.RawMessage 以保持参数原文)。
type ToolFuncCall struct {
Name string `json:"name"`
Arguments json.RawMessage `json:"arguments"`
}
// ToolSchema OpenAI 兼容 function tool 定义,随请求一并提交给 LLM。
type ToolSchema struct {
Type string `json:"type"` // "function"
Function struct {
Name string `json:"name"`
Description string `json:"description"`
Parameters interface{} `json:"parameters"`
} `json:"function"`
}
// ChatResult 非流式调用结果
@@ -29,6 +59,7 @@ type ChatResult struct {
Model string
Provider string
FinishReason string
ToolCalls []ToolCall
Usage struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
@@ -42,6 +73,7 @@ type ChatResult struct {
// Client 低层 HTTP 客户端(基于 RouteConfig)
type Client struct {
route *config.RouteConfig
baseURL string
apiKey string
model string
@@ -50,6 +82,11 @@ type Client struct {
hc *http.Client
}
// Route 返回创建本客户端的路由(可能为 nil)。
func (c *Client) Route() *config.RouteConfig {
return c.route
}
// defaultHTTPTimeout 默认 HTTP 整体超时;路由可用 timeout_seconds 单独覆盖
const defaultHTTPTimeout = 60 * time.Second
@@ -60,6 +97,7 @@ func NewClient(aiRoute *config.RouteConfig) *Client {
timeout = time.Duration(aiRoute.TimeoutSeconds) * time.Second
}
return &Client{
route: aiRoute,
baseURL: strings.TrimRight(aiRoute.BaseURL, "/"),
apiKey: aiRoute.APIKey,
model: aiRoute.Model,
@@ -81,12 +119,14 @@ func (c *Client) headers() map[string]string {
return h
}
func (c *Client) post(path string, body any) (*http.Response, error) {
// post 发起 OpenAI 兼容请求。ctx 可被上层(如编排 150s 兜底)用 WithTimeout 取消,
// 使 LLM 调用真正中断,而不是只能等死的 http.Client.Timeout。
func (c *Client) post(ctx context.Context, path string, body any) (*http.Response, error) {
j, err := json.Marshal(body)
if err != nil {
return nil, err
}
req, err := http.NewRequest(http.MethodPost, c.url(path), bytes.NewReader(j))
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.url(path), bytes.NewReader(j))
if err != nil {
return nil, err
}
@@ -102,11 +142,81 @@ func (c *Client) post(path string, body any) (*http.Response, error) {
// ──────────────────────────────────────────────
// 非流式调用
// EmptyCompletionError 上游回了 200,但正文是空的。
//
// 为什么要单独成类型:**「空正文」有好几种成因,处置完全不同,而 finish_reason
// 是唯一能把它们分开的东西**。
//
// 最要命的一种是推理模型把 max_tokens 全花在思考上 —— 此时响应里**根本没有
// content 字段**(不是空串,是不存在),finish_reason=length。实测某对话路由
// (max_tokens=4096)对 2500 字的密集中文输入,reasoning_content 写了 7187 字、
// 正文 0 字;把预算提到 8192 才有正文(2470 字,reasoning 9590 字)。
// 这种情况报「空正文」会把人引去查模型名和密钥,而真正该做的是调大预算;
// 报「思考吃光了预算」才能一眼看到病根。
//
// 只由 GenerateFull 返回(Generate 只回字符串,拿不到 finish_reason)。
type EmptyCompletionError struct {
FinishReason string
CompletionTokens int
}
// Error 保留「LLM 返回空正文」前缀:这是这条例外此前的文案,外部可能有按
// 子串匹配的地方,补上诊断信息不该顺手改掉已经存在的说法。
func (e *EmptyCompletionError) Error() string {
if e.FinishReason == "" {
return "LLM 返回空正文(响应里没有 finish_reason)"
}
if strings.EqualFold(e.FinishReason, "length") {
return fmt.Sprintf(
"LLM 返回空正文(finish_reason=length,completion_tokens=%d):预算被思考用光了,"+
"推理模型写正文之前就把 max_tokens 花完了,调大该路由的 max_tokens 才可能写出正文",
e.CompletionTokens)
}
return fmt.Sprintf("LLM 返回空正文(finish_reason=%q,completion_tokens=%d)", e.FinishReason, e.CompletionTokens)
}
// TransientUpstreamError 上游这一次没成,但**同样的请求重发有机会成**。
//
// 与 EmptyCompletionError 的区别就是「要不要重试」这一件事,而这件事必须由类型
// 决定、不能由文案猜:
//
// - EmptyCompletionError 是模型确实写不出正文(预算被思考吃光、模型拒答),
// 重发一百次都是同一个结果,重试只是再花一次钱;
// - 本类型是**协议或链路层面的抖动** —— 网关回了 200 却给一个没有 choices 的
// 报文、限流、上游 5xx、连接被掐断。这些重发就有机会成,不重发则整步白跑。
//
// 实测背景:某技能的第 3 步要连打 11 次模型(逐字稿按 1200 字分块),第 4 次
// 撞上的就是这个 —— 网关 HTTP 200,报文里却没有 choices,整步就此失败。
type TransientUpstreamError struct {
// StatusCode 0 表示压根没拿到状态码(连接就没建起来)。
// 目前只用来说明情况,重试决策在构造处就定了。
StatusCode int
// Message 是**原样的**错误文案。故意不做二次拼装:这些文案是既有说法,
// 记录日志、前端提示都可能在看,加个类型标记没理由顺手改动它们。
Message string
// Err 保留底层错误,让 errors.Is/As 还能穿透下去
// (例如上层要判 context.DeadlineExceeded)。此前靠 %w 提供的这条链不能断。
Err error
}
func (e *TransientUpstreamError) Error() string { return e.Message }
func (e *TransientUpstreamError) Unwrap() error { return e.Err }
// IsTransient 判断 err 值不值得重发。
//
// 走 errors.As,所以**经过 %w 包装后依然认得出** —— 回退链会给错误套上
// 「所有路由均失败: [路由 id] …」两层包装,调用方拿到的是最外层那个。
func IsTransient(err error) bool {
var transient *TransientUpstreamError
return errors.As(err, &transient)
}
// ──────────────────────────────────────────────
// Generate 非流式对话,返回完整正文
func (c *Client) Generate(messages []Message) (string, error) {
resp, err := c.post("/chat/completions", map[string]any{
func (c *Client) Generate(ctx context.Context, messages []Message) (string, error) {
resp, err := c.post(ctx, "/chat/completions", map[string]any{
"model": c.model,
"messages": messages,
"stream": false,
@@ -143,8 +253,8 @@ func (c *Client) Generate(messages []Message) (string, error) {
}
// GenerateFull 非流式调用,返回完整 ChatResult(含 usage)
func (c *Client) GenerateFull(messages []Message) (*ChatResult, error) {
resp, err := c.post("/chat/completions", map[string]any{
func (c *Client) GenerateFull(ctx context.Context, messages []Message) (*ChatResult, error) {
resp, err := c.post(ctx, "/chat/completions", map[string]any{
"model": c.model,
"messages": messages,
"stream": false,
@@ -152,16 +262,30 @@ func (c *Client) GenerateFull(messages []Message) (*ChatResult, error) {
"max_tokens": c.maxTokens,
})
if err != nil {
return nil, fmt.Errorf("LLM 服务不可达: %w", err)
// 连接建不起来 / 被掐断 / 超时:重发有意义。
return nil, &TransientUpstreamError{
Message: fmt.Sprintf("LLM 服务不可达: %v", err),
Err: err,
}
}
defer resp.Body.Close()
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("读取 LLM 响应失败: %w", err)
// 正文读到一半断了,同样属于这一趟的抖动。
return nil, &TransientUpstreamError{
Message: fmt.Sprintf("读取 LLM 响应失败: %v", err),
Err: err,
}
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("LLM 返回 %d: %s", resp.StatusCode, truncate(string(data), 200))
msg := fmt.Sprintf("LLM 返回 %d: %s", resp.StatusCode, truncate(string(data), 200))
// 限流和上游 5xx 是**这一趟**的问题,重发可能就过了;
// 4xx(模型名写错、密钥无效、参数非法)重发一百次也是同一个结果,不标可重试。
if resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= 500 {
return nil, &TransientUpstreamError{StatusCode: resp.StatusCode, Message: msg}
}
return nil, errors.New(msg)
}
var raw struct {
@@ -179,10 +303,34 @@ func (c *Client) GenerateFull(messages []Message) (*ChatResult, error) {
} `json:"usage"`
}
if err := json.Unmarshal(data, &raw); err != nil {
return nil, fmt.Errorf("LLM 响应解析失败: %w", err)
// 报文根本不是 JSON:与「没有 choices」同一种病 —— 网关塞了别的东西进来。
return nil, &TransientUpstreamError{
StatusCode: resp.StatusCode,
Message: fmt.Sprintf("LLM 响应解析失败: %v", err),
Err: err,
}
}
if len(raw.Choices) == 0 || strings.TrimSpace(raw.Choices[0].Message.Content) == "" {
return nil, fmt.Errorf("LLM 返回空正文")
if len(raw.Choices) == 0 {
// 没有 choices 是**协议层面**的异常,跟「模型没写出正文」不是一回事:
// 网关回 200 却是别的报文形状(限流、上游报错包在 200 里等)都会落到这里。
// 所以把原始报文带上 —— 只说一句「空正文」,拿到的人根本无从下手。
// 与上面状态码分支一样截断,且保留「LLM 返回空正文」这个前缀。
//
// 标成可重试:报文形状不对是**这一趟**的事,不是模型对这个输入的回答。
return nil, &TransientUpstreamError{
StatusCode: resp.StatusCode,
Message: fmt.Sprintf("LLM 返回空正文(响应里没有 choices):%s", truncate(string(data), 300)),
}
}
if strings.TrimSpace(raw.Choices[0].Message.Content) == "" {
completionTokens := 0
if raw.Usage != nil {
completionTokens = raw.Usage.CompletionTokens
}
return nil, &EmptyCompletionError{
FinishReason: raw.Choices[0].FinishReason,
CompletionTokens: completionTokens,
}
}
result := &ChatResult{
@@ -203,8 +351,8 @@ func (c *Client) GenerateFull(messages []Message) (*ChatResult, error) {
// ──────────────────────────────────────────────
// GenerateStream SSE 流式调用,逐 chunk 回调
func (c *Client) GenerateStream(messages []Message, onChunk func(string)) error {
resp, err := c.post("/chat/completions", map[string]any{
func (c *Client) GenerateStream(ctx context.Context, messages []Message, onChunk func(string)) error {
resp, err := c.post(ctx, "/chat/completions", map[string]any{
"model": c.model,
"messages": messages,
"stream": true,
@@ -283,8 +431,8 @@ func (c *Client) GenerateStream(messages []Message, onChunk func(string)) error
// ──────────────────────────────────────────────
// Embed 批量向量化
func (c *Client) Embed(inputs []string) ([][]float64, error) {
resp, err := c.post("/embeddings", map[string]any{
func (c *Client) Embed(ctx context.Context, inputs []string) ([][]float64, error) {
resp, err := c.post(ctx, "/embeddings", map[string]any{
"model": c.model, "input": inputs,
})
if err != nil {
@@ -335,8 +483,9 @@ func buildRouteChain(primary *config.RouteConfig) ([]*config.RouteConfig, error)
return aiRouteChain, nil
}
// GenerateWithFallback 依次尝试主路由 + 回退链,首个成功返回
func GenerateWithFallback(primary *config.RouteConfig, messages []Message) (string, error) {
// GenerateWithFallback 依次尝试主路由 + 回退链,首个成功返回。
// ctx 贯穿到各路由的 HTTP 请求,使上层超时(如编排 150s 兜底)真正能取消 LLM 调用。
func GenerateWithFallback(ctx context.Context, primary *config.RouteConfig, messages []Message) (string, error) {
aiRouteChain, err := buildRouteChain(primary)
if err != nil {
return "", err
@@ -346,7 +495,7 @@ func GenerateWithFallback(primary *config.RouteConfig, messages []Message) (stri
for _, aiRoute := range aiRouteChain {
client := NewClient(aiRoute)
start := time.Now()
content, err := client.Generate(messages)
content, err := client.Generate(ctx, messages)
if err == nil {
config.ReportRouteHealth(config.RouteHealth{
AIRouteID: aiRoute.RouteID,
@@ -372,8 +521,9 @@ func GenerateWithFallback(primary *config.RouteConfig, messages []Message) (stri
return "", fmt.Errorf("所有路由均失败: %w", lastErr)
}
// GenerateFullWithFallback 非流式 + 回退链:返回结果与「实际命中」的路由(用于审计)
func GenerateFullWithFallback(primary *config.RouteConfig, messages []Message) (*ChatResult, *config.RouteConfig, error) {
// GenerateFullWithFallback 非流式 + 回退链:返回结果与「实际命中」的路由(用于审计)。
// ctx 贯穿到各路由的 HTTP 请求,使上层超时真正能取消 LLM 调用。
func GenerateFullWithFallback(ctx context.Context, primary *config.RouteConfig, messages []Message) (*ChatResult, *config.RouteConfig, error) {
aiRouteChain, err := buildRouteChain(primary)
if err != nil {
return nil, nil, err
@@ -383,7 +533,7 @@ func GenerateFullWithFallback(primary *config.RouteConfig, messages []Message) (
for _, aiRoute := range aiRouteChain {
client := NewClient(aiRoute)
start := time.Now()
res, err := client.GenerateFull(messages)
res, err := client.GenerateFull(ctx, messages)
if err == nil {
config.ReportRouteHealth(config.RouteHealth{
AIRouteID: aiRoute.RouteID,
@@ -410,8 +560,9 @@ func GenerateFullWithFallback(primary *config.RouteConfig, messages []Message) (
return nil, nil, fmt.Errorf("所有路由均失败: %w", lastErr)
}
// GenerateStreamWithFallback 流式 + 回退链:返回实际命中的路由
func GenerateStreamWithFallback(primary *config.RouteConfig, messages []Message, onChunk func(string)) (*config.RouteConfig, error) {
// GenerateStreamWithFallback 流式 + 回退链:返回实际命中的路由。
// ctx 贯穿到各路由的 SSE 请求,使上层超时(如前端断开)真正能取消 LLM 调用。
func GenerateStreamWithFallback(ctx context.Context, primary *config.RouteConfig, messages []Message, onChunk func(string)) (*config.RouteConfig, error) {
aiRouteChain, err := buildRouteChain(primary)
if err != nil {
return nil, err
@@ -419,7 +570,8 @@ func GenerateStreamWithFallback(primary *config.RouteConfig, messages []Message,
var lastErr error
for _, aiRoute := range aiRouteChain {
if requiresAPIKey(aiRoute) && strings.TrimSpace(aiRoute.APIKey) == "" {
// 判据只有 config 那一处,本文件原先自己抄了一份(缺「回环地址免密钥」)。
if config.RequiresRouteAPIKey(aiRoute) && strings.TrimSpace(aiRoute.APIKey) == "" {
config.ReportRouteHealth(config.RouteHealth{
AIRouteID: aiRoute.RouteID,
Category: aiRoute.Category,
@@ -433,7 +585,7 @@ func GenerateStreamWithFallback(primary *config.RouteConfig, messages []Message,
}
client := NewClient(aiRoute)
start := time.Now()
if err := client.GenerateStream(messages, onChunk); err == nil {
if err := client.GenerateStream(ctx, messages, onChunk); err == nil {
config.ReportRouteHealth(config.RouteHealth{
AIRouteID: aiRoute.RouteID,
Category: aiRoute.Category,
@@ -459,18 +611,6 @@ func GenerateStreamWithFallback(primary *config.RouteConfig, messages []Message,
return nil, fmt.Errorf("所有路由均失败: %w", lastErr)
}
func requiresAPIKey(aiRoute *config.RouteConfig) bool {
if aiRoute == nil {
return false
}
baseURL := strings.ToLower(strings.TrimSpace(aiRoute.BaseURL))
if strings.Contains(baseURL, "openrouter.ai") || strings.Contains(baseURL, "openai.com") {
return true
}
provider := strings.ToLower(strings.TrimSpace(aiRoute.Provider))
return provider == "openrouter" || provider == "openai"
}
func truncate(s string, n int) string {
r := []rune(s)
if len(r) <= n {