feat: 微信公众号技能包重命名(weixin_public_account)并增强功能

- 将 wechat_official_account 重命名为 weixin_public_account,符合中文命名规范
- 新增 DOCX 文档生成技能、聊天历史、请求 ID 中间件
- 增强工作流、热点服务、文章服务等模块功能
- 前端同步重命名组件和 API
- 新增架构文档 AR13/AR14、专员文档更新
- 补充测试用例(seed_specialists_test, db_migration_test)

Co-Authored-AI: yes
This commit is contained in:
eaiadmin
2026-09-24 21:13:19 +08:00
parent 593323a934
commit 89ae31c998
60 changed files with 7251 additions and 967 deletions
+57 -22
View File
@@ -50,15 +50,22 @@ type Client struct {
hc *http.Client
}
// defaultHTTPTimeout 默认 HTTP 整体超时;路由可用 timeout_seconds 单独覆盖
const defaultHTTPTimeout = 60 * time.Second
// NewClient 从 RouteConfig 创建客户端
func NewClient(aiRoute *config.RouteConfig) *Client {
timeout := defaultHTTPTimeout
if aiRoute.TimeoutSeconds > 0 {
timeout = time.Duration(aiRoute.TimeoutSeconds) * time.Second
}
return &Client{
baseURL: strings.TrimRight(aiRoute.BaseURL, "/"),
apiKey: aiRoute.APIKey,
model: aiRoute.Model,
maxTokens: aiRoute.MaxTokens,
temperature: aiRoute.Temperature,
hc: &http.Client{Timeout: 120 * time.Second},
hc: &http.Client{Timeout: timeout},
}
}
@@ -215,32 +222,60 @@ func (c *Client) GenerateStream(messages []Message, onChunk func(string)) error
return fmt.Errorf("LLM 返回 %d: %s", resp.StatusCode, truncate(string(data), 200))
}
// 流式空闲超时保护:若超过 idleTimeout 没有收到任何可消费 chunk,
// 判定为卡死并返回错误,避免前端无限等待(此前本地模型卡顿时会干等 120s)。
const idleTimeout = 45 * time.Second
scanner := bufio.NewScanner(resp.Body)
scanner.Buffer(make([]byte, 64*1024), 1024*1024)
for scanner.Scan() {
line := scanner.Text()
if !strings.HasPrefix(line, "data:") {
continue
scannerCh := make(chan string, 1)
// 驱动 scanner 的 goroutine,配合 select 实现空闲超时。
// 函数返回时 defer resp.Body.Close() 会让 scanner.Scan() 立即返回,
// goroutine 随之退出并通过 close 通知主循环(缓冲 channel 避免阻塞发送)。
go func() {
for scanner.Scan() {
scannerCh <- scanner.Text()
}
payload := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
if payload == "[DONE]" {
break
}
var chunk struct {
Choices []struct {
Delta struct {
Content string `json:"content"`
} `json:"delta"`
} `json:"choices"`
}
if err := json.Unmarshal([]byte(payload), &chunk); err != nil {
continue
}
if len(chunk.Choices) > 0 && chunk.Choices[0].Delta.Content != "" {
onChunk(chunk.Choices[0].Delta.Content)
close(scannerCh)
}()
idleTimer := time.NewTimer(idleTimeout)
defer idleTimer.Stop()
for {
select {
case <-idleTimer.C:
return fmt.Errorf("LLM 流式响应空闲超时(%s 内无数据)", idleTimeout)
case line, ok := <-scannerCh:
if !ok {
return nil
}
if !strings.HasPrefix(line, "data:") {
continue
}
payload := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
if payload == "[DONE]" {
return nil
}
var chunk struct {
Choices []struct {
Delta struct {
Content string `json:"content"`
} `json:"delta"`
} `json:"choices"`
}
if err := json.Unmarshal([]byte(payload), &chunk); err != nil {
continue
}
if len(chunk.Choices) > 0 && chunk.Choices[0].Delta.Content != "" {
// 收到有效内容,重置空闲计时
if !idleTimer.Stop() {
<-idleTimer.C
}
idleTimer.Reset(idleTimeout)
onChunk(chunk.Choices[0].Delta.Content)
}
}
}
return scanner.Err()
}
// ──────────────────────────────────────────────
@@ -0,0 +1,427 @@
// Package routetest 提供「AI 通路测试」能力。
//
// 与线上定时探针(config/route_health.go 的 30 分钟健康检查)互补:
// 探针只做最简连通性验证;本包用于**开发/运维**对某个具体模型做一次真实业务调用,
// 逐个候选模型发独立请求并记录 request/response 原始报文,验证"真的能跑出结果"。
//
// 设计约束:
// - 本包的所有测试调用**绝不扣算力点、不写入 ai_call_log**(纯后台诊断,避免污染用量/计费)。
// - 支持 chat / embed / image 三类路由,按路由 Category 分发。
package routetest
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"sort"
"strings"
"time"
"eai_agentplatform/backend/internal/config"
)
// 默认测试超时与默认测试提示词(真实业务类提示词,而非 "ping")。
const (
defaultTestTimeout = 60 * time.Second
// defaultChatProbe 对话类默认测试提示词
defaultChatProbe = "你好,请用一句简短的话回复我(用于连通性测试)。"
// 模型发现请求的 base 路径:/models
modelsPath = "/models"
)
// TestOptions 单次 / 批量通路测试入参
type TestOptions struct {
// Prompt 业务测试提示词;为空时使用各分类的默认提示词。
Prompt string
// Models 待测试的候选模型(传空则使用 route 自身 model;批量测试时传候选列表)。
Models []string
// Size image 类测试的图片尺寸(默认 1024x1024)。
Size string
}
// ResultItem 单个模型的测试结果(对应每个模型一个结果行)
type ResultItem struct {
Model string `json:"model"`
Ok bool `json:"ok"`
StatusCode int `json:"status_code"`
LatencyMs int64 `json:"latency_ms"`
Error string `json:"error,omitempty"`
// ModelEcho 服务端回显的 model 字段(用于确认实际被路由到的模型)
ModelEcho string `json:"model_echo,omitempty"`
// Summary text 类测试返回正文摘要 / image 类成功标志
Summary string `json:"summary,omitempty"`
// OutputPreview 产物预览:image 类返回 data-url(base64),text 类返回正文片段
OutputPreview string `json:"output_preview,omitempty"`
// RawRequest / RawResponse 原始报文(JSON 字符串,供诊断展开)
RawRequest string `json:"raw_request,omitempty"`
RawResponse string `json:"raw_response,omitempty"`
}
// Result 一次通路测试的整体返回
type Result struct {
RouteID string `json:"route_id"`
Provider string `json:"provider"`
Category string `json:"category"`
BaseURL string `json:"base_url"`
Endpoint string `json:"endpoint"`
TestedAt string `json:"tested_at"`
Succeeded bool `json:"succeeded"`
Items []ResultItem `json:"items"`
}
// Do 对指定 RouteConfig 发起通路测试。
// - opts.Models 为空 → 仅测试 route 自身 model(单模型测试)。
// - opts.Models 非空 → 逐个模型测试(批量/候选 试跑)。
func Do(route *config.RouteConfig, opts *TestOptions) *Result {
if route == nil {
return &Result{Succeeded: false, Items: []ResultItem{{Ok: false, Error: "路由不存在"}}}
}
r := &Result{
RouteID: route.RouteID,
Provider: route.Provider,
Category: route.Category,
BaseURL: route.BaseURL,
Endpoint: route.Endpoint,
TestedAt: time.Now().Format(time.RFC3339),
}
models := opts.Models
if len(models) == 0 {
models = []string{route.Model}
}
size := opts.Size
if size == "" {
size = "1024x1024"
}
for _, model := range models {
item := testOne(route, model, opts.Prompt, size)
r.Items = append(r.Items, item)
}
for _, it := range r.Items {
if it.Ok {
r.Succeeded = true
break
}
}
return r
}
// testOne 对单个模型(+ 某条路由)+ 真实请求做测试,返回该模型独立结果行。
func testOne(route *config.RouteConfig, model string, prompt, size string) ResultItem {
item := ResultItem{Model: model}
if strings.TrimSpace(route.BaseURL) == "" {
item.Error = "base_url 未配置"
return item
}
if requiresAPIKey(route) && strings.TrimSpace(route.APIKey) == "" {
item.Error = "API Key 未配置"
return item
}
start := time.Now()
switch route.Category {
case "image":
testImage(route, model, prompt, size, &item)
case "video":
testVideo(route, model, prompt, &item)
case "embed":
testEmbed(route, model, prompt, &item)
default:
testChat(route, model, prompt, &item)
}
item.LatencyMs = time.Since(start).Milliseconds()
if item.Error != "" {
item.Ok = false
} else {
item.Ok = true
}
return item
}
// httpPost 发一次 POST,返回状态码、原始响应体、错误。用于记录原始报文。
func httpPost(route *config.RouteConfig, path string, body any, out any) (int, string, error) {
raw, err := json.Marshal(body)
if err != nil {
return 0, "", fmt.Errorf("请求序列化失败: %w", err)
}
fullURL := strings.TrimRight(route.BaseURL, "/") + path
req, err := http.NewRequest(http.MethodPost, fullURL, bytes.NewReader(raw))
if err != nil {
return 0, "", fmt.Errorf("构造请求失败: %w", err)
}
req.Header.Set("Content-Type", "application/json")
if strings.TrimSpace(route.APIKey) != "" {
req.Header.Set("Authorization", "Bearer "+route.APIKey)
}
client := &http.Client{Timeout: defaultTestTimeout}
resp, err := client.Do(req)
if err != nil {
return 0, "", fmt.Errorf("服务不可达: %w", err)
}
defer resp.Body.Close()
data, err := io.ReadAll(resp.Body)
if err != nil {
return resp.StatusCode, "", fmt.Errorf("读取响应失败: %w", err)
}
rawStr := string(data)
if resp.StatusCode != http.StatusOK {
return resp.StatusCode, rawStr, fmt.Errorf("返回 %d: %s", resp.StatusCode, truncate(rawStr, 200))
}
if len(data) > 0 && out != nil {
if err := json.Unmarshal(data, out); err != nil {
return resp.StatusCode, rawStr, fmt.Errorf("响应解析失败: %w", err)
}
}
return resp.StatusCode, rawStr, nil
}
// httpGet 发一次 GET,用于 /models 模型发现。
func httpGet(route *config.RouteConfig, path string) ([]string, error) {
fullURL := strings.TrimRight(route.BaseURL, "/") + path
req, err := http.NewRequest(http.MethodGet, fullURL, nil)
if err != nil {
return nil, err
}
if strings.TrimSpace(route.APIKey) != "" {
req.Header.Set("Authorization", "Bearer "+route.APIKey)
}
client := &http.Client{Timeout: defaultTestTimeout}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("返回 %d", resp.StatusCode)
}
var out struct {
Data []struct {
ID string `json:"id"`
} `json:"data"`
}
if err := json.Unmarshal(data, &out); err != nil {
return nil, err
}
models := make([]string, 0, len(out.Data))
for _, d := range out.Data {
if strings.TrimSpace(d.ID) != "" {
models = append(models, d.ID)
}
}
return models, nil
}
// testChat 对话类单模型测试。
func testChat(route *config.RouteConfig, model, prompt string, item *ResultItem) {
if strings.TrimSpace(prompt) == "" {
prompt = defaultChatProbe
}
body := map[string]any{
"model": model,
"messages": []map[string]string{{"role": "user", "content": prompt}},
"stream": false,
}
var out struct {
Model string `json:"model"`
Choices []struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
} `json:"choices"`
}
reqStr, _ := json.Marshal(body)
item.RawRequest = string(reqStr)
code, rawRes, err := httpPost(route, "/chat/completions", body, &out)
item.StatusCode = code
item.RawResponse = truncate(rawRes, 4000)
if err != nil {
item.Error = err.Error()
return
}
if len(out.Choices) == 0 || strings.TrimSpace(out.Choices[0].Message.Content) == "" {
item.Error = "返回空正文"
return
}
item.ModelEcho = out.Model
content := strings.TrimSpace(out.Choices[0].Message.Content)
item.Summary = truncate(content, 120)
item.OutputPreview = truncate(content, 2000)
}
// testEmbed 向量类单模型测试。
func testEmbed(route *config.RouteConfig, model, prompt string, item *ResultItem) {
input := "ping"
if strings.TrimSpace(prompt) != "" {
input = prompt
}
body := map[string]any{"model": model, "input": input}
var out struct {
Data []struct {
Embedding []float64 `json:"embedding"`
} `json:"data"`
}
reqStr, _ := json.Marshal(body)
item.RawRequest = string(reqStr)
code, rawRes, err := httpPost(route, "/embeddings", body, &out)
item.StatusCode = code
item.RawResponse = truncate(rawRes, 4000)
if err != nil {
item.Error = err.Error()
return
}
if len(out.Data) == 0 || len(out.Data[0].Embedding) == 0 {
item.Error = "返回空 embedding"
return
}
item.ModelEcho = model
item.Summary = fmt.Sprintf("返回 %d 维向量", len(out.Data[0].Embedding))
}
// testImage 生图类单模型测试;成功时以 data-url 预览产物。
func testImage(route *config.RouteConfig, model, prompt, size string, item *ResultItem) {
if strings.TrimSpace(prompt) == "" {
prompt = "生成一张简洁的产品展示图(用于连通性测试)"
}
body := map[string]any{
"model": model,
"prompt": prompt,
"size": size,
"n": 1,
"response_format": "b64_json",
}
var out struct {
Data []struct {
B64JSON string `json:"b64_json"`
URL string `json:"url"`
} `json:"data"`
}
reqStr, _ := json.Marshal(body)
item.RawRequest = string(reqStr)
code, rawRes, err := httpPost(route, "/images/generations", body, &out)
item.StatusCode = code
item.RawResponse = truncate(rawRes, 4000)
if err != nil {
item.Error = err.Error()
return
}
if len(out.Data) == 0 {
item.Error = "返回空 data"
return
}
if strings.TrimSpace(out.Data[0].B64JSON) != "" {
item.ModelEcho = model
item.Summary = "生成成功"
item.OutputPreview = "data:image/png;base64," + out.Data[0].B64JSON
return
}
if strings.TrimSpace(out.Data[0].URL) != "" {
item.ModelEcho = model
item.Summary = "生成成功(URL 模式)"
item.OutputPreview = out.Data[0].URL
return
}
item.Error = "响应缺少 b64_json / url"
}
// testVideo 对视频生成模型做一次真实调用(OpenAI 兼容 POST /videos/generations)。
// 视频响应通常回传 data[].url(视频文件地址)。
func testVideo(route *config.RouteConfig, model, prompt string, item *ResultItem) {
if strings.TrimSpace(prompt) == "" {
prompt = "请生成一段简洁的演示视频(用于连通性测试)"
}
body := map[string]any{
"model": model,
"prompt": prompt,
"n": 1,
}
var out struct {
Data []struct {
URL string `json:"url"`
B64JSON string `json:"b64_json"`
ID string `json:"id"`
} `json:"data"`
}
reqStr, _ := json.Marshal(body)
item.RawRequest = string(reqStr)
code, rawRes, err := httpPost(route, "/videos/generations", body, &out)
item.StatusCode = code
item.RawResponse = truncate(rawRes, 4000)
if err != nil {
item.Error = err.Error()
return
}
if len(out.Data) == 0 {
item.Error = "返回空 data"
return
}
if strings.TrimSpace(out.Data[0].URL) != "" {
item.ModelEcho = model
item.Summary = "生成成功(URL 模式)"
item.OutputPreview = out.Data[0].URL
return
}
if strings.TrimSpace(out.Data[0].B64JSON) != "" {
item.ModelEcho = model
item.Summary = "生成成功"
item.OutputPreview = "data:video/mp4;base64," + out.Data[0].B64JSON
return
}
item.Error = "响应缺少 url / b64_json"
}
// DiscoverModels 拉取 provider 端 /models 获取可用模型列表(自动发现)。
// 返回按字母排序的模型 ID 列表;不可达或失败时返回 nil + error。
func DiscoverModels(route *config.RouteConfig) ([]string, error) {
if route == nil {
return nil, fmt.Errorf("路由不存在")
}
models, err := httpGet(route, modelsPath)
if err != nil {
return nil, err
}
sort.Strings(models)
return models, nil
}
// MergeCandidates 将配置/已知候选与自动发现的模型合并去重。
// 优先保留调用方给定顺序;用于「候选逐个试跑」。
func MergeCandidates(known, discovered []string) []string {
seen := map[string]bool{}
out := make([]string, 0, len(known)+len(discovered))
for _, m := range append(append([]string{}, known...), discovered...) {
m = strings.TrimSpace(m)
if m == "" || seen[m] {
continue
}
seen[m] = true
out = append(out, m)
}
return out
}
// requiresAPIKey 判断该路由是否必须携带 API Key(只有云端 provider 才强制)。
// 本地服务(ollama / llamacpp 等)无需 key 也应能完成测试。
func requiresAPIKey(route *config.RouteConfig) bool {
if route == nil {
return false
}
baseURL := strings.ToLower(strings.TrimSpace(route.BaseURL))
if strings.Contains(baseURL, "openrouter.ai") || strings.Contains(baseURL, "openai.com") {
return true
}
provider := strings.ToLower(strings.TrimSpace(route.Provider))
return provider == "openrouter" || provider == "openai"
}
func truncate(s string, n int) string {
s = strings.TrimSpace(s)
if n <= 0 || len(s) <= n {
return s
}
return s[:n] + "...[截断]"
}