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
@@ -31,6 +31,12 @@ func main() {
cfg := config.Load()
// 启动前校验 AI 路由配置:若 ai_config.json 不可解析或默认路由无效,
// 立刻失败而不是等用户聊天时才爆"AI 模型不可用"。
if err := config.ValidateAIConfig(); err != nil {
log.Fatalf("AI 路由配置校验失败(请检查 config/ai_config.json): %v", err)
}
db, err := store.Init(cfg.DBPath)
if err != nil {
log.Fatalf("数据库初始化失败: %v", err)
@@ -1,40 +1,32 @@
{
"agent_routes": {
"embed_gen": "embed_route_openrouter_text_v3",
"embed_gen": "embed_route_llamacpp_nomic",
"image_gen": "image_route_openrouter_gpt_image_2",
"path_coach": "chat_route_lmuai_deepseek_flash",
"title_gen": "chat_route_lmuai_deepseek_flash"
"video_gen": "video_route_openrouter_veo_3",
"path_coach": "chat_route_lmuai_deepseek_v4_flash",
"title_gen": "chat_route_lmuai_deepseek_v4_flash"
},
"chat_routes": {
"chat_route_lmuai_deepseek_flash": {
"chat_route_lmuai_deepseek_v4_flash": {
"description": "LMUAI / DeepSeek Flash",
"endpoint": "/chat/completions",
"max_tokens": 4096,
"model": "deepseek-flash",
"model": "deepseek-v4-flash",
"provider": "lmuai",
"short_model_name": "DeepSeek Flash",
"short_route_name": "LMUAI",
"temperature": 0.7
},
"chat_route_siliconflow_deepseek_v4_flash": {
"description": "SiliconFlow / DeepSeek V4 Flash",
"endpoint": "/chat/completions",
"max_tokens": 4096,
"model": "deepseek-ai/DeepSeek-V4-Flash",
"provider": "siliconflow",
"short_model_name": "DeepSeek V4 Flash",
"short_route_name": "SiliconFlow",
"short_route_name": "LMUAI",
"temperature": 0.7
},
"chat_route_ollama_qwen": {
"description": "本地对话 · llama.cpp / Qwen3.5 4B",
"endpoint": "/chat/completions",
"max_tokens": 2048,
"max_tokens": 512,
"model": "Qwen3.5-4B-Q4_K_M.gguf",
"provider": "llamacpp",
"short_model_name": "Qwen3.5 4B",
"short_route_name": "本地对话",
"temperature": 0.7
"temperature": 0.7,
"timeout_seconds": 90
},
"chat_route_ollama_qwen_fast": {
"description": "本地快聊 · llama.cpp / Qwen3.5 4B",
@@ -85,12 +77,32 @@
"short_model_name": "Grok 4.20",
"short_route_name": "OpenRouter",
"temperature": 0.6
},
"chat_route_siliconflow_deepseek_v4_flash": {
"description": "SiliconFlow / DeepSeek V4 Flash",
"endpoint": "/chat/completions",
"max_tokens": 4096,
"model": "deepseek-ai/DeepSeek-V4-Flash",
"provider": "siliconflow",
"short_model_name": "DeepSeek V4 Flash",
"short_route_name": "SiliconFlow",
"temperature": 0.7
}
},
"default_embed_route": "embed_route_openrouter_text_v3",
"default_route": "chat_route_lmuai_deepseek_flash",
"default_embed_route": "embed_route_llamacpp_nomic",
"default_route": "chat_route_lmuai_deepseek_v4_flash",
"description": "eai_agentplatform AI 路由配置 — 按场景分类定义多条路由,支持 provider/模型切换",
"embed_routes": {
"embed_route_llamacpp_nomic": {
"base_url": "http://127.0.0.1:8081/v1",
"description": "本地向量 · llama.cpp / Nomic Embed v1.5",
"endpoint": "/embeddings",
"model": "nomic-embed-text-v1.5.Q4_K_M.gguf",
"provider": "llamacpp",
"short_model_name": "Nomic Embed v1.5",
"short_route_name": "本地向量",
"timeout_seconds": 30
},
"embed_route_ollama_bge_m3": {
"description": "本地向量 · Ollama / BGE-M3",
"endpoint": "/embeddings",
@@ -113,10 +125,6 @@
"chat_route_ollama_qwen_fast",
"chat_route_ollama_qwen"
],
"chat_route_siliconflow_deepseek_v4_flash": [
"chat_route_ollama_qwen_fast",
"chat_route_ollama_qwen"
],
"chat_route_openrouter_deepseek_v3": [
"chat_route_openrouter_grok_4",
"chat_route_ollama_qwen"
@@ -129,6 +137,13 @@
"chat_route_openrouter_grok_4": [
"chat_route_ollama_qwen"
],
"chat_route_siliconflow_deepseek_v4_flash": [
"chat_route_ollama_qwen_fast",
"chat_route_ollama_qwen"
],
"embed_route_llamacpp_nomic": [
"embed_route_openrouter_text_v3"
],
"embed_route_ollama_bge_m3": [
"embed_route_openrouter_text_v3"
],
@@ -168,5 +183,17 @@
"temperature": 1
}
},
"video_routes": {
"video_route_openrouter_veo_3": {
"description": "OpenRouter / Google Veo 3",
"endpoint": "/videos/generations",
"model": "google/veo-3",
"max_tokens": 4096,
"provider": "openrouter",
"short_model_name": "Veo 3",
"short_route_name": "OpenRouter",
"temperature": 0.7
}
},
"version": "2.1.0"
}
@@ -0,0 +1,199 @@
{
"agent_routes": {
"embed_gen": "embed_route_llamacpp_nomic",
"image_gen": "image_route_openrouter_gpt_image_2",
"video_gen": "video_route_openrouter_veo_3",
"path_coach": "chat_route_lmuai_deepseek_v4_flash",
"title_gen": "chat_route_lmuai_deepseek_v4_flash"
},
"chat_routes": {
"chat_route_lmuai_deepseek_v4_flash": {
"description": "LMUAI / DeepSeek Flash",
"endpoint": "/chat/completions",
"max_tokens": 4096,
"model": "deepseek-v4-flash",
"provider": "lmuai",
"short_model_name": "DeepSeek V4 Flash",
"short_route_name": "LMUAI",
"temperature": 0.7
},
"chat_route_ollama_qwen": {
"description": "本地对话 · llama.cpp / Qwen3.5 4B",
"endpoint": "/chat/completions",
"max_tokens": 512,
"model": "Qwen3.5-4B-Q4_K_M.gguf",
"provider": "llamacpp",
"short_model_name": "Qwen3.5 4B",
"short_route_name": "本地对话",
"temperature": 0.7,
"timeout_seconds": 90
},
"chat_route_ollama_qwen_fast": {
"description": "本地快聊 · llama.cpp / Qwen3.5 4B",
"endpoint": "/chat/completions",
"max_tokens": 512,
"model": "Qwen3.5-4B-Q4_K_M.gguf",
"provider": "llamacpp",
"short_model_name": "Qwen3.5 4B",
"short_route_name": "本地快聊",
"temperature": 0.3
},
"chat_route_openrouter_deepseek_v3": {
"description": "OpenRouter / DeepSeek V3.2",
"endpoint": "/chat/completions",
"max_tokens": 4096,
"model": "deepseek/deepseek-v3.2",
"provider": "openrouter",
"short_model_name": "DeepSeek V3.2",
"short_route_name": "OpenRouter",
"temperature": 0.6
},
"chat_route_openrouter_deepseek_v4_flash": {
"description": "OpenRouter / DeepSeek V4 Flash 0731",
"endpoint": "/chat/completions",
"max_tokens": 4096,
"model": "deepseek/deepseek-v4-flash-0731",
"provider": "openrouter",
"short_model_name": "DeepSeek V4 Flash 0731",
"short_route_name": "OpenRouter",
"temperature": 0.7
},
"chat_route_openrouter_gpt_5": {
"description": "OpenRouter / GPT 5.4",
"endpoint": "/chat/completions",
"max_tokens": 8192,
"model": "openai/gpt-5.4",
"provider": "openrouter",
"short_model_name": "GPT 5.4",
"short_route_name": "OpenRouter",
"temperature": 0.7
},
"chat_route_openrouter_grok_4": {
"description": "OpenRouter / Grok 4.20",
"endpoint": "/chat/completions",
"max_tokens": 4096,
"model": "x-ai/grok-4.20",
"provider": "openrouter",
"short_model_name": "Grok 4.20",
"short_route_name": "OpenRouter",
"temperature": 0.6
},
"chat_route_siliconflow_deepseek_v4_flash": {
"description": "SiliconFlow / DeepSeek V4 Flash",
"endpoint": "/chat/completions",
"max_tokens": 4096,
"model": "deepseek-ai/DeepSeek-V4-Flash",
"provider": "siliconflow",
"short_model_name": "DeepSeek V4 Flash",
"short_route_name": "SiliconFlow",
"temperature": 0.7
}
},
"default_embed_route": "embed_route_llamacpp_nomic",
"default_route": "chat_route_lmuai_deepseek_v4_flash",
"description": "eai_agentplatform AI 路由配置 — 按场景分类定义多条路由,支持 provider/模型切换",
"embed_routes": {
"embed_route_llamacpp_nomic": {
"base_url": "http://127.0.0.1:8081/v1",
"description": "本地向量 · llama.cpp / Nomic Embed v1.5",
"endpoint": "/embeddings",
"model": "nomic-embed-text-v1.5.Q4_K_M.gguf",
"provider": "llamacpp",
"short_model_name": "Nomic Embed v1.5",
"short_route_name": "本地向量",
"timeout_seconds": 30
},
"embed_route_ollama_bge_m3": {
"description": "本地向量 · Ollama / BGE-M3",
"endpoint": "/embeddings",
"model": "bge-m3",
"provider": "ollama",
"short_model_name": "BGE-M3",
"short_route_name": "本地向量"
},
"embed_route_openrouter_text_v3": {
"description": "OpenRouter / text-embedding-3-small",
"endpoint": "/embeddings",
"model": "openai/text-embedding-3-small",
"provider": "openrouter",
"short_model_name": "text-embedding-3-small",
"short_route_name": "OpenRouter"
}
},
"fallback_routes": {
"chat_route_lmuai_deepseek_flash": [
"chat_route_ollama_qwen_fast",
"chat_route_ollama_qwen"
],
"chat_route_openrouter_deepseek_v3": [
"chat_route_openrouter_grok_4",
"chat_route_ollama_qwen"
],
"chat_route_openrouter_deepseek_v4_flash": [
"chat_route_openrouter_deepseek_v3",
"chat_route_openrouter_grok_4",
"chat_route_ollama_qwen"
],
"chat_route_openrouter_grok_4": [
"chat_route_ollama_qwen"
],
"chat_route_siliconflow_deepseek_v4_flash": [
"chat_route_ollama_qwen_fast",
"chat_route_ollama_qwen"
],
"embed_route_llamacpp_nomic": [
"embed_route_openrouter_text_v3"
],
"embed_route_ollama_bge_m3": [
"embed_route_openrouter_text_v3"
],
"embed_route_openrouter_text_v3": [
"embed_route_ollama_bge_m3"
]
},
"image_routes": {
"image_route_ollama_llava": {
"description": "本地图像 · Ollama / LLaVA",
"endpoint": "/chat/completions",
"max_tokens": 2048,
"model": "llava",
"provider": "ollama",
"short_model_name": "LLaVA",
"short_route_name": "本地图像",
"temperature": 0.7
},
"image_route_openrouter_flux": {
"description": "OpenRouter / FLUX 1.1 Pro",
"endpoint": "/images/generations",
"max_tokens": 1024,
"model": "black-forest-labs/flux-1.1-pro",
"provider": "openrouter",
"short_model_name": "FLUX 1.1 Pro",
"short_route_name": "OpenRouter",
"temperature": 1
},
"image_route_openrouter_gpt_image_2": {
"description": "OpenRouter / GPT 5.4 Image 2",
"endpoint": "/images/generations",
"max_tokens": 4096,
"model": "openai/gpt-5.4-image-2",
"provider": "openrouter",
"short_model_name": "GPT 5.4 Image 2",
"short_route_name": "OpenRouter",
"temperature": 1
}
},
"video_routes": {
"video_route_openrouter_veo_3": {
"description": "OpenRouter / Google Veo 3",
"endpoint": "/videos/generations",
"model": "google/veo-3",
"max_tokens": 4096,
"provider": "openrouter",
"short_model_name": "Veo 3",
"short_route_name": "OpenRouter",
"temperature": 0.7
}
},
"version": "2.1.0"
}
+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] + "...[截断]"
}
@@ -3,6 +3,7 @@ package api
import (
"encoding/json"
"fmt"
"log"
"net/http"
"strings"
"time"
@@ -14,6 +15,7 @@ import (
"eai_agentplatform/backend/internal/middleware"
"eai_agentplatform/backend/internal/model"
specialistruntime "eai_agentplatform/backend/internal/specialists/runtime"
"eai_agentplatform/backend/internal/store"
"eai_agentplatform/backend/internal/web"
)
@@ -37,10 +39,11 @@ func ChatMessage(c *gin.Context) {
return
}
var req struct {
Message string `json:"message"`
Context map[string]any `json:"context"`
History []ai.Message `json:"history"`
AIRouteID string `json:"ai_route_id"`
Message string `json:"message"`
ConversationID string `json:"conversation_id"`
Context map[string]any `json:"context"`
History []ai.Message `json:"history"`
AIRouteID string `json:"ai_route_id"`
}
if err := c.ShouldBindJSON(&req); err != nil || strings.TrimSpace(req.Message) == "" {
web.Fail(c, web.NewBadRequest("message 必填"))
@@ -52,6 +55,11 @@ func ChatMessage(c *gin.Context) {
return
}
// 完整对话落库:先确保会话存在,再写用户消息(失败只记日志,不影响主流程)。
spaceKey := extractKnowledgeSpaceKey(req.Context)
convID := ensureChatConversation(req.ConversationID, user, req.Context, spaceKey)
saveChatMessageRecord(convID, user.ID, "user", req.Message)
c.Writer.Header().Set("Content-Type", "text/event-stream")
c.Writer.Header().Set("Cache-Control", "no-cache")
c.Writer.Header().Set("Connection", "keep-alive")
@@ -79,6 +87,7 @@ func ChatMessage(c *gin.Context) {
if len(plan.LLMMessages) == 0 {
writeEvent(gin.H{"type": "text", "content": plan.Answer})
writeEvent(gin.H{"type": "done"})
saveChatMessageRecord(convID, user.ID, "assistant", plan.Answer)
return
}
@@ -96,7 +105,9 @@ func ChatMessage(c *gin.Context) {
return
}
start := time.Now()
var replyBuilder strings.Builder
usedAiRoute, err := ai.GenerateStreamWithFallback(aiRoute, plan.LLMMessages, func(chunk string) {
replyBuilder.WriteString(chunk)
writeEvent(gin.H{"type": "text", "content": chunk})
})
if err != nil {
@@ -107,6 +118,7 @@ func ChatMessage(c *gin.Context) {
message = "当前知识库已进入 LLM 兜底层,但本机没有可用的模型服务正在运行。请先启动本地 Ollama,或补充可用的远端模型配置。"
}
writeEvent(gin.H{"type": "error", "message": message})
saveChatMessageRecord(convID, user.ID, "assistant", message)
ai.LogCall(ai.LogEntry{
UserID: user.ID, UsageKind: ai.UsageKindAIChat, Provider: aiRoute.Provider,
AIRouteID: aiRoute.RouteID, Model: aiRoute.Model, Success: false,
@@ -115,6 +127,7 @@ func ChatMessage(c *gin.Context) {
return
}
writeEvent(gin.H{"type": "done"})
saveChatMessageRecord(convID, user.ID, "assistant", replyBuilder.String())
finalAiRoute := aiRoute
if usedAiRoute != nil {
finalAiRoute = usedAiRoute
@@ -128,6 +141,60 @@ func ChatMessage(c *gin.Context) {
// buildSystemPrompt 已迁移至 internal/specialists/runtime(specialistruntime.BuildSystemPrompt)。
// chatSpecialistKey 尽量从请求上下文里取专员 key,取不到就留空,不报错。
func chatSpecialistKey(ctx map[string]any) string {
if ctx == nil {
return ""
}
for _, key := range []string{"specialist_key", "specialistKey", "role_key"} {
if raw, ok := ctx[key]; ok {
s := strings.TrimSpace(fmt.Sprint(raw))
if s != "" && s != "general" && s != "all" {
return s
}
}
}
return ""
}
// ensureChatConversation 返回业务会话ID:优先用请求传入的;为空则新建一个会话落库。
func ensureChatConversation(convID string, user *model.User, ctx map[string]any, spaceKey string) string {
convID = strings.TrimSpace(convID)
if convID != "" {
return convID
}
convID = fmt.Sprintf("conv-%d", time.Now().UnixNano())
conv := model.ChatConversation{
UserID: user.ID,
ConversationID: convID,
SpecialistKey: chatSpecialistKey(ctx),
SpaceKey: spaceKey,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if err := store.DB.Create(&conv).Error; err != nil {
log.Printf("创建 ChatConversation 失败: %v", err)
}
return convID
}
// saveChatMessageRecord 落库一条对话消息;失败只记日志,不中断主流程。
func saveChatMessageRecord(conversationID string, userID uint, role, content string) {
if conversationID == "" {
return
}
rec := model.ChatMessage{
ConversationID: conversationID,
UserID: userID,
Role: role,
Content: content,
CreatedAt: time.Now(),
}
if err := store.DB.Create(&rec).Error; err != nil {
log.Printf("写入 ChatMessage 失败: %v", err)
}
}
// QuickActions GET /api/ai-chat/quick-actions —— 3 个快捷按钮
func QuickActions(c *gin.Context) {
web.OK(c, gin.H{"actions": []gin.H{
@@ -0,0 +1,84 @@
// ai_routetest.go —— 「AI 通路测试」的管理员接口。
//
// 用于开发/运维对某个 AI 模型路由/具体模型做一次完整业务验证:
// - 获取某路由的候选模型清单(配置 model ∪ 自动发现 /models)
// - 对单个模型 或 候选模型逐个,发起一次真实请求并记录原始报文 / 产物预览
//
// 设计约束:本接口的测试调用**不扣算力点、不写 ai_call_log**,纯后台诊断。
package api
import (
"encoding/json"
"io"
"github.com/gin-gonic/gin"
"eai_agentplatform/backend/internal/ai/routetest"
"eai_agentplatform/backend/internal/config"
"eai_agentplatform/backend/internal/web"
)
// AIRouteTestModels POST /api/ai/route-test/models
// 入参:{ "route_id": "..." },返回该路由可测的候选模型清单 + 路由信息。
func AIRouteTestModels(c *gin.Context) {
var req struct {
RouteID string `json:"route_id"`
}
if err := c.ShouldBindJSON(&req); err != nil || req.RouteID == "" {
web.Fail(c, web.NewBadRequest("请提供 route_id"))
return
}
route, err := config.GetRoute(req.RouteID)
if err != nil || route == nil {
web.Fail(c, web.NewBadRequest("路由不存在: "+req.RouteID))
return
}
candidates := []string{route.Model}
discovered, _ := routetest.DiscoverModels(route)
candidates = routetest.MergeCandidates(candidates, discovered)
if len(candidates) == 0 {
candidates = []string{route.Model}
}
web.OK(c, gin.H{
"route_id": route.RouteID,
"provider": route.Provider,
"category": route.Category,
"base_url": route.BaseURL,
"endpoint": route.Endpoint,
"config_model": route.Model,
"candidates": candidates,
"discovered": discovered,
})
}
// AIRouteTestRun POST /api/ai/route-test/run
// 入参:{ "route_id": "...", "models": ["..."], "prompt": "...", "size": "1024x1024" }
// models 为空 → 只测配置 model;填写多个 → 逐个测试(批量试跑)。
func AIRouteTestRun(c *gin.Context) {
raw, err := io.ReadAll(c.Request.Body)
if err != nil {
web.Fail(c, web.NewBadRequest("读取请求体失败"))
return
}
var req struct {
RouteID string `json:"route_id"`
Models []string `json:"models"`
Prompt string `json:"prompt"`
Size string `json:"size"`
}
if err := json.Unmarshal(raw, &req); err != nil || req.RouteID == "" {
web.Fail(c, web.NewBadRequest("请提供合法的 route_id / models"))
return
}
route, err := config.GetRoute(req.RouteID)
if err != nil || route == nil {
web.Fail(c, web.NewBadRequest("路由不存在: "+req.RouteID))
return
}
opts := &routetest.TestOptions{Prompt: req.Prompt, Models: req.Models, Size: req.Size}
result := routetest.Do(route, opts)
web.OK(c, gin.H{"result": result})
}
@@ -0,0 +1,82 @@
package api
import (
"github.com/gin-gonic/gin"
"eai_agentplatform/backend/internal/middleware"
"eai_agentplatform/backend/internal/model"
"eai_agentplatform/backend/internal/store"
"eai_agentplatform/backend/internal/web"
)
// ListConversations GET /api/ai-chat/conversations —— 当前用户的会话列表(按最近更新倒序)。
// 每个会话含 ConversationID、Title、SpaceKey、SpecialistKey、CreatedAt、
// 最后一条消息摘要 last_content、消息数 message_count。
func ListConversations(c *gin.Context) {
user := middleware.CurrentUser(c)
if user == nil {
web.Fail(c, web.NewAuthError("未登录"))
return
}
var convs []model.ChatConversation
if err := store.DB.Where("user_id = ?", user.ID).
Order("updated_at DESC").Order("created_at DESC").Find(&convs).Error; err != nil {
web.Fail(c, web.NewBadRequest("会话列表查询失败"))
return
}
items := make([]gin.H, 0, len(convs))
for _, conv := range convs {
var messageCount int64
store.DB.Model(&model.ChatMessage{}).
Where("conversation_id = ? AND user_id = ?", conv.ConversationID, user.ID).
Count(&messageCount)
lastContent := ""
var last model.ChatMessage
if err := store.DB.Where("conversation_id = ? AND user_id = ?", conv.ConversationID, user.ID).
Order("created_at DESC").First(&last).Error; err == nil {
lastContent = last.Content
}
items = append(items, gin.H{
"conversation_id": conv.ConversationID,
"title": conv.Title,
"space_key": conv.SpaceKey,
"specialist_key": conv.SpecialistKey,
"created_at": conv.CreatedAt,
"last_content": lastContent,
"message_count": messageCount,
})
}
web.OK(c, items)
}
// ConversationMessages GET /api/ai-chat/conversations/:id/messages —— 某会话的全部消息(时间升序)。
func ConversationMessages(c *gin.Context) {
user := middleware.CurrentUser(c)
if user == nil {
web.Fail(c, web.NewAuthError("未登录"))
return
}
convID := c.Param("id")
var conv model.ChatConversation
if err := store.DB.Where("conversation_id = ? AND user_id = ?", convID, user.ID).First(&conv).Error; err != nil {
web.Fail(c, web.NewNotFoundError("会话不存在"))
return
}
var msgs []model.ChatMessage
if err := store.DB.Where("conversation_id = ? AND user_id = ?", convID, user.ID).
Order("created_at ASC").Find(&msgs).Error; err != nil {
web.Fail(c, web.NewBadRequest("消息查询失败"))
return
}
items := make([]gin.H, 0, len(msgs))
for _, m := range msgs {
items = append(items, gin.H{
"role": m.Role,
"content": m.Content,
"meta": m.Meta,
"created_at": m.CreatedAt,
})
}
web.OK(c, items)
}
@@ -1,6 +1,7 @@
package api
import (
"fmt"
"time"
"github.com/gin-gonic/gin"
@@ -169,7 +170,8 @@ func callChatModel(userID uint, req ChatMessageRequest, specialist *specialistmo
aiRoute, err := config.GetRoute(aiRouteID)
if err != nil {
logEntry.ErrorMessage = err.Error()
return "抱歉,当前后台AI模型不可用,请检查 AI 路由配置。", logEntry
errMsg := fmt.Sprintf("抱歉,当前后台AI模型不可用,请检查 AI 路由配置。详情:%v", err)
return errMsg, logEntry
}
logEntry.Provider = aiRoute.Provider
logEntry.Model = aiRoute.Model
@@ -11,7 +11,7 @@ import (
"eai_agentplatform/backend/internal/middleware"
skillapi "eai_agentplatform/backend/internal/skills/api"
specialistapi "eai_agentplatform/backend/internal/specialists/api"
wechatofficialaccountapi "eai_agentplatform/backend/internal/specialists/packages/wechat_official_account/api"
weixinpublicaccountapi "eai_agentplatform/backend/internal/specialists/packages/weixin_public_account/api"
"eai_agentplatform/backend/internal/specialists/runtime"
xappapi "eai_agentplatform/backend/internal/xapps/api"
)
@@ -73,13 +73,13 @@ func RegisterRoutes(r *gin.Engine, cfg *config.Config) {
r.DELETE("/api/projects/:id", middleware.Auth(cfg), DeleteProject)
r.GET("/api/projects/:id/tasks", middleware.Auth(cfg), ListProjectTasks)
r.GET("/api/artifacts/:id", middleware.Auth(cfg), GetArtifactDetail)
r.POST("/api/official-account/tasks", middleware.Auth(cfg), wechatofficialaccountapi.CreateOfficialAccountTask)
r.GET("/api/official-account/tasks/:id/workflow", middleware.Auth(cfg), wechatofficialaccountapi.GetOfficialAccountWorkflow)
r.PUT("/api/official-account/tasks/:id", middleware.Auth(cfg), wechatofficialaccountapi.UpdateOfficialAccountTask)
r.POST("/api/official-account/tasks/:id/steps/:stepKey", middleware.Auth(cfg), wechatofficialaccountapi.ExecuteOfficialAccountWorkflowStep)
r.POST("/api/official-account/tasks/:id/images/:imageKey/regenerate", middleware.Auth(cfg), wechatofficialaccountapi.RegenerateOfficialAccountImage)
r.GET("/api/official-account/tasks/:id/export", middleware.Auth(cfg), wechatofficialaccountapi.ExportOfficialAccountDocument)
r.GET("/api/official-account/generated-images/:filename", middleware.Auth(cfg), wechatofficialaccountapi.ServeOfficialAccountGeneratedImage)
r.POST("/api/weixin-public-account/tasks", middleware.Auth(cfg), weixinpublicaccountapi.CreateWeixinPublicAccountTask)
r.GET("/api/weixin-public-account/tasks/:id/workflow", middleware.Auth(cfg), weixinpublicaccountapi.GetWeixinPublicAccountWorkflow)
r.PUT("/api/weixin-public-account/tasks/:id", middleware.Auth(cfg), weixinpublicaccountapi.UpdateWeixinPublicAccountTask)
r.POST("/api/weixin-public-account/tasks/:id/steps/:stepKey", middleware.Auth(cfg), weixinpublicaccountapi.ExecuteWeixinPublicAccountWorkflowStep)
r.POST("/api/weixin-public-account/tasks/:id/images/:imageKey/regenerate", middleware.Auth(cfg), weixinpublicaccountapi.RegenerateWeixinPublicAccountImage)
r.GET("/api/weixin-public-account/tasks/:id/export", middleware.Auth(cfg), weixinpublicaccountapi.ExportWeixinPublicAccountDocument)
r.GET("/api/weixin-public-account/generated-images/:filename", middleware.Auth(cfg), weixinpublicaccountapi.ServeWeixinPublicAccountGeneratedImage)
r.GET("/api/connectors", middleware.Auth(cfg), connectorapi.ListConnectors)
r.GET("/api/connectors/:key", middleware.Auth(cfg), connectorapi.GetConnector)
r.POST("/api/connectors/:key/query", middleware.Auth(cfg), connectorapi.QueryConnector)
@@ -123,6 +123,8 @@ func RegisterRoutes(r *gin.Engine, cfg *config.Config) {
// ── AI 对话(普通员工可访问,管理员访问)──
r.POST("/api/ai-chat/message", middleware.Auth(cfg), ChatMessage)
r.GET("/api/ai-chat/conversations", middleware.Auth(cfg), ListConversations)
r.GET("/api/ai-chat/conversations/:id/messages", middleware.Auth(cfg), ConversationMessages)
r.GET("/api/ai-chat/quick-actions", middleware.Auth(cfg), QuickActions)
r.POST("/api/ai-chat/quick-action", middleware.Auth(cfg), QuickAction)
r.GET("/api/ai/usage", middleware.Auth(cfg), AIUsage)
@@ -259,5 +261,9 @@ func RegisterRoutes(r *gin.Engine, cfg *config.Config) {
admin.POST("/ai/reload", ReloadAIConfig)
admin.GET("/ai/secrets-status", SecretsStatusHandler)
admin.GET("/ai/usage/users", AIUsageUsers)
// AI 通路测试(不扣算力点、不写调用日志)
admin.POST("/ai/route-test/models", AIRouteTestModels)
admin.POST("/ai/route-test/run", AIRouteTestRun)
}
}
@@ -133,7 +133,7 @@ func setupAPITestDB(t *testing.T) {
for _, s := range []specialistmodel.Specialist{
{Key: "contract-review", Label: "合同审查专员", State: "active", Tier: "industry", ObjectEntryRoute: "/apps/contract-review"},
{Key: "wechat-official-account", Label: "公众号创作专员", State: "active", Tier: "generic", ObjectEntryRoute: "/apps/wechat-official-account"},
{Key: "weixin-public-account", Label: "公众号创作专员", State: "active", Tier: "generic", ObjectEntryRoute: "/apps/weixin-public-account"},
{Key: "retired-one", Label: "已下线专员", State: "inactive", Tier: "generic", ObjectEntryRoute: "/apps/retired"},
{Key: "general-assistant", Label: "通用助手", State: "system", Tier: "generic", ObjectEntryRoute: "/home"},
} {
@@ -168,7 +168,7 @@ func TestResolveSpecialistTaskWinsOverRequest(t *testing.T) {
t.Fatalf("建任务失败: %v", err)
}
got := resolveSpecialist(ChatMessageRequest{TaskID: task.ID, SpecialistKey: "wechat-official-account"})
got := resolveSpecialist(ChatMessageRequest{TaskID: task.ID, SpecialistKey: "weixin-public-account"})
if got == nil || got.Key != "contract-review" {
t.Fatalf("任务上的专员应优先,实际 %v", got)
}
@@ -178,8 +178,8 @@ func TestResolveSpecialistTaskWinsOverRequest(t *testing.T) {
func TestResolveSpecialistFallsBackToRequest(t *testing.T) {
setupAPITestDB(t)
got := resolveSpecialist(ChatMessageRequest{SpecialistKey: "wechat-official-account"})
if got == nil || got.Key != "wechat-official-account" {
got := resolveSpecialist(ChatMessageRequest{SpecialistKey: "weixin-public-account"})
if got == nil || got.Key != "weixin-public-account" {
t.Fatalf("应回退到请求里的 specialist_key,实际 %v", got)
}
}
@@ -228,8 +228,8 @@ func TestResolveSpecialistTaskWithoutSpecialistFallsBack(t *testing.T) {
t.Fatalf("建任务失败: %v", err)
}
got := resolveSpecialist(ChatMessageRequest{TaskID: task.ID, SpecialistKey: "wechat-official-account"})
if got == nil || got.Key != "wechat-official-account" {
got := resolveSpecialist(ChatMessageRequest{TaskID: task.ID, SpecialistKey: "weixin-public-account"})
if got == nil || got.Key != "weixin-public-account" {
t.Fatalf("任务专员失效时应回退到请求里的 key,实际 %v", got)
}
}
@@ -11,7 +11,7 @@ import (
"eai_agentplatform/backend/internal/dal"
"eai_agentplatform/backend/internal/middleware"
"eai_agentplatform/backend/internal/model"
wechatofficialaccountapi "eai_agentplatform/backend/internal/specialists/packages/wechat_official_account/api"
weixinpublicaccountapi "eai_agentplatform/backend/internal/specialists/packages/weixin_public_account/api"
specialistruntime "eai_agentplatform/backend/internal/specialists/runtime"
"eai_agentplatform/backend/internal/web"
)
@@ -60,9 +60,9 @@ func GetTaskDetail(c *gin.Context) {
artifacts := taskArtifactDAO.ListByTask(id)
runs := taskRunDAO.ListByTask(id)
if task.SpecialistKey == wechatofficialaccountapi.OfficialAccountSpecialistKey {
artifacts = wechatofficialaccountapi.CompactOfficialAccountArtifactsForResponse(artifacts)
runs = wechatofficialaccountapi.CompactOfficialAccountRunsForResponse(runs)
if task.SpecialistKey == weixinpublicaccountapi.WeixinPublicAccountSpecialistKey {
artifacts = weixinpublicaccountapi.CompactWeixinPublicAccountArtifactsForResponse(artifacts)
runs = weixinpublicaccountapi.CompactWeixinPublicAccountRunsForResponse(runs)
}
web.OK(c, gin.H{
@@ -21,6 +21,8 @@ type RouteInfo struct {
Endpoint string `json:"endpoint"`
MaxTokens int `json:"max_tokens"`
Temperature float64 `json:"temperature"`
BaseURL string `json:"base_url,omitempty"` // 可选:覆盖 provider 默认 base_url(如本地服务非默认端口时)
TimeoutSeconds int `json:"timeout_seconds,omitempty"` // 可选:HTTP 超时(秒),默认 60
Category string `json:"category,omitempty"`
Description string `json:"description,omitempty"`
ShortRouteName string `json:"short_route_name,omitempty"`
@@ -38,6 +40,7 @@ type RouteConfig struct {
APIKey string
MaxTokens int
Temperature float64
TimeoutSeconds int // HTTP 超时(秒),0 表示使用默认
Category string // chat / embed / image
Description string
ShortRouteName string
@@ -54,6 +57,7 @@ type AIConfig struct {
ChatRoutes map[string]RouteInfo `json:"chat_routes"`
EmbedRoutes map[string]RouteInfo `json:"embed_routes"`
ImageRoutes map[string]RouteInfo `json:"image_routes"`
VideoRoutes map[string]RouteInfo `json:"video_routes"`
FallbackRoutes map[string][]string `json:"fallback_routes"`
// 兼容旧版平铺 routes(若有则回退)
Routes map[string]RouteInfo `json:"routes,omitempty"`
@@ -201,6 +205,41 @@ func LoadAIConfig(forceReload ...bool) (*AIConfig, error) {
return &cfg, nil
}
// ValidateAIConfig 启动时强校验:确保 ai_config.json 可解析,且默认路由与
// agent 映射所指向的路由均可解析。任一步失败即返回错误,由调用方(main)
// 直接拒绝启动,从而把"配置损坏"这类问题在启动阶段暴露,而不是拖到用户
// 聊天时才报"AI 模型不可用"。
//
// 注意:本文件及 config/ai_config.json 保持多行、带缩进的普通格式。
// 严禁把 JSON 或本函数折叠成单行——单行会让格式错误难定位、diff 难读。
// (历史教训:ai_config.json 曾因缺一个右花括号导致整个 AI 路由不可用,
// 且文件原本被压成单行 5KB,错误极难排查。)
func ValidateAIConfig() error {
aiCfg, err := LoadAIConfig()
if err != nil {
return err
}
if aiCfg.Version == "" {
return fmt.Errorf("缺少 version 字段")
}
// 校验 default_route 与 default_embed_route 指向的路由真实存在且可解析
for _, id := range []string{aiCfg.DefaultRoute, aiCfg.DefaultEmbedRoute} {
if id == "" {
continue
}
if _, err := GetRoute(id); err != nil {
return fmt.Errorf("默认路由 %q 不可用: %w", id, err)
}
}
// 校验 agent_routes 中每个映射目标均可解析
for agent, rid := range aiCfg.AgentRoutes {
if _, err := GetRoute(rid); err != nil {
return fmt.Errorf("agent[%q] -> 路由 %q 不可用: %w", agent, rid, err)
}
}
return nil
}
func LoadAISecrets(forceReload ...bool) (*AISecrets, error) {
fr := len(forceReload) > 0 && forceReload[0]
if !fr {
@@ -301,9 +340,13 @@ func GetRoute(agentOrRouteID string) (*RouteConfig, error) {
apiKey = getSecretByField(secrets, secretField)
}
// base_url: 先从 ProviderDefaultBaseURL 取
if url, ok := ProviderDefaultBaseURL[info.Provider]; ok {
baseURL = url
// base_url: 优先取路由级 base_url 覆盖,否则用 ProviderDefaultBaseURL
if info.BaseURL != "" {
baseURL = info.BaseURL
} else {
if url, ok := ProviderDefaultBaseURL[info.Provider]; ok {
baseURL = url
}
}
// 组装
@@ -319,6 +362,7 @@ func GetRoute(agentOrRouteID string) (*RouteConfig, error) {
APIKey: apiKey,
MaxTokens: info.MaxTokens,
Temperature: info.Temperature,
TimeoutSeconds: info.TimeoutSeconds,
Category: category,
Description: info.Description,
ShortRouteName: info.ShortRouteName,
@@ -350,6 +394,11 @@ func findRoute(cfg *AIConfig, routeID string) (RouteInfo, string, bool) {
return info, "image", true
}
}
if cfg.VideoRoutes != nil {
if info, ok := cfg.VideoRoutes[routeID]; ok {
return info, "video", true
}
}
// 兼容旧版平铺 routes
if cfg.Routes != nil {
if info, ok := cfg.Routes[routeID]; ok {
@@ -429,6 +478,8 @@ func GetRoutesByCategory(category string) ([]*RouteConfig, error) {
routeMap = aiCfg.EmbedRoutes
case "image":
routeMap = aiCfg.ImageRoutes
case "video":
routeMap = aiCfg.VideoRoutes
default:
return nil, fmt.Errorf("未知路由分类: %s", category)
}
@@ -491,6 +542,11 @@ func SaveAIConfig(raw []byte) error {
}
path := resolveConfigPath("ai_config.json")
// 写前备份:把当前可用版本复制为 .bak,防止一次写坏后无法恢复。
// 备份失败不影响主变更(仅告警日志)。
backupAIConfig(path)
tmp := path + ".tmp"
if err := os.WriteFile(tmp, raw, 0o644); err != nil {
return fmt.Errorf("写入临时文件失败: %w", err)
@@ -505,6 +561,21 @@ func SaveAIConfig(raw []byte) error {
return nil
}
// backupAIConfig 把当前 ai_config.json 复制到 ai_config.json.bak。
// 若目标不存在或读取失败则静默跳过(首次写入或尚未建文件时无备份可做)。
func backupAIConfig(path string) {
data, err := os.ReadFile(path)
if err != nil {
return
}
// 仅当现有文件是合法 JSON 时才备份,避免把上一份已经损坏的内容固化成备份。
var probe AIConfig
if err := json.Unmarshal(data, &probe); err != nil || probe.Version == "" {
return
}
_ = os.WriteFile(path+".bak", data, 0o644)
}
// ResetCache 清空全部配置缓存(热重载,无需重启)
func ResetCache() {
mu.Lock()
@@ -5,14 +5,14 @@ import (
"gorm.io/gorm"
oaamodel "eai_agentplatform/backend/internal/specialists/packages/wechat_official_account/model"
oaamodel "eai_agentplatform/backend/internal/specialists/packages/weixin_public_account/model"
)
// OfficialAccountArticle 公众号文章状态仓库(一个任务一篇,task_id 上有唯一索引)。
// WeixinPublicAccountArticle 公众号文章状态仓库(一个任务一篇,task_id 上有唯一索引)。
//
// 模型定义在专员技能包内(specialists/packages/wechat_official_account/model),
// 模型定义在专员技能包内(specialists/packages/weixin_public_account/model),
// 原先这个包自己拿 store.DB 读写,收口后统一走这里。
type OfficialAccountArticleDAO struct{ *QueryBuilder }
type WeixinPublicAccountArticleDAO struct{ *QueryBuilder }
// FindByTaskID 按任务取文章状态。三种结果,**调用方必须分清**:
// - row != nil 找到了
@@ -23,8 +23,8 @@ type OfficialAccountArticleDAO struct{ *QueryBuilder }
// `if err == nil { return article }` 其余一律往下走新建 —— 也就是说读取
// 真出错时也会去建一篇新的。task_id 上有唯一索引,这种误建多半会撞唯一键
// 而失败,但那是运气不是设计。(同 UserXAppCenterDAO.FindByUser)
func (r OfficialAccountArticleDAO) FindByTaskID(taskID uint) (*oaamodel.OfficialAccountArticle, error) {
var row oaamodel.OfficialAccountArticle
func (r WeixinPublicAccountArticleDAO) FindByTaskID(taskID uint) (*oaamodel.WeixinPublicAccountArticle, error) {
var row oaamodel.WeixinPublicAccountArticle
err := r.Inner().Where("task_id = ?", taskID).First(&row).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
@@ -36,11 +36,11 @@ func (r OfficialAccountArticleDAO) FindByTaskID(taskID uint) (*oaamodel.Official
}
// Insert 新建文章状态。
func (r OfficialAccountArticleDAO) Insert(a *oaamodel.OfficialAccountArticle) bool {
func (r WeixinPublicAccountArticleDAO) Insert(a *oaamodel.WeixinPublicAccountArticle) bool {
return r.QueryBuilder.Insert(a)
}
// Update 保存文章状态(改标题/提纲/正文/配图等一律走这里)。
func (r OfficialAccountArticleDAO) Update(a *oaamodel.OfficialAccountArticle) bool {
func (r WeixinPublicAccountArticleDAO) Update(a *oaamodel.WeixinPublicAccountArticle) bool {
return r.Save(a)
}
@@ -5,18 +5,18 @@ import (
"gorm.io/gorm/clause"
oahmodel "eai_agentplatform/backend/internal/specialists/packages/wechat_official_account/model"
oahmodel "eai_agentplatform/backend/internal/specialists/packages/weixin_public_account/model"
)
// OfficialAccountHotspot 公众号热点缓存仓库。
// WeixinPublicAccountHotspot 公众号热点缓存仓库。
//
// 表里存的是抓取回来的 RSS/网页条目及其领域评分,按 (source_key, url)
// 唯一,重复抓到同一条要就地更新而不是堆新行。
type OfficialAccountHotspotDAO struct{ *QueryBuilder }
type WeixinPublicAccountHotspotDAO struct{ *QueryBuilder }
// hotspotListLimit 单次取回的热点条数上限。
//
// 调用方(loadFreshOfficialAccountHotspots)取回后还要按关键词在内存里再滤一遍,
// 调用方(loadFreshWeixinPublicAccountHotspots)取回后还要按关键词在内存里再滤一遍,
// 所以这里多取一些留余量 —— 这个 40 是原实现写死的值,收口时原样保留。
const hotspotListLimit = 40
@@ -33,8 +33,8 @@ const hotspotListLimit = 40
// 调用方会走到不依赖热点的兜底选题路径。这个降级是有意接受的 ——
// 数据访问层统一是 bool / 裸返回的风格(同 TaskRunDAO.ListByTask),
// 不为一次缓存读再造一个 error 出口;但这是**行为变化**,不是等价重构。
func (r OfficialAccountHotspotDAO) ListFresh(domainKey string, cutoff time.Time) []oahmodel.OfficialAccountHotspot {
var items []oahmodel.OfficialAccountHotspot
func (r WeixinPublicAccountHotspotDAO) ListFresh(domainKey string, cutoff time.Time) []oahmodel.WeixinPublicAccountHotspot {
var items []oahmodel.WeixinPublicAccountHotspot
if r.Type(&items).
Where("business_domain = ? AND fetched_at >= ? AND domain_score > 0", domainKey, cutoff).
Order("domain_score DESC, published_at DESC, id DESC").
@@ -50,7 +50,7 @@ func (r OfficialAccountHotspotDAO) ListFresh(domainKey string, cutoff time.Time)
// 这里返回 error 而不是 bool,是因为调用方要把 err.Error() 拼进给用户看的
// 抓取日志(「热点入库失败:…」)——bool 装不下这句话,丢掉它用户就只看到
// 「本轮未写入新热点」,无从知道是库的问题还是源站的问题。
func (r OfficialAccountHotspotDAO) UpsertAll(rows []oahmodel.OfficialAccountHotspot) error {
func (r WeixinPublicAccountHotspotDAO) UpsertAll(rows []oahmodel.WeixinPublicAccountHotspot) error {
if len(rows) == 0 {
return nil
}
@@ -0,0 +1,39 @@
package middleware
import (
crand "crypto/rand"
"encoding/hex"
"github.com/gin-gonic/gin"
"eai_agentplatform/backend/internal/web"
)
// RequestID 为每个请求注入追踪编号:优先沿用调用方传入的 X-Request-ID,
// 否则生成 8 字节随机十六进制串。编号写入 Gin Context 与请求头,
// 由 web.OK / web.Fail 统一回写到响应头与错误体,支撑前后端与日志联查。
func RequestID() gin.HandlerFunc {
return func(c *gin.Context) {
rid := c.Request.Header.Get("X-Request-ID")
if rid == "" {
rid = newRequestID()
}
c.Set(web.CtxRequestID, rid)
c.Request.Header.Set("X-Request-ID", rid)
defer func() {
// 无论 handler 是否写入响应头,统一保证带上请求编号
c.Writer.Header().Set("X-Request-ID", c.GetString(web.CtxRequestID))
}()
c.Next()
}
}
func newRequestID() string {
b := make([]byte, 8)
if _, err := crand.Read(b); err != nil {
return "req-00000000"
}
return hex.EncodeToString(b)
}
@@ -0,0 +1,17 @@
package model
import "time"
// ChatConversation 一次完整的多轮对话(业务会话),用于聊天记录持久化与回溯。
type ChatConversation struct {
ID uint `gorm:"primaryKey" json:"id"`
UserID uint `gorm:"not null;index" json:"user_id"`
ConversationID string `gorm:"size:128;uniqueIndex;not null" json:"conversation_id"` // 业务会话ID
SpecialistKey string `gorm:"size:64" json:"specialist_key"`
SpaceKey string `gorm:"size:64" json:"space_key"`
Title string `gorm:"size:256" json:"title"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (ChatConversation) TableName() string { return "chat_conversation" }
@@ -0,0 +1,17 @@
package model
import "time"
// ChatMessage 单条对话消息(用户提问或 AI 回复),归属某个 ChatConversation。
type ChatMessage struct {
ID uint `gorm:"primaryKey" json:"id"`
ConversationID string `gorm:"size:128;index;not null" json:"conversation_id"`
UserID uint `gorm:"not null;index" json:"user_id"`
Role string `gorm:"size:16;not null;index" json:"role"` // user / assistant
Content string `gorm:"type:text" json:"content"`
// Meta 可选结构化信息(如 citations / layer / intent 等),以 JSON 字符串存储。
Meta string `gorm:"type:text" json:"meta,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
func (ChatMessage) TableName() string { return "chat_message" }
@@ -5,11 +5,11 @@ import (
customerfollowup "eai_agentplatform/backend/internal/specialists/packages/customer_followup"
resumeprocessor "eai_agentplatform/backend/internal/specialists/packages/resume_processor"
solutionproposal "eai_agentplatform/backend/internal/specialists/packages/solution_proposal"
wechatofficialaccount "eai_agentplatform/backend/internal/specialists/packages/wechat_official_account"
weixinpublicaccount "eai_agentplatform/backend/internal/specialists/packages/weixin_public_account"
)
var builtinRegistry = NewRegistry(
wechatofficialaccount.Manifest,
weixinpublicaccount.Manifest,
contractreview.Manifest,
solutionproposal.Manifest,
customerfollowup.Manifest,
@@ -1,4 +1,4 @@
package wechatofficialaccountapi
package weixinpublicaccountapi
import "eai_agentplatform/backend/internal/dal"
@@ -15,8 +15,8 @@ var (
taskRecordDAO dal.TaskRecordDAO
taskRunDAO dal.TaskRunDAO
taskArtifactDAO dal.TaskArtifactDAO
oaArticleDAO dal.OfficialAccountArticleDAO
oaHotspotDAO dal.OfficialAccountHotspotDAO
oaArticleDAO dal.WeixinPublicAccountArticleDAO
oaHotspotDAO dal.WeixinPublicAccountHotspotDAO
)
func init() {
@@ -24,6 +24,6 @@ func init() {
taskRecordDAO = dal.TaskRecordDAO{}
taskRunDAO = dal.TaskRunDAO{}
taskArtifactDAO = dal.TaskArtifactDAO{}
oaArticleDAO = dal.OfficialAccountArticleDAO{}
oaHotspotDAO = dal.OfficialAccountHotspotDAO{}
oaArticleDAO = dal.WeixinPublicAccountArticleDAO{}
oaHotspotDAO = dal.WeixinPublicAccountHotspotDAO{}
}
@@ -1,4 +1,4 @@
package wechatofficialaccountapi
package weixinpublicaccountapi
import (
"encoding/json"
@@ -13,8 +13,8 @@ import (
)
const (
OfficialAccountSpecialistKey = "wechat-official-account"
officialAccountSpecialistKey = OfficialAccountSpecialistKey
WeixinPublicAccountSpecialistKey = "weixin-public-account"
weixinPublicAccountSpecialistKey = WeixinPublicAccountSpecialistKey
taskStatusPending = "待处理"
taskStatusInProgress = "进行中"
@@ -23,12 +23,12 @@ const (
artifactStatusDraft = "draft"
)
func CompactOfficialAccountRunsForResponse(items []model.TaskRun) []model.TaskRun {
return compactOfficialAccountRunsForResponse(items)
func CompactWeixinPublicAccountRunsForResponse(items []model.TaskRun) []model.TaskRun {
return compactWeixinPublicAccountRunsForResponse(items)
}
func CompactOfficialAccountArtifactsForResponse(items []model.TaskArtifact) []model.TaskArtifact {
return compactOfficialAccountArtifactsForResponse(items)
func CompactWeixinPublicAccountArtifactsForResponse(items []model.TaskArtifact) []model.TaskArtifact {
return compactWeixinPublicAccountArtifactsForResponse(items)
}
func bootstrapOwner(user *model.User) string {
@@ -1,4 +1,4 @@
package wechatofficialaccountapi
package weixinpublicaccountapi
import (
"encoding/json"
@@ -8,17 +8,17 @@ import (
"strings"
"eai_agentplatform/backend/internal/model"
officialaccountmodel "eai_agentplatform/backend/internal/specialists/packages/wechat_official_account/model"
weixinpublicaccountmodel "eai_agentplatform/backend/internal/specialists/packages/weixin_public_account/model"
)
// ensureOfficialAccountArticle 取该任务的文章状态,没有就按当前工作流新建一篇。
// ensureWeixinPublicAccountArticle 取该任务的文章状态,没有就按当前工作流新建一篇。
//
// 三种情况要分开走,不能把「读取出错」和「还没有」合并:
// - 读到了 → 直接用
// - 确实没有 → 新建(正常路径,每个任务第一次进来都会走这里)
// - 读取出错 → 原样返回错误,**不要**去建新的 —— task_id 上有唯一索引,
// 误建多半会撞唯一键而失败,但那是运气不是设计
func ensureOfficialAccountArticle(task model.TaskRecord, workflow officialAccountWorkflowState) (*officialaccountmodel.OfficialAccountArticle, error) {
func ensureWeixinPublicAccountArticle(task model.TaskRecord, workflow weixinPublicAccountWorkflowState) (*weixinpublicaccountmodel.WeixinPublicAccountArticle, error) {
article, err := oaArticleDAO.FindByTaskID(task.ID)
if err != nil {
return nil, err
@@ -26,19 +26,19 @@ func ensureOfficialAccountArticle(task model.TaskRecord, workflow officialAccoun
if article != nil {
return article, nil
}
row := &officialaccountmodel.OfficialAccountArticle{
row := &weixinpublicaccountmodel.WeixinPublicAccountArticle{
TaskID: task.ID,
SpecialistKey: task.SpecialistKey,
BusinessDomain: resolveOfficialAccountBusinessDomain(workflow.Form.BusinessDomain, workflow.Form.Keyword),
Keyword: firstNonEmpty(workflow.Form.Keyword, getOfficialAccountBusinessDomainConfig(workflow.Form.BusinessDomain).DefaultKeyword),
BusinessDomain: resolveWeixinPublicAccountBusinessDomain(workflow.Form.BusinessDomain, workflow.Form.Keyword),
Keyword: firstNonEmpty(workflow.Form.Keyword, getWeixinPublicAccountBusinessDomainConfig(workflow.Form.BusinessDomain).DefaultKeyword),
Audience: firstNonEmpty(workflow.Form.Audience, "公众号读者"),
Goal: firstNonEmpty(workflow.Form.Goal, "输出一篇可发布的公众号文章"),
Tone: firstNonEmpty(workflow.Form.Tone, "专业但好懂"),
Requirements: workflow.Form.Requirements,
OutlineStyle: normalizeOfficialAccountOutlineStyle(workflow.Form.OutlineStyle),
OutlineWords: normalizeOfficialAccountOutlineWords(workflow.Form.OutlineWords),
ContentTargetWords: normalizeOfficialAccountContentTargetWords(workflow.Form.ContentTargetWords),
MaxContentImages: normalizeOfficialAccountMaxContentImages(workflow.Form.MaxContentImages),
OutlineStyle: normalizeWeixinPublicAccountOutlineStyle(workflow.Form.OutlineStyle),
OutlineWords: normalizeWeixinPublicAccountOutlineWords(workflow.Form.OutlineWords),
ContentTargetWords: normalizeWeixinPublicAccountContentTargetWords(workflow.Form.ContentTargetWords),
MaxContentImages: normalizeWeixinPublicAccountMaxContentImages(workflow.Form.MaxContentImages),
Status: "draft",
}
if !oaArticleDAO.Insert(row) {
@@ -47,23 +47,23 @@ func ensureOfficialAccountArticle(task model.TaskRecord, workflow officialAccoun
return row, nil
}
func syncWorkflowFromOfficialAccountArticle(workflow *officialAccountWorkflowState, article *officialaccountmodel.OfficialAccountArticle) {
func syncWorkflowFromWeixinPublicAccountArticle(workflow *weixinPublicAccountWorkflowState, article *weixinpublicaccountmodel.WeixinPublicAccountArticle) {
if workflow == nil || article == nil {
return
}
workflow.Form.Keyword = firstNonEmpty(article.Keyword, workflow.Form.Keyword)
workflow.Form.BusinessDomain = resolveOfficialAccountBusinessDomain(article.BusinessDomain, workflow.Form.Keyword)
workflow.Form.BusinessDomain = resolveWeixinPublicAccountBusinessDomain(article.BusinessDomain, workflow.Form.Keyword)
workflow.Form.Audience = firstNonEmpty(article.Audience, workflow.Form.Audience)
workflow.Form.Goal = firstNonEmpty(article.Goal, workflow.Form.Goal)
workflow.Form.Tone = firstNonEmpty(article.Tone, workflow.Form.Tone)
workflow.Form.Requirements = article.Requirements
workflow.Form.OutlineStyle = normalizeOfficialAccountOutlineStyle(article.OutlineStyle)
workflow.Form.OutlineWords = normalizeOfficialAccountOutlineWords(article.OutlineWords)
workflow.Form.ContentTargetWords = normalizeOfficialAccountContentTargetWords(article.ContentTargetWords)
workflow.Form.MaxContentImages = normalizeOfficialAccountMaxContentImages(article.MaxContentImages)
workflow.Form.OutlineStyle = normalizeWeixinPublicAccountOutlineStyle(article.OutlineStyle)
workflow.Form.OutlineWords = normalizeWeixinPublicAccountOutlineWords(article.OutlineWords)
workflow.Form.ContentTargetWords = normalizeWeixinPublicAccountContentTargetWords(article.ContentTargetWords)
workflow.Form.MaxContentImages = normalizeWeixinPublicAccountMaxContentImages(article.MaxContentImages)
if strings.TrimSpace(article.TopicCandidatesJSON) != "" {
var items []officialAccountTopicCandidate
var items []weixinPublicAccountTopicCandidate
if json.Unmarshal([]byte(article.TopicCandidatesJSON), &items) == nil {
workflow.Shared.TopicCandidates = items
}
@@ -80,13 +80,13 @@ func syncWorkflowFromOfficialAccountArticle(workflow *officialAccountWorkflowSta
workflow.Shared.Content = article.Content
workflow.Shared.ContentWithPrompts = article.ContentWithPrompts
if strings.TrimSpace(article.ImagePromptsJSON) != "" {
var items []officialAccountImagePrompt
var items []weixinPublicAccountImagePrompt
if json.Unmarshal([]byte(article.ImagePromptsJSON), &items) == nil {
workflow.Shared.ImagePrompts = items
}
}
if strings.TrimSpace(article.ImageRefsJSON) != "" {
var items []officialAccountImageRef
var items []weixinPublicAccountImageRef
if json.Unmarshal([]byte(article.ImageRefsJSON), &items) == nil {
workflow.Shared.ImageRefs = items
}
@@ -95,27 +95,27 @@ func syncWorkflowFromOfficialAccountArticle(workflow *officialAccountWorkflowSta
workflow.Shared.PreviewHTML = article.PreviewHTML
workflow.Shared.PageMarkdown = article.PageMarkdown
if strings.TrimSpace(article.PagesJSON) != "" {
var items []officialAccountPageChunk
var items []weixinPublicAccountPageChunk
if json.Unmarshal([]byte(article.PagesJSON), &items) == nil {
workflow.Shared.Pages = items
}
}
}
func syncOfficialAccountArticleFromWorkflow(article *officialaccountmodel.OfficialAccountArticle, workflow officialAccountWorkflowState) {
func syncWeixinPublicAccountArticleFromWorkflow(article *weixinpublicaccountmodel.WeixinPublicAccountArticle, workflow weixinPublicAccountWorkflowState) {
if article == nil {
return
}
article.BusinessDomain = resolveOfficialAccountBusinessDomain(workflow.Form.BusinessDomain, workflow.Form.Keyword)
article.BusinessDomain = resolveWeixinPublicAccountBusinessDomain(workflow.Form.BusinessDomain, workflow.Form.Keyword)
article.Keyword = firstNonEmpty(workflow.Form.Keyword, article.Keyword)
article.Audience = firstNonEmpty(workflow.Form.Audience, article.Audience)
article.Goal = firstNonEmpty(workflow.Form.Goal, article.Goal)
article.Tone = firstNonEmpty(workflow.Form.Tone, article.Tone)
article.Requirements = workflow.Form.Requirements
article.OutlineStyle = normalizeOfficialAccountOutlineStyle(workflow.Form.OutlineStyle)
article.OutlineWords = normalizeOfficialAccountOutlineWords(workflow.Form.OutlineWords)
article.ContentTargetWords = normalizeOfficialAccountContentTargetWords(workflow.Form.ContentTargetWords)
article.MaxContentImages = normalizeOfficialAccountMaxContentImages(workflow.Form.MaxContentImages)
article.OutlineStyle = normalizeWeixinPublicAccountOutlineStyle(workflow.Form.OutlineStyle)
article.OutlineWords = normalizeWeixinPublicAccountOutlineWords(workflow.Form.OutlineWords)
article.ContentTargetWords = normalizeWeixinPublicAccountContentTargetWords(workflow.Form.ContentTargetWords)
article.MaxContentImages = normalizeWeixinPublicAccountMaxContentImages(workflow.Form.MaxContentImages)
article.SelectedTopic = workflow.Shared.SelectedTopic
article.SelectedTitle = workflow.Shared.SelectedTitle
article.Outline = workflow.Shared.Outline
@@ -160,11 +160,11 @@ func syncOfficialAccountArticleFromWorkflow(article *officialaccountmodel.Offici
}
}
func resetOfficialAccountArticle(article *officialaccountmodel.OfficialAccountArticle, req officialAccountTaskUpdateReq) {
func resetWeixinPublicAccountArticle(article *weixinpublicaccountmodel.WeixinPublicAccountArticle, req weixinPublicAccountTaskUpdateReq) {
if article == nil {
return
}
article.BusinessDomain = resolveOfficialAccountBusinessDomain(req.BusinessDomain, req.Keyword)
article.BusinessDomain = resolveWeixinPublicAccountBusinessDomain(req.BusinessDomain, req.Keyword)
article.Keyword = req.Keyword
article.Audience = firstNonEmpty(req.Audience, "公众号读者")
article.Goal = firstNonEmpty(req.Goal, "输出一篇可发布的公众号文章")
@@ -176,11 +176,11 @@ func resetOfficialAccountArticle(article *officialaccountmodel.OfficialAccountAr
article.TopicHeat = ""
article.TitleCandidatesJSON = "[]"
article.SelectedTitle = ""
article.OutlineStyle = normalizeOfficialAccountOutlineStyle(req.OutlineStyle)
article.OutlineWords = normalizeOfficialAccountOutlineWords(req.OutlineWords)
article.OutlineStyle = normalizeWeixinPublicAccountOutlineStyle(req.OutlineStyle)
article.OutlineWords = normalizeWeixinPublicAccountOutlineWords(req.OutlineWords)
article.Outline = ""
article.ContentTargetWords = normalizeOfficialAccountContentTargetWords(req.ContentTargetWords)
article.MaxContentImages = normalizeOfficialAccountMaxContentImages(req.MaxContentImages)
article.ContentTargetWords = normalizeWeixinPublicAccountContentTargetWords(req.ContentTargetWords)
article.MaxContentImages = normalizeWeixinPublicAccountMaxContentImages(req.MaxContentImages)
article.Content = ""
article.ContentWithPrompts = ""
article.ImagePromptsJSON = "[]"
@@ -192,17 +192,17 @@ func resetOfficialAccountArticle(article *officialaccountmodel.OfficialAccountAr
article.Status = "draft"
}
func buildOfficialAccountTopicCandidatesFromHotspots(form officialAccountForm, hotspots []officialaccountmodel.OfficialAccountHotspot) []officialAccountTopicCandidate {
domainLabel := officialAccountBusinessDomainLabel(form.BusinessDomain)
candidates := make([]officialAccountTopicCandidate, 0, 3)
func buildWeixinPublicAccountTopicCandidatesFromHotspots(form weixinPublicAccountForm, hotspots []weixinpublicaccountmodel.WeixinPublicAccountHotspot) []weixinPublicAccountTopicCandidate {
domainLabel := weixinPublicAccountBusinessDomainLabel(form.BusinessDomain)
candidates := make([]weixinPublicAccountTopicCandidate, 0, 3)
for _, item := range hotspots {
topic := deriveOfficialAccountTopic(form.Keyword, item.Title)
topic := deriveWeixinPublicAccountTopic(form.Keyword, item.Title)
if topic == "" {
continue
}
candidates = append(candidates, officialAccountTopicCandidate{
candidates = append(candidates, weixinPublicAccountTopicCandidate{
Topic: topic,
Angle: deriveOfficialAccountAngle(item.Title),
Angle: deriveWeixinPublicAccountAngle(item.Title),
Reason: firstNonEmpty(item.Summary, "来自公开"+domainLabel+"资讯源,已按业务域相关性筛选。"),
Heat: firstNonEmpty(item.HeatLabel, "中高"),
})
@@ -211,14 +211,14 @@ func buildOfficialAccountTopicCandidatesFromHotspots(form officialAccountForm, h
}
}
if len(candidates) == 0 {
return buildOfficialAccountTopicCandidates(form)
return buildWeixinPublicAccountTopicCandidates(form)
}
return dedupeOfficialAccountTopicCandidates(candidates)
return dedupeWeixinPublicAccountTopicCandidates(candidates)
}
func dedupeOfficialAccountTopicCandidates(items []officialAccountTopicCandidate) []officialAccountTopicCandidate {
func dedupeWeixinPublicAccountTopicCandidates(items []weixinPublicAccountTopicCandidate) []weixinPublicAccountTopicCandidate {
seen := map[string]struct{}{}
result := make([]officialAccountTopicCandidate, 0, len(items))
result := make([]weixinPublicAccountTopicCandidate, 0, len(items))
for _, item := range items {
key := strings.TrimSpace(item.Topic)
if key == "" {
@@ -233,7 +233,7 @@ func dedupeOfficialAccountTopicCandidates(items []officialAccountTopicCandidate)
return result
}
func deriveOfficialAccountTopic(keyword, sourceTitle string) string {
func deriveWeixinPublicAccountTopic(keyword, sourceTitle string) string {
keyword = strings.TrimSpace(keyword)
sourceTitle = strings.TrimSpace(sourceTitle)
switch {
@@ -242,11 +242,16 @@ func deriveOfficialAccountTopic(keyword, sourceTitle string) string {
case strings.Contains(strings.ToLower(sourceTitle), strings.ToLower(keyword)):
return sourceTitle
default:
return fmt.Sprintf("%s:%s", keyword, sourceTitle)
for _, term := range buildWeixinPublicAccountKeywordTerms(keyword) {
if strings.Contains(strings.ToLower(sourceTitle), strings.ToLower(term)) {
return sourceTitle
}
}
return ""
}
}
func deriveOfficialAccountAngle(sourceTitle string) string {
func deriveWeixinPublicAccountAngle(sourceTitle string) string {
switch {
case strings.Contains(sourceTitle, "融资") || strings.Contains(sourceTitle, "估值"):
return "产业动向"
@@ -259,7 +264,7 @@ func deriveOfficialAccountAngle(sourceTitle string) string {
}
}
func marshalOfficialAccountHotspots(hotspots []officialaccountmodel.OfficialAccountHotspot) string {
func marshalWeixinPublicAccountHotspots(hotspots []weixinpublicaccountmodel.WeixinPublicAccountHotspot) string {
if len(hotspots) == 0 {
return "[]"
}
@@ -270,8 +275,8 @@ func marshalOfficialAccountHotspots(hotspots []officialaccountmodel.OfficialAcco
return string(data)
}
func buildOfficialAccountHotspotPrompt(form officialAccountForm, hotspots []officialaccountmodel.OfficialAccountHotspot) string {
cfg := getOfficialAccountBusinessDomainConfig(form.BusinessDomain)
func buildWeixinPublicAccountHotspotPrompt(form weixinPublicAccountForm, hotspots []weixinpublicaccountmodel.WeixinPublicAccountHotspot) string {
cfg := getWeixinPublicAccountBusinessDomainConfig(form.BusinessDomain)
lines := []string{
fmt.Sprintf("你是公众号选题策划编辑,请基于下面的%s热点,为公众号生成 3 个值得写的热点选题。", cfg.PromptLabel),
"要求:",
@@ -296,8 +301,8 @@ func buildOfficialAccountHotspotPrompt(form officialAccountForm, hotspots []offi
return strings.Join(lines, "\n")
}
func buildOfficialAccountTopicEvalPrompt(form officialAccountForm, topic string, hotspots []officialaccountmodel.OfficialAccountHotspot) string {
cfg := getOfficialAccountBusinessDomainConfig(form.BusinessDomain)
func buildWeixinPublicAccountTopicEvalPrompt(form weixinPublicAccountForm, topic string, hotspots []weixinpublicaccountmodel.WeixinPublicAccountHotspot) string {
cfg := getWeixinPublicAccountBusinessDomainConfig(form.BusinessDomain)
lines := []string{
fmt.Sprintf("请判断这个公众号选题在当前%s热点里的热度和可写性,返回一个 JSON 对象,字段固定为 heat、reason。", cfg.PromptLabel),
fmt.Sprintf("选题:%s", topic),
@@ -315,8 +320,8 @@ func buildOfficialAccountTopicEvalPrompt(form officialAccountForm, topic string,
return strings.Join(lines, "\n")
}
func buildOfficialAccountTopicBatchEvalPrompt(form officialAccountForm, candidates []officialAccountTopicCandidate, hotspots []officialaccountmodel.OfficialAccountHotspot) string {
cfg := getOfficialAccountBusinessDomainConfig(form.BusinessDomain)
func buildWeixinPublicAccountTopicBatchEvalPrompt(form weixinPublicAccountForm, candidates []weixinPublicAccountTopicCandidate, hotspots []weixinpublicaccountmodel.WeixinPublicAccountHotspot) string {
cfg := getWeixinPublicAccountBusinessDomainConfig(form.BusinessDomain)
lines := []string{
fmt.Sprintf("你是公众号选题评审助手,请基于当前%s热点,对下面多个公众号选题候选做一次批量评判。", cfg.PromptLabel),
"要求:",
@@ -345,8 +350,8 @@ func buildOfficialAccountTopicBatchEvalPrompt(form officialAccountForm, candidat
return strings.Join(lines, "\n")
}
func sortOfficialAccountHotspots(items []officialaccountmodel.OfficialAccountHotspot) []officialaccountmodel.OfficialAccountHotspot {
cloned := append([]officialaccountmodel.OfficialAccountHotspot(nil), items...)
func sortWeixinPublicAccountHotspots(items []weixinpublicaccountmodel.WeixinPublicAccountHotspot) []weixinpublicaccountmodel.WeixinPublicAccountHotspot {
cloned := append([]weixinpublicaccountmodel.WeixinPublicAccountHotspot(nil), items...)
sort.Slice(cloned, func(i, j int) bool {
if cloned[i].DomainScore == cloned[j].DomainScore {
return cloned[i].FetchedAt.After(cloned[j].FetchedAt)
@@ -1,4 +1,4 @@
package wechatofficialaccountapi
package weixinpublicaccountapi
import (
"bytes"
@@ -17,18 +17,18 @@ import (
"eai_agentplatform/backend/internal/config"
"eai_agentplatform/backend/internal/middleware"
"eai_agentplatform/backend/internal/model"
officialaccountmodel "eai_agentplatform/backend/internal/specialists/packages/wechat_official_account/model"
weixinpublicaccountmodel "eai_agentplatform/backend/internal/specialists/packages/weixin_public_account/model"
"eai_agentplatform/backend/internal/web"
)
type officialAccountSection struct {
type weixinPublicAccountSection struct {
Title string
Content []string
}
func executeOfficialAccountImagePromptStep(workflow *officialAccountWorkflowState, article *officialaccountmodel.OfficialAccountArticle, user *model.User, now time.Time) officialAccountStepExecution {
prompts := buildOfficialAccountImagePrompts(workflow)
contentWithPrompts := buildOfficialAccountContentWithPrompts(workflow.Shared.Content, prompts)
func executeWeixinPublicAccountImagePromptStep(workflow *weixinPublicAccountWorkflowState, article *weixinpublicaccountmodel.WeixinPublicAccountArticle, user *model.User, now time.Time) weixinPublicAccountStepExecution {
prompts := buildWeixinPublicAccountImagePrompts(workflow)
contentWithPrompts := buildWeixinPublicAccountContentWithPrompts(workflow.Shared.Content, prompts)
workflow.Shared.ContentWithPrompts = contentWithPrompts
workflow.Shared.ImagePrompts = prompts
@@ -37,9 +37,9 @@ func executeOfficialAccountImagePromptStep(workflow *officialAccountWorkflowStat
"image_prompts": prompts,
"count": len(prompts),
}
return officialAccountStepExecution{
return weixinPublicAccountStepExecution{
Summary: fmt.Sprintf("已生成 %d 条配图提示词。", len(prompts)),
TaskStatus: taskStatusDraft,
TaskStatus: taskStatusInProgress,
ArtifactTitle: "公众号配图提示词",
ArtifactType: "image_prompts",
ArtifactStatus: artifactStatusDraft,
@@ -52,16 +52,16 @@ func executeOfficialAccountImagePromptStep(workflow *officialAccountWorkflowStat
}
}
func executeOfficialAccountImageGenerationStep(workflow *officialAccountWorkflowState, article *officialaccountmodel.OfficialAccountArticle, user *model.User, now time.Time) (officialAccountStepExecution, *web.AppError) {
func executeWeixinPublicAccountImageGenerationStep(workflow *weixinPublicAccountWorkflowState, article *weixinpublicaccountmodel.WeixinPublicAccountArticle, user *model.User, now time.Time) (weixinPublicAccountStepExecution, *web.AppError) {
prompts := workflow.Shared.ImagePrompts
if len(prompts) == 0 {
prompts = buildOfficialAccountImagePrompts(workflow)
prompts = buildWeixinPublicAccountImagePrompts(workflow)
workflow.Shared.ImagePrompts = prompts
workflow.Shared.ContentWithPrompts = buildOfficialAccountContentWithPrompts(workflow.Shared.Content, prompts)
workflow.Shared.ContentWithPrompts = buildWeixinPublicAccountContentWithPrompts(workflow.Shared.Content, prompts)
}
aiRoute, refs, appErr := generateOfficialAccountImages(article.TaskID, workflow, prompts, user)
aiRoute, refs, appErr := generateWeixinPublicAccountImages(article.TaskID, workflow, prompts, user)
if appErr != nil {
return officialAccountStepExecution{}, appErr
return weixinPublicAccountStepExecution{}, appErr
}
workflow.Shared.ImageRefs = refs
@@ -72,13 +72,13 @@ func executeOfficialAccountImageGenerationStep(workflow *officialAccountWorkflow
"ai_route_model": aiRoute.Model,
"ai_route_provider": aiRoute.Provider,
}
return officialAccountStepExecution{
return weixinPublicAccountStepExecution{
Summary: fmt.Sprintf("已生成 %d 张图片结果。", len(refs)),
TaskStatus: taskStatusDraft,
TaskStatus: taskStatusInProgress,
ArtifactTitle: "公众号配图结果",
ArtifactType: "images",
ArtifactStatus: artifactStatusDraft,
ArtifactContent: buildOfficialAccountImageArtifact(refs),
ArtifactContent: buildWeixinPublicAccountImageArtifact(refs),
Output: output,
Logs: []string{
fmt.Sprintf("%s 已通过 %s 生成图片结果。", now.Format("15:04:05"), aiRoute.Model),
@@ -87,9 +87,9 @@ func executeOfficialAccountImageGenerationStep(workflow *officialAccountWorkflow
}, nil
}
func executeOfficialAccountPreviewStep(workflow *officialAccountWorkflowState, article *officialaccountmodel.OfficialAccountArticle, user *model.User, now time.Time) officialAccountStepExecution {
previewMarkdown := buildOfficialAccountPreviewMarkdown(workflow)
previewHTML := renderOfficialAccountHTML(previewMarkdown)
func executeWeixinPublicAccountPreviewStep(workflow *weixinPublicAccountWorkflowState, article *weixinpublicaccountmodel.WeixinPublicAccountArticle, user *model.User, now time.Time) weixinPublicAccountStepExecution {
previewMarkdown := buildWeixinPublicAccountPreviewMarkdown(workflow)
previewHTML := renderWeixinPublicAccountHTML(previewMarkdown)
workflow.Shared.PreviewMarkdown = previewMarkdown
workflow.Shared.PreviewHTML = previewHTML
@@ -98,9 +98,9 @@ func executeOfficialAccountPreviewStep(workflow *officialAccountWorkflowState, a
"preview_html": previewHTML,
"image_count": len(workflow.Shared.ImageRefs),
}
return officialAccountStepExecution{
return weixinPublicAccountStepExecution{
Summary: "预览稿已生成,可直接检查排版并导出。",
TaskStatus: taskStatusDraft,
TaskStatus: taskStatusInProgress,
ArtifactTitle: "公众号预览稿",
ArtifactType: "preview",
ArtifactStatus: artifactStatusDraft,
@@ -113,16 +113,16 @@ func executeOfficialAccountPreviewStep(workflow *officialAccountWorkflowState, a
}
}
func executeOfficialAccountPageMarkdownStep(workflow *officialAccountWorkflowState, article *officialaccountmodel.OfficialAccountArticle, user *model.User, now time.Time) officialAccountStepExecution {
pages := buildOfficialAccountPages(workflow)
pageMarkdown := buildOfficialAccountPageMarkdown(pages)
func executeWeixinPublicAccountPageMarkdownStep(workflow *weixinPublicAccountWorkflowState, article *weixinpublicaccountmodel.WeixinPublicAccountArticle, user *model.User, now time.Time) weixinPublicAccountStepExecution {
pages := buildWeixinPublicAccountPages(workflow)
pageMarkdown := buildWeixinPublicAccountPageMarkdown(pages)
workflow.Shared.Pages = pages
workflow.Shared.PageMarkdown = pageMarkdown
if workflow.Shared.PreviewMarkdown == "" {
workflow.Shared.PreviewMarkdown = buildOfficialAccountPreviewMarkdown(workflow)
workflow.Shared.PreviewMarkdown = buildWeixinPublicAccountPreviewMarkdown(workflow)
}
if workflow.Shared.PreviewHTML == "" {
workflow.Shared.PreviewHTML = renderOfficialAccountHTML(workflow.Shared.PreviewMarkdown)
workflow.Shared.PreviewHTML = renderWeixinPublicAccountHTML(workflow.Shared.PreviewMarkdown)
}
output := map[string]any{
@@ -130,7 +130,7 @@ func executeOfficialAccountPageMarkdownStep(workflow *officialAccountWorkflowSta
"pages": pages,
"count": len(pages),
}
return officialAccountStepExecution{
return weixinPublicAccountStepExecution{
Summary: fmt.Sprintf("分页 MD 已生成,共 %d 页。", len(pages)),
TaskStatus: taskStatusDraft,
ArtifactTitle: "公众号分页 MD",
@@ -145,31 +145,31 @@ func executeOfficialAccountPageMarkdownStep(workflow *officialAccountWorkflowSta
}
}
func buildOfficialAccountImagePrompts(workflow *officialAccountWorkflowState) []officialAccountImagePrompt {
func buildWeixinPublicAccountImagePrompts(workflow *weixinPublicAccountWorkflowState) []weixinPublicAccountImagePrompt {
title := firstNonEmpty(workflow.Shared.SelectedTitle, workflow.Form.Keyword, "公众号文章")
sections := extractOfficialAccountSections(workflow.Shared.Content)
maxContentImages := normalizeOfficialAccountMaxContentImages(workflow.Form.MaxContentImages)
prompts := []officialAccountImagePrompt{
sections := extractWeixinPublicAccountSections(workflow.Shared.Content)
maxContentImages := normalizeWeixinPublicAccountMaxContentImages(workflow.Form.MaxContentImages)
prompts := []weixinPublicAccountImagePrompt{
{
Key: "cover",
Section: "封面配图",
Prompt: buildOfficialAccountVisualPrompt(workflow, title, "公众号封面主视觉"),
Prompt: buildWeixinPublicAccountVisualPrompt(workflow, title, "公众号封面主视觉"),
},
}
for idx, item := range sections {
if maxContentImages > 0 && idx >= maxContentImages {
break
}
prompts = append(prompts, officialAccountImagePrompt{
prompts = append(prompts, weixinPublicAccountImagePrompt{
Key: fmt.Sprintf("section_%02d", idx+1),
Section: item.Title,
Prompt: buildOfficialAccountVisualPrompt(workflow, item.Title, strings.Join(item.Content, " ")),
Prompt: buildWeixinPublicAccountVisualPrompt(workflow, item.Title, strings.Join(item.Content, " ")),
})
}
return prompts
}
func buildOfficialAccountContentWithPrompts(content string, prompts []officialAccountImagePrompt) string {
func buildWeixinPublicAccountContentWithPrompts(content string, prompts []weixinPublicAccountImagePrompt) string {
content = strings.TrimSpace(content)
if content == "" {
return ""
@@ -181,14 +181,14 @@ func buildOfficialAccountContentWithPrompts(content string, prompts []officialAc
return strings.Join(blocks, "\n")
}
func buildOfficialAccountVisualPrompt(workflow *officialAccountWorkflowState, sectionTitle, context string) string {
func buildWeixinPublicAccountVisualPrompt(workflow *weixinPublicAccountWorkflowState, sectionTitle, context string) string {
keyword := firstNonEmpty(workflow.Form.Keyword, "业务主题")
tone := firstNonEmpty(workflow.Form.Tone, "专业但好懂")
domain := officialAccountBusinessDomainLabel(workflow.Form.BusinessDomain)
domain := weixinPublicAccountBusinessDomainLabel(workflow.Form.BusinessDomain)
return fmt.Sprintf("realistic editorial illustration, Chinese business article cover, %s, focus on %s, %s, clean composition, natural lighting, brand-safe, no text, no watermark", domain, firstNonEmpty(sectionTitle, keyword), firstNonEmpty(context, tone))
}
func buildOfficialAccountImageArtifact(refs []officialAccountImageRef) string {
func buildWeixinPublicAccountImageArtifact(refs []weixinPublicAccountImageRef) string {
lines := []string{"# 图片结果", ""}
for idx, item := range refs {
lines = append(lines,
@@ -202,7 +202,7 @@ func buildOfficialAccountImageArtifact(refs []officialAccountImageRef) string {
return strings.Join(lines, "\n")
}
func generateOfficialAccountImages(taskID uint, workflow *officialAccountWorkflowState, prompts []officialAccountImagePrompt, user *model.User) (*config.RouteConfig, []officialAccountImageRef, *web.AppError) {
func generateWeixinPublicAccountImages(taskID uint, workflow *weixinPublicAccountWorkflowState, prompts []weixinPublicAccountImagePrompt, user *model.User) (*config.RouteConfig, []weixinPublicAccountImageRef, *web.AppError) {
aiRoute, err := config.GetRoute("image_gen")
if err != nil {
return nil, nil, web.NewLLMNotConfigured("未找到可用的图片生成路由,请先检查 ai_config.json 中的 image_gen 配置")
@@ -212,9 +212,9 @@ func generateOfficialAccountImages(taskID uint, workflow *officialAccountWorkflo
}
client := &http.Client{Timeout: 240 * time.Second}
refs := make([]officialAccountImageRef, 0, len(prompts))
refs := make([]weixinPublicAccountImageRef, 0, len(prompts))
for idx, item := range prompts {
ref, appErr := generateOfficialAccountImageRefWithRoute(client, aiRoute, taskID, item, idx, user)
ref, appErr := generateWeixinPublicAccountImageRefWithRoute(client, aiRoute, taskID, item, idx, user)
if appErr != nil {
return nil, nil, appErr
}
@@ -223,27 +223,27 @@ func generateOfficialAccountImages(taskID uint, workflow *officialAccountWorkflo
return aiRoute, refs, nil
}
func generateOfficialAccountImageRef(taskID uint, prompt officialAccountImagePrompt, index int, user *model.User) (*config.RouteConfig, officialAccountImageRef, *web.AppError) {
func generateWeixinPublicAccountImageRef(taskID uint, prompt weixinPublicAccountImagePrompt, index int, user *model.User) (*config.RouteConfig, weixinPublicAccountImageRef, *web.AppError) {
aiRoute, err := config.GetRoute("image_gen")
if err != nil {
return nil, officialAccountImageRef{}, web.NewLLMNotConfigured("未找到可用的图片生成路由,请先检查 ai_config.json 中的 image_gen 配置")
return nil, weixinPublicAccountImageRef{}, web.NewLLMNotConfigured("未找到可用的图片生成路由,请先检查 ai_config.json 中的 image_gen 配置")
}
if aiRoute.Category != "image" || strings.TrimSpace(aiRoute.FullURL) == "" || strings.TrimSpace(aiRoute.Model) == "" {
return nil, officialAccountImageRef{}, web.NewLLMNotConfigured("图片生成路由配置不完整")
return nil, weixinPublicAccountImageRef{}, web.NewLLMNotConfigured("图片生成路由配置不完整")
}
client := &http.Client{Timeout: 240 * time.Second}
ref, appErr := generateOfficialAccountImageRefWithRoute(client, aiRoute, taskID, prompt, index, user)
ref, appErr := generateWeixinPublicAccountImageRefWithRoute(client, aiRoute, taskID, prompt, index, user)
if appErr != nil {
return nil, officialAccountImageRef{}, appErr
return nil, weixinPublicAccountImageRef{}, appErr
}
return aiRoute, ref, nil
}
func generateOfficialAccountImageRefWithRoute(client *http.Client, aiRoute *config.RouteConfig, taskID uint, prompt officialAccountImagePrompt, index int, user *model.User) (officialAccountImageRef, *web.AppError) {
imageSize := officialAccountImageSlotSize(index)
requestSize := officialAccountImageRequestSize(imageSize)
func generateWeixinPublicAccountImageRefWithRoute(client *http.Client, aiRoute *config.RouteConfig, taskID uint, prompt weixinPublicAccountImagePrompt, index int, user *model.User) (weixinPublicAccountImageRef, *web.AppError) {
imageSize := weixinPublicAccountImageSlotSize(index)
requestSize := weixinPublicAccountImageRequestSize(imageSize)
started := time.Now()
imageURL, reqErr := requestOfficialAccountImage(client, aiRoute, prompt.Prompt, requestSize)
imageURL, reqErr := requestWeixinPublicAccountImage(client, aiRoute, prompt.Prompt, requestSize)
latencyMs := int(time.Since(started) / time.Millisecond)
ai.LogCall(ai.LogEntry{
UserID: user.ID,
@@ -256,15 +256,15 @@ func generateOfficialAccountImageRefWithRoute(client *http.Client, aiRoute *conf
LatencyMs: latencyMs,
})
if reqErr != nil {
return officialAccountImageRef{}, web.NewLLMError(fmt.Sprintf("图片生成失败:%s", reqErr.Error()))
return weixinPublicAccountImageRef{}, web.NewLLMError(fmt.Sprintf("图片生成失败:%s", reqErr.Error()))
}
if strings.HasPrefix(strings.TrimSpace(imageURL), "data:image/") {
imageURL, reqErr = persistOfficialAccountDataURL(taskID, prompt.Key, imageURL)
imageURL, reqErr = persistWeixinPublicAccountDataURL(taskID, prompt.Key, imageURL)
if reqErr != nil {
return officialAccountImageRef{}, web.NewLLMError(fmt.Sprintf("图片保存失败:%s", reqErr.Error()))
return weixinPublicAccountImageRef{}, web.NewLLMError(fmt.Sprintf("图片保存失败:%s", reqErr.Error()))
}
}
return officialAccountImageRef{
return weixinPublicAccountImageRef{
Key: prompt.Key,
Section: prompt.Section,
Prompt: prompt.Prompt,
@@ -276,23 +276,23 @@ func generateOfficialAccountImageRefWithRoute(client *http.Client, aiRoute *conf
}, nil
}
func requestOfficialAccountImage(client *http.Client, aiRoute *config.RouteConfig, prompt, size string) (string, error) {
func requestWeixinPublicAccountImage(client *http.Client, aiRoute *config.RouteConfig, prompt, size string) (string, error) {
payload := map[string]any{
"model": aiRoute.Model,
"prompt": strings.TrimSpace(prompt),
"n": 1,
"size": size,
}
imageURL, err := postOfficialAccountImageRequest(client, aiRoute, payload)
imageURL, err := postWeixinPublicAccountImageRequest(client, aiRoute, payload)
if err == nil {
return imageURL, nil
}
payload["response_format"] = "b64_json"
return postOfficialAccountImageRequest(client, aiRoute, payload)
return postWeixinPublicAccountImageRequest(client, aiRoute, payload)
}
func postOfficialAccountImageRequest(client *http.Client, aiRoute *config.RouteConfig, payload map[string]any) (string, error) {
func postWeixinPublicAccountImageRequest(client *http.Client, aiRoute *config.RouteConfig, payload map[string]any) (string, error) {
body, err := json.Marshal(payload)
if err != nil {
return "", err
@@ -318,7 +318,7 @@ func postOfficialAccountImageRequest(client *http.Client, aiRoute *config.RouteC
return "", err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return "", fmt.Errorf("图片接口返回 %d:%s", resp.StatusCode, truncateOfficialAccountErrorBody(string(raw)))
return "", fmt.Errorf("图片接口返回 %d:%s", resp.StatusCode, truncateWeixinPublicAccountErrorBody(string(raw)))
}
var out struct {
@@ -343,14 +343,14 @@ func postOfficialAccountImageRequest(client *http.Client, aiRoute *config.RouteC
return "", fmt.Errorf("图片接口未返回 url 或 b64_json")
}
func officialAccountImageSlotSize(index int) string {
func weixinPublicAccountImageSlotSize(index int) string {
if index <= 0 {
return "landscape_16_9"
}
return "landscape_4_3"
}
func officialAccountImageRequestSize(size string) string {
func weixinPublicAccountImageRequestSize(size string) string {
switch strings.TrimSpace(size) {
case "landscape_16_9":
return "1536x864"
@@ -367,7 +367,7 @@ func officialAccountImageRequestSize(size string) string {
}
}
func truncateOfficialAccountErrorBody(raw string) string {
func truncateWeixinPublicAccountErrorBody(raw string) string {
raw = strings.TrimSpace(raw)
if len(raw) <= 240 {
return raw
@@ -382,9 +382,9 @@ func errorMessage(err error) string {
return err.Error()
}
func buildOfficialAccountPreviewMarkdown(workflow *officialAccountWorkflowState) string {
func buildWeixinPublicAccountPreviewMarkdown(workflow *weixinPublicAccountWorkflowState) string {
title := firstNonEmpty(workflow.Shared.SelectedTitle, workflow.Form.Keyword, "公众号文章")
sections := extractOfficialAccountSections(workflow.Shared.Content)
sections := extractWeixinPublicAccountSections(workflow.Shared.Content)
refs := workflow.Shared.ImageRefs
lines := []string{fmt.Sprintf("# %s", title), ""}
if len(refs) > 0 {
@@ -404,16 +404,16 @@ func buildOfficialAccountPreviewMarkdown(workflow *officialAccountWorkflowState)
return strings.TrimSpace(strings.Join(lines, "\n"))
}
func buildOfficialAccountPages(workflow *officialAccountWorkflowState) []officialAccountPageChunk {
func buildWeixinPublicAccountPages(workflow *weixinPublicAccountWorkflowState) []weixinPublicAccountPageChunk {
title := firstNonEmpty(workflow.Shared.SelectedTitle, workflow.Form.Keyword, "公众号文章")
sections := extractOfficialAccountSections(workflow.Shared.Content)
sections := extractWeixinPublicAccountSections(workflow.Shared.Content)
refs := workflow.Shared.ImageRefs
pages := make([]officialAccountPageChunk, 0, len(sections)+1)
pages := make([]weixinPublicAccountPageChunk, 0, len(sections)+1)
intro := []string{fmt.Sprintf("# %s", title)}
if len(refs) > 0 {
intro = append(intro, "", fmt.Sprintf("![封面配图](%s)", refs[0].URL))
}
pages = append(pages, officialAccountPageChunk{
pages = append(pages, weixinPublicAccountPageChunk{
Index: 1,
Title: title,
Content: strings.Join(intro, "\n"),
@@ -424,7 +424,7 @@ func buildOfficialAccountPages(workflow *officialAccountWorkflowState) []officia
if idx+1 < len(refs) {
lines = append(lines, "", fmt.Sprintf("![段落配图](%s)", refs[idx+1].URL))
}
pages = append(pages, officialAccountPageChunk{
pages = append(pages, weixinPublicAccountPageChunk{
Index: idx + 2,
Title: item.Title,
Content: strings.TrimSpace(strings.Join(lines, "\n")),
@@ -433,7 +433,7 @@ func buildOfficialAccountPages(workflow *officialAccountWorkflowState) []officia
return pages
}
func buildOfficialAccountPageMarkdown(pages []officialAccountPageChunk) string {
func buildWeixinPublicAccountPageMarkdown(pages []weixinPublicAccountPageChunk) string {
chunks := make([]string, 0, len(pages))
for _, item := range pages {
chunks = append(chunks, fmt.Sprintf("<!-- PAGE %d: %s -->\n%s", item.Index, item.Title, item.Content))
@@ -441,10 +441,10 @@ func buildOfficialAccountPageMarkdown(pages []officialAccountPageChunk) string {
return strings.Join(chunks, "\n\n---\n\n")
}
func extractOfficialAccountSections(content string) []officialAccountSection {
func extractWeixinPublicAccountSections(content string) []weixinPublicAccountSection {
lines := strings.Split(strings.ReplaceAll(content, "\r\n", "\n"), "\n")
sections := make([]officialAccountSection, 0, 6)
current := officialAccountSection{}
sections := make([]weixinPublicAccountSection, 0, 6)
current := weixinPublicAccountSection{}
for _, raw := range lines {
line := strings.TrimSpace(raw)
if strings.HasPrefix(line, "# ") {
@@ -454,7 +454,7 @@ func extractOfficialAccountSections(content string) []officialAccountSection {
if current.Title != "" || len(current.Content) > 0 {
sections = append(sections, current)
}
current = officialAccountSection{Title: strings.TrimSpace(strings.TrimPrefix(line, "## "))}
current = weixinPublicAccountSection{Title: strings.TrimSpace(strings.TrimPrefix(line, "## "))}
continue
}
if current.Title == "" && line == "" {
@@ -486,7 +486,7 @@ func trimEmptyLines(lines []string) []string {
return append([]string(nil), lines[start:end]...)
}
func renderOfficialAccountHTML(markdown string) string {
func renderWeixinPublicAccountHTML(markdown string) string {
var builder strings.Builder
builder.WriteString(`<article class="oa-preview">`)
listOpen := false
@@ -505,7 +505,7 @@ func renderOfficialAccountHTML(markdown string) string {
builder.WriteString(`</ul>`)
listOpen = false
}
alt, src := parseOfficialAccountImageMarkdown(line)
alt, src := parseWeixinPublicAccountImageMarkdown(line)
builder.WriteString(fmt.Sprintf(`<figure class="oa-preview-figure"><img src="%s" alt="%s" /><figcaption>%s</figcaption></figure>`, html.EscapeString(src), html.EscapeString(alt), html.EscapeString(alt)))
continue
}
@@ -554,7 +554,7 @@ func renderOfficialAccountHTML(markdown string) string {
return builder.String()
}
func parseOfficialAccountImageMarkdown(line string) (string, string) {
func parseWeixinPublicAccountImageMarkdown(line string) (string, string) {
startAlt := strings.Index(line, "![")
mid := strings.Index(line, "](")
end := strings.LastIndex(line, ")")
@@ -566,49 +566,59 @@ func parseOfficialAccountImageMarkdown(line string) (string, string) {
return firstNonEmpty(strings.TrimSpace(alt), "配图"), strings.TrimSpace(src)
}
func ExportOfficialAccountDocument(c *gin.Context) {
func ExportWeixinPublicAccountDocument(c *gin.Context) {
user := middleware.CurrentUser(c)
task, ok := loadAccessibleTask(c, user)
if !ok {
return
}
if task.SpecialistKey != officialAccountSpecialistKey {
if task.SpecialistKey != weixinPublicAccountSpecialistKey {
web.Fail(c, web.NewNotFoundError("当前事项不属于公众号创作专员"))
return
}
workflow := parseOfficialAccountWorkflow(task.ContextJSON)
article, err := ensureOfficialAccountArticle(task, workflow)
workflow := parseWeixinPublicAccountWorkflow(task.ContextJSON)
article, err := ensureWeixinPublicAccountArticle(task, workflow)
if err != nil {
web.Fail(c, web.NewBadRequest("读取公众号文章状态失败"))
return
}
syncWorkflowFromOfficialAccountArticle(&workflow, article)
syncWorkflowFromWeixinPublicAccountArticle(&workflow, article)
format := strings.TrimSpace(strings.ToLower(c.DefaultQuery("format", "preview_md")))
filename := "official-account-export.md"
filename := "weixin-public-account-export.md"
contentType := "text/markdown; charset=utf-8"
body := workflow.Shared.PreviewMarkdown
switch format {
case "preview_md":
if strings.TrimSpace(body) == "" {
body = buildOfficialAccountPreviewMarkdown(&workflow)
body = buildWeixinPublicAccountPreviewMarkdown(&workflow)
}
filename = "official-account-preview.md"
filename = "weixin-public-account-preview.md"
case "preview_html":
contentType = "text/html; charset=utf-8"
body = workflow.Shared.PreviewHTML
if strings.TrimSpace(body) == "" {
body = renderOfficialAccountHTML(buildOfficialAccountPreviewMarkdown(&workflow))
body = renderWeixinPublicAccountHTML(buildWeixinPublicAccountPreviewMarkdown(&workflow))
}
filename = "official-account-preview.html"
filename = "weixin-public-account-preview.html"
case "pagemd":
body = workflow.Shared.PageMarkdown
if strings.TrimSpace(body) == "" {
body = buildOfficialAccountPageMarkdown(buildOfficialAccountPages(&workflow))
body = buildWeixinPublicAccountPageMarkdown(buildWeixinPublicAccountPages(&workflow))
}
filename = "official-account-pagemd.md"
filename = "weixin-public-account-pagemd.md"
case "docx":
docxBytes, buildErr := buildWeixinPublicAccountDOCX(&workflow)
if buildErr != nil {
web.Fail(c, web.NewBadRequest("生成 Word 文档失败"))
return
}
contentType = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename*=UTF-8''weixin-public-account-preview.docx"))
c.Data(200, contentType, docxBytes)
return
default:
web.Fail(c, web.NewBadRequest("导出格式不支持"))
return
@@ -0,0 +1,336 @@
package weixinpublicaccountapi
import (
"archive/zip"
"bytes"
"encoding/base64"
"fmt"
"image"
_ "image/gif"
_ "image/jpeg"
_ "image/png"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
)
// weixinPublicAccountDocImage 保存一张待写进 docx 的图片。
type weixinPublicAccountDocImage struct {
ID int
Ext string
Content []byte
WidthEMU int
HeightEMU int
}
// buildWeixinPublicAccountDOCX 把「预览稿」导出为排版良好的 Word 文档:
// 题目为 Title 样式,各级小节为 Heading1,正文按段落排,封面图与各小节配图
// 分别嵌入到合适位置并居中。图片优先读本地落盘文件,其次解析 data: URL,
// 对外部 HTTP 地址会尝试下载;三种来源任一拿不到字节就跳过内嵌,不影响整份文档。
func buildWeixinPublicAccountDOCX(workflow *weixinPublicAccountWorkflowState) ([]byte, error) {
title := firstNonEmpty(workflow.Shared.SelectedTitle, workflow.Form.Keyword, "公众号文章")
sections := extractWeixinPublicAccountSections(workflow.Shared.Content)
refs := workflow.Shared.ImageRefs
images := make([]weixinPublicAccountDocImage, 0, len(refs))
for idx := 0; idx < len(refs); idx++ {
data, ext, ok := readWeixinPublicAccountDocImage(refs[idx].URL)
if !ok {
continue
}
w, h, wErr := imageDecodeWeixinPublicAccountImage(data)
if wErr != nil {
w, h = 0, 0
}
images = append(images, weixinPublicAccountDocImage{
ID: idx + 1,
Ext: ext,
Content: data,
WidthEMU: w,
HeightEMU: h,
})
}
coverID := -1
for i := range images {
if images[i].ID == 1 {
coverID = images[i].ID
break
}
}
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
// 包级内容类型与根关系。
zipWriteDocxFile(zw, "[Content_Types].xml", buildWeixinOAContentTypes(images))
zipWriteDocxFile(zw, "_rels/.rels", `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>`+
`<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">`+
`<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>`+
`</Relationships>`)
// 关系列表:rId1 = styles,rId2.. = 图片。
var rels strings.Builder
rels.WriteString(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` +
`<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">` +
`<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>`)
imageRel := make(map[int]string)
for _, img := range images {
rid := fmt.Sprintf("rId%d", img.ID+1)
imageRel[img.ID] = rid
rels.WriteString(fmt.Sprintf(`<Relationship Id="%s" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="media/weixin_oa_image_%d.%s"/>`, rid, img.ID, img.Ext))
}
rels.WriteString(`</Relationships>`)
zipWriteDocxFile(zw, "word/_rels/document.xml.rels", rels.String())
// 正文。
var doc strings.Builder
doc.WriteString(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` +
`<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" ` +
`xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" ` +
`xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing" ` +
`xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" ` +
`xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture"><w:body>`)
// 标题。
doc.WriteString(`<w:p><w:pPr><w:pStyle w:val="Title"/><w:jc w:val="center"/></w:pPr>` +
`<w:r><w:rPr><w:b/><w:sz w:val="44"/></w:rPr><w:t>` + escapeWeixinOADocXML(title) + `</w:t></w:r></w:p>`)
// 封面图。
if coverID > 0 {
doc.WriteString(buildWeixinOADocParagraphImage(coverID, images, imageRel, 16))
}
// 各小节 + 段内配图。
for idx, sec := range sections {
doc.WriteString(`<w:p><w:pPr><w:pStyle w:val="Heading1"/></w:pPr>` +
`<w:r><w:rPr><w:b/><w:sz w:val="28"/></w:rPr><w:t>` + escapeWeixinOADocXML(sec.Title) + `</w:t></w:r></w:p>`)
for _, line := range sec.Content {
text := strings.TrimSpace(line)
if text == "" {
continue
}
doc.WriteString(`<w:p><w:r><w:rPr><w:sz w:val="22"/></w:rPr>` +
`<w:t xml:space="preserve">` + escapeWeixinOADocXML(text) + `</w:t></w:r></w:p>`)
}
// 小节配图:封面图是第 1 张、已放开头,这里按顺序放第 2..N 张。
needID := idx + 2
if _, ok := findWeixinOADocImageByID(images, needID); ok {
doc.WriteString(buildWeixinOADocParagraphImage(needID, images, imageRel, 12))
}
}
doc.WriteString(`</w:body></w:document>`)
zipWriteDocxFile(zw, "word/document.xml", doc.String())
// styles.xml。
zipWriteDocxFile(zw, "word/styles.xml", `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>`+
`<w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">`+
`<w:docDefaults><w:rPrDefault><w:rPr><w:rFonts w:ascii="Calibri" w:hAnsi="Calibri" w:eastAsia="Microsoft YaHei"/><w:sz w:val="22"/></w:rPr></w:rPrDefault></w:docDefaults>`+
`<w:style w:type="paragraph" w:styleId="Title"><w:name w:val="Title"/><w:rPr><w:rFonts w:ascii="Calibri" w:hAnsi="Calibri" w:eastAsia="Microsoft YaHei"/><w:b/><w:sz w:val="44"/></w:rPr></w:style>`+
`<w:style w:type="paragraph" w:styleId="Heading1"><w:name w:val="Heading 1"/><w:basedOn w:val="Normal"/><w:rPr><w:rFonts w:ascii="Calibri" w:hAnsi="Calibri" w:eastAsia="Microsoft YaHei"/><w:b/><w:sz w:val="28"/></w:rPr></w:style>`+
`</w:styles>`)
// 图片媒体文件。
for _, img := range images {
zipWriteDocxBinary(zw, fmt.Sprintf("word/media/weixin_oa_image_%d.%s", img.ID, img.Ext), img.Content)
}
zipWriteDocxFile(zw, "docProps/core.xml", `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>`+
`<cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties" xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:title>`+escapeWeixinOADocXML(title)+`</dc:title>`+
`<dcterms:created xmlns:dcterms="http://purl.org/dc/terms/" xsi:type="dcterms:W3CDTF" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">`+time.Now().UTC().Format("2006-01-02T15:04:05Z")+`</dcterms:created></cp:coreProperties>`)
zipWriteDocxFile(zw, "docProps/app.xml", `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>`+
`<Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/extended-properties"><Application>EAI Agent Platform</Application></Properties>`)
if err := zw.Close(); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
// buildWeixinOAContentTypes 生成包级内容类型;图片需要按扩展名声明 Default。
func buildWeixinOAContentTypes(images []weixinPublicAccountDocImage) string {
defaults := map[string]string{
"xml": "application/xml",
"rels": "application/vnd.openxmlformats-package.relationships+xml",
}
imageTypes := map[string]string{
"png": "image/png",
"jpg": "image/jpeg",
"jpeg": "image/jpeg",
"gif": "image/gif",
"webp": "image/webp",
"bmp": "image/bmp",
}
for _, img := range images {
if ct, ok := imageTypes[strings.ToLower(img.Ext)]; ok {
defaults[strings.ToLower(img.Ext)] = ct
}
}
var b strings.Builder
b.WriteString(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>`)
b.WriteString(`<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">`)
for ext, ct := range defaults {
b.WriteString(`<Default Extension="` + ext + `" ContentType="` + ct + `"/>`)
}
b.WriteString(`<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>`)
b.WriteString(`<Override PartName="/word/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/>`)
b.WriteString(`</Types>`)
return b.String()
}
// buildWeixinOADocParagraphImage 构造一个居中插入图片的段落。
// widthCm 用于给出期望显示宽度,高按原图比例自适应。
func buildWeixinOADocParagraphImage(id int, images []weixinPublicAccountDocImage, rel map[int]string, widthCm int) string {
img, ok := findWeixinOADocImageByID(images, id)
if !ok {
return ""
}
rid := rel[id]
if rid == "" {
return ""
}
const emuPerCm = 360000
cx := widthCm * emuPerCm
cy := cx
if img.WidthEMU > 0 && img.HeightEMU > 0 {
cy = cx * img.HeightEMU / img.WidthEMU
}
// 高度限制,避免超界。
if cy <= 0 || cy > 12*emuPerCm {
cy = cx * 9 / 16
}
return fmt.Sprintf(
`<w:p><w:pPr><w:jc w:val="center"/><w:spacing w:before="120" w:after="120"/></w:pPr>`+
`<w:r><w:drawing><wp:inline distT="0" distB="0" distL="0" distR="0">`+
`<wp:extent cx="%d" cy="%d"/>`+
`<wp:docPr id="%d" name="Picture %d"/>`+
`<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture">`+
`<pic:pic xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture">`+
`<pic:nvPicPr><pic:cNvPr id="%d" name="Picture %d"/><pic:cNvPicPr/></pic:nvPicPr>`+
`<pic:blipFill><a:blip r:embed="%s"/><a:stretch><a:fillRect/></a:stretch></pic:blipFill>`+
`<pic:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="%d" cy="%d"/></a:xfrm>`+
`<a:prstGeom prst="rect"><a:avLst/></a:prstGeom></pic:spPr>`+
`</pic:pic></a:graphicData></a:graphic>`+
`</wp:inline></w:drawing></w:r></w:p>`,
cx, cy, id, id, id, id, rid, cx, cy)
}
// readWeixinPublicAccountDocImage 从三种来源读取图片字节:
// 本地落盘文件路径、data: URL、外部 HTTP 地址。返回字节、扩展名、是否成功。
func readWeixinPublicAccountDocImage(ref string) ([]byte, string, bool) {
ref = strings.TrimSpace(ref)
if ref == "" {
return nil, "", false
}
if strings.HasPrefix(ref, "data:image/") {
header := strings.SplitN(ref, ",", 2)
if len(header) != 2 {
return nil, "", false
}
payload, err := base64.StdEncoding.DecodeString(header[1])
if err != nil {
return nil, "", false
}
return payload, guessWeixinOADocExt(header[0]), true
}
// 本地文件:URL 形如 /api/weixin-public-account/generated-images/<filename>
if strings.HasPrefix(ref, "/api/weixin-public-account/generated-images/") {
name := filepath.Base(ref)
full := filepath.Join(weixinPublicAccountGeneratedImageDir(), name)
data, err := os.ReadFile(full)
if err != nil {
return nil, "", false
}
return data, guessWeixinOADocExtFromName(name), true
}
if strings.HasPrefix(ref, "http://") || strings.HasPrefix(ref, "https://") {
client := &http.Client{Timeout: 20 * time.Second}
resp, err := client.Get(ref)
if err != nil {
return nil, "", false
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, "", false
}
data, err := io.ReadAll(resp.Body)
if err != nil || len(data) == 0 {
return nil, "", false
}
ctype := resp.Header.Get("Content-Type")
return data, guessWeixinOADocExt(ctype), true
}
return nil, "", false
}
// imageDecodeWeixinPublicAccountImage 读取图片宽高(EMU 无关,返回像素宽高)。
func imageDecodeWeixinPublicAccountImage(data []byte) (w, h int, err error) {
cfg, _, err := image.DecodeConfig(bytes.NewReader(data))
if err != nil {
return 0, 0, err
}
return cfg.Width, cfg.Height, nil
}
func findWeixinOADocImageByID(images []weixinPublicAccountDocImage, id int) (weixinPublicAccountDocImage, bool) {
for _, img := range images {
if img.ID == id {
return img, true
}
}
return weixinPublicAccountDocImage{}, false
}
func guessWeixinOADocExt(header string) string {
header = strings.ToLower(header)
switch {
case strings.Contains(header, "jpeg") || strings.Contains(header, "jpg"):
return "jpg"
case strings.Contains(header, "gif"):
return "gif"
case strings.Contains(header, "webp"):
return "webp"
case strings.Contains(header, "bmp"):
return "bmp"
default:
return "png"
}
}
func guessWeixinOADocExtFromName(name string) string {
ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(name), "."))
if ext == "" {
return "png"
}
return ext
}
func escapeWeixinOADocXML(s string) string {
s = strings.ReplaceAll(s, "&", "&amp;")
s = strings.ReplaceAll(s, "<", "&lt;")
s = strings.ReplaceAll(s, ">", "&gt;")
s = strings.ReplaceAll(s, `"`, "&quot;")
s = strings.ReplaceAll(s, "'", "&apos;")
return s
}
func zipWriteDocxFile(zw *zip.Writer, name, content string) error {
f, err := zw.Create(name)
if err != nil {
return err
}
_, err = f.Write([]byte(content))
return err
}
func zipWriteDocxBinary(zw *zip.Writer, name string, content []byte) error {
f, err := zw.Create(name)
if err != nil {
return err
}
_, err = f.Write(content)
return err
}
@@ -1,25 +1,25 @@
package wechatofficialaccountapi
package weixinpublicaccountapi
import "strings"
const (
officialAccountBusinessDomainAuto = "auto"
officialAccountBusinessDomainAI = "ai"
officialAccountBusinessDomainMedical = "medical"
officialAccountBusinessDomainInsurance = "insurance"
officialAccountBusinessDomainHR = "hr"
officialAccountBusinessDomainLegal = "legal"
officialAccountBusinessDomainGeneric = "generic"
weixinPublicAccountBusinessDomainAuto = "auto"
weixinPublicAccountBusinessDomainAI = "ai"
weixinPublicAccountBusinessDomainMedical = "medical"
weixinPublicAccountBusinessDomainInsurance = "insurance"
weixinPublicAccountBusinessDomainHR = "hr"
weixinPublicAccountBusinessDomainLegal = "legal"
weixinPublicAccountBusinessDomainGeneric = "generic"
)
type officialAccountHotspotSource struct {
type weixinPublicAccountHotspotSource struct {
Key string
Label string
Type string
URL string
}
type officialAccountBusinessDomainConfig struct {
type weixinPublicAccountBusinessDomainConfig struct {
Key string
Label string
PromptLabel string
@@ -28,18 +28,18 @@ type officialAccountBusinessDomainConfig struct {
BusinessKeywords []string
BoostKeywords []string
FallbackSearchHints []string
Sources []officialAccountHotspotSource
Sources []weixinPublicAccountHotspotSource
}
var officialAccountBusinessDomainConfigs = map[string]officialAccountBusinessDomainConfig{
officialAccountBusinessDomainAI: {
Key: officialAccountBusinessDomainAI,
var weixinPublicAccountBusinessDomainConfigs = map[string]weixinPublicAccountBusinessDomainConfig{
weixinPublicAccountBusinessDomainAI: {
Key: weixinPublicAccountBusinessDomainAI,
Label: "AI / 智能体",
PromptLabel: "AI 业务域",
DefaultKeyword: "AI 落地",
DomainKeywords: []string{
"ai", "aigc", "大模型", "人工智能", "智能体", "agent", "生成式", "机器学习", "多模态", "工作流", "知识库",
"rag", "copilot", "自动化", "机器人", "推理", "模型", "算力", "数据智能", "数字员工", "workbuddy",
"rag", "copilot", "自动化", "推理", "模型", "算力", "数据智能", "数字员工", "workbuddy",
},
BusinessKeywords: []string{
"企业", "落地", "场景", "产品", "平台", "工作台", "办公", "培训", "实施", "知识库", "销售", "客户",
@@ -47,37 +47,38 @@ var officialAccountBusinessDomainConfigs = map[string]officialAccountBusinessDom
},
BoostKeywords: []string{"工作流", "智能体", "企业ai"},
FallbackSearchHints: []string{"AI", "大模型", "智能体", "企业AI"},
Sources: []officialAccountHotspotSource{
Sources: []weixinPublicAccountHotspotSource{
{Key: "36kr_feed", Label: "36氪订阅", Type: "rss", URL: "https://www.36kr.com/feed"},
{Key: "ithome_rss", Label: "IT之家订阅", Type: "rss", URL: "https://www.ithome.com/rss/"},
{Key: "36kr_ai_web", Label: "36氪AI专题", Type: "web", URL: "https://www.36kr.com/information/AI/"},
{Key: "36kr_aigc_web", Label: "36氪AIGC专题", Type: "web", URL: "https://www.36kr.com/information/AIGC/"},
},
},
officialAccountBusinessDomainMedical: {
Key: officialAccountBusinessDomainMedical,
weixinPublicAccountBusinessDomainMedical: {
Key: weixinPublicAccountBusinessDomainMedical,
Label: "医疗 / 健康",
PromptLabel: "医疗健康业务域",
DefaultKeyword: "医疗健康",
DomainKeywords: []string{
"医疗", "医保", "医院", "医生", "患者", "医药", "药企", "药品", "诊疗", "健康", "护理", "手术", "疾病", "控费",
"集采", "互联网医疗", "医疗器械", "生物医药", "健康险", "临床", "门诊", "住院", "卫健", "医保局", "药监", "药审",
"创新药", "医改", "药械", "医用耗材", "公共卫生", "分级诊疗",
"创新药", "医改", "药械", "医用耗材", "公共卫生", "分级诊疗",
"外骨骼", "外骨骼机器人", "康复机器人", "康复训练", "助行", "步态", "偏瘫", "截瘫",
},
BusinessKeywords: []string{
"政策", "支付", "落地", "案例", "服务", "行业", "机构", "改革", "商业模式", "运营", "平台", "场景", "解决方案",
"通知", "公告", "指南", "征求意见", "审评", "审批", "基金", "目录", "招采", "挂网", "集采",
},
BoostKeywords: []string{"医保", "医院", "医疗器械", "互联网医疗", "创新药", "药审", "医改", "分级诊疗"},
FallbackSearchHints: []string{"医疗", "医保", "医院", "健康险", "创新药", "医疗器械"},
Sources: []officialAccountHotspotSource{
BoostKeywords: []string{"医保", "医院", "医疗器械", "互联网医疗", "创新药", "药审", "医改", "分级诊疗", "外骨骼", "康复机器人", "康复训练"},
FallbackSearchHints: []string{"医疗", "医保", "医院", "健康险", "创新药", "医疗器械", "外骨骼", "康复机器人"},
Sources: []weixinPublicAccountHotspotSource{
{Key: "nhsa_policy_web", Label: "国家医保局政策栏目", Type: "web", URL: "https://www.nhsa.gov.cn/col/col14/index.html"},
{Key: "nhsa_news_web", Label: "国家医保局工作动态", Type: "web", URL: "https://www.nhsa.gov.cn/col/col104/index.html"},
{Key: "cde_home_web", Label: "药审中心", Type: "web", URL: "https://www.cde.org.cn/"},
},
},
officialAccountBusinessDomainInsurance: {
Key: officialAccountBusinessDomainInsurance,
weixinPublicAccountBusinessDomainInsurance: {
Key: weixinPublicAccountBusinessDomainInsurance,
Label: "保险 / 金融",
PromptLabel: "保险金融业务域",
DefaultKeyword: "保险行业",
@@ -90,14 +91,14 @@ var officialAccountBusinessDomainConfigs = map[string]officialAccountBusinessDom
},
BoostKeywords: []string{"理赔", "偿付能力", "代理人", "保险公司"},
FallbackSearchHints: []string{"保险", "理赔", "代理人", "养老保险"},
Sources: []officialAccountHotspotSource{
Sources: []weixinPublicAccountHotspotSource{
{Key: "iachina_web", Label: "中国保险行业协会", Type: "web", URL: "https://www.iachina.cn/col/col3/index.html"},
{Key: "cnstock_insurance_web", Label: "上海证券报保险频道", Type: "web", URL: "http://www.cnstock.com/channel/10120"},
{Key: "china_finance_insurance_web", Label: "中国网财经保险", Type: "web", URL: "http://finance.china.com.cn/money/insurance/index.shtml"},
},
},
officialAccountBusinessDomainHR: {
Key: officialAccountBusinessDomainHR,
weixinPublicAccountBusinessDomainHR: {
Key: weixinPublicAccountBusinessDomainHR,
Label: "HR / 招聘",
PromptLabel: "人力资源业务域",
DefaultKeyword: "人力资源",
@@ -110,14 +111,14 @@ var officialAccountBusinessDomainConfigs = map[string]officialAccountBusinessDom
},
BoostKeywords: []string{"招聘", "社保", "薪酬", "劳动"},
FallbackSearchHints: []string{"招聘", "人力资源", "薪酬", "社保"},
Sources: []officialAccountHotspotSource{
Sources: []weixinPublicAccountHotspotSource{
{Key: "hrac_web", Label: "中国人力资源协会", Type: "web", URL: "http://www.hrac.net.cn/CN/HR/"},
{Key: "hrtechweekly_rss", Label: "HR Tech Weekly", Type: "rss", URL: "https://hrtechweekly.com/feed/"},
{Key: "techrseries_rss", Label: "HRTech Series", Type: "rss", URL: "https://techrseries.com/feed/"},
},
},
officialAccountBusinessDomainLegal: {
Key: officialAccountBusinessDomainLegal,
weixinPublicAccountBusinessDomainLegal: {
Key: weixinPublicAccountBusinessDomainLegal,
Label: "法务 / 合规",
PromptLabel: "法务合规业务域",
DefaultKeyword: "法务合规",
@@ -130,14 +131,14 @@ var officialAccountBusinessDomainConfigs = map[string]officialAccountBusinessDom
},
BoostKeywords: []string{"合同", "合规", "数据", "劳动法"},
FallbackSearchHints: []string{"合同", "合规", "劳动法", "数据合规"},
Sources: []officialAccountHotspotSource{
Sources: []weixinPublicAccountHotspotSource{
{Key: "law_asia_cn_web", Label: "商法中国", Type: "web", URL: "https://law.asia/zh-hans/china/"},
{Key: "law_asia_en_web", Label: "China Business Law Journal", Type: "web", URL: "https://law.asia/china/"},
{Key: "jurist_rss", Label: "Jurist", Type: "rss", URL: "https://www.jurist.org/news/feed/"},
},
},
officialAccountBusinessDomainGeneric: {
Key: officialAccountBusinessDomainGeneric,
weixinPublicAccountBusinessDomainGeneric: {
Key: weixinPublicAccountBusinessDomainGeneric,
Label: "通用业务域",
PromptLabel: "通用业务域",
DefaultKeyword: "业务热点",
@@ -149,7 +150,7 @@ var officialAccountBusinessDomainConfigs = map[string]officialAccountBusinessDom
},
BoostKeywords: []string{"行业", "企业", "趋势"},
FallbackSearchHints: []string{"行业", "企业", "政策", "趋势"},
Sources: []officialAccountHotspotSource{
Sources: []weixinPublicAccountHotspotSource{
{Key: "36kr_feed", Label: "36氪订阅", Type: "rss", URL: "https://www.36kr.com/feed"},
{Key: "cnstock_business_web", Label: "上海证券报", Type: "web", URL: "http://www.cnstock.com/"},
{Key: "ithome_rss", Label: "IT之家订阅", Type: "rss", URL: "https://www.ithome.com/rss/"},
@@ -157,47 +158,56 @@ var officialAccountBusinessDomainConfigs = map[string]officialAccountBusinessDom
},
}
var officialAccountBusinessDomainDetectionHints = map[string][]string{
officialAccountBusinessDomainAI: officialAccountBusinessDomainConfigs[officialAccountBusinessDomainAI].DomainKeywords,
officialAccountBusinessDomainMedical: officialAccountBusinessDomainConfigs[officialAccountBusinessDomainMedical].DomainKeywords,
officialAccountBusinessDomainInsurance: officialAccountBusinessDomainConfigs[officialAccountBusinessDomainInsurance].DomainKeywords,
officialAccountBusinessDomainHR: officialAccountBusinessDomainConfigs[officialAccountBusinessDomainHR].DomainKeywords,
officialAccountBusinessDomainLegal: officialAccountBusinessDomainConfigs[officialAccountBusinessDomainLegal].DomainKeywords,
var weixinPublicAccountBusinessDomainDetectionHints = map[string][]string{
weixinPublicAccountBusinessDomainAI: {
"ai", "aigc", "大模型", "人工智能", "智能体", "agent", "生成式", "机器学习", "多模态", "rag", "copilot",
},
weixinPublicAccountBusinessDomainMedical: {
"医疗", "健康", "医院", "医保", "医疗器械", "临床", "外骨骼", "外骨骼机器人", "康复机器人", "康复训练", "助行", "步态", "偏瘫", "截瘫",
},
weixinPublicAccountBusinessDomainInsurance: weixinPublicAccountBusinessDomainConfigs[weixinPublicAccountBusinessDomainInsurance].DomainKeywords,
weixinPublicAccountBusinessDomainHR: weixinPublicAccountBusinessDomainConfigs[weixinPublicAccountBusinessDomainHR].DomainKeywords,
weixinPublicAccountBusinessDomainLegal: weixinPublicAccountBusinessDomainConfigs[weixinPublicAccountBusinessDomainLegal].DomainKeywords,
}
func normalizeOfficialAccountBusinessDomain(value string) string {
func normalizeWeixinPublicAccountBusinessDomain(value string) string {
value = strings.TrimSpace(strings.ToLower(value))
switch value {
case "", officialAccountBusinessDomainAuto, "自动识别", "自动":
return officialAccountBusinessDomainAuto
case "", weixinPublicAccountBusinessDomainAuto, "自动识别", "自动":
return weixinPublicAccountBusinessDomainAuto
case "ai", "aigc", "智能体", "人工智能":
return officialAccountBusinessDomainAI
return weixinPublicAccountBusinessDomainAI
case "medical", "health", "医疗", "健康":
return officialAccountBusinessDomainMedical
return weixinPublicAccountBusinessDomainMedical
case "insurance", "finance", "保险", "金融":
return officialAccountBusinessDomainInsurance
return weixinPublicAccountBusinessDomainInsurance
case "hr", "recruiting", "招聘", "人力", "人力资源":
return officialAccountBusinessDomainHR
return weixinPublicAccountBusinessDomainHR
case "legal", "compliance", "法务", "合规", "法律":
return officialAccountBusinessDomainLegal
return weixinPublicAccountBusinessDomainLegal
case "generic", "other", "其他", "通用":
return officialAccountBusinessDomainGeneric
return weixinPublicAccountBusinessDomainGeneric
default:
if _, ok := officialAccountBusinessDomainConfigs[value]; ok {
if _, ok := weixinPublicAccountBusinessDomainConfigs[value]; ok {
return value
}
return officialAccountBusinessDomainGeneric
return weixinPublicAccountBusinessDomainGeneric
}
}
func detectOfficialAccountBusinessDomain(keyword string) string {
func detectWeixinPublicAccountBusinessDomain(keyword string) string {
text := strings.ToLower(strings.TrimSpace(keyword))
if text == "" {
return officialAccountBusinessDomainAI
return weixinPublicAccountBusinessDomainAI
}
bestKey := officialAccountBusinessDomainAI
for _, anchor := range []string{"外骨骼", "外骨骼机器人", "康复机器人", "康复训练", "助行", "步态", "偏瘫", "截瘫"} {
if strings.Contains(text, strings.ToLower(anchor)) {
return weixinPublicAccountBusinessDomainMedical
}
}
bestKey := weixinPublicAccountBusinessDomainAI
bestScore := 0
for key, hints := range officialAccountBusinessDomainDetectionHints {
for key, hints := range weixinPublicAccountBusinessDomainDetectionHints {
score := 0
for _, hint := range hints {
if strings.Contains(text, strings.ToLower(hint)) {
@@ -210,30 +220,30 @@ func detectOfficialAccountBusinessDomain(keyword string) string {
}
}
if bestScore == 0 {
return officialAccountBusinessDomainGeneric
return weixinPublicAccountBusinessDomainGeneric
}
return bestKey
}
func resolveOfficialAccountBusinessDomain(domain, keyword string) string {
normalized := normalizeOfficialAccountBusinessDomain(domain)
if normalized == officialAccountBusinessDomainAuto {
return detectOfficialAccountBusinessDomain(keyword)
func resolveWeixinPublicAccountBusinessDomain(domain, keyword string) string {
normalized := normalizeWeixinPublicAccountBusinessDomain(domain)
if normalized == weixinPublicAccountBusinessDomainAuto {
return detectWeixinPublicAccountBusinessDomain(keyword)
}
return normalized
}
func getOfficialAccountBusinessDomainConfig(domain string) officialAccountBusinessDomainConfig {
key := normalizeOfficialAccountBusinessDomain(domain)
if key == officialAccountBusinessDomainAuto {
key = officialAccountBusinessDomainAI
func getWeixinPublicAccountBusinessDomainConfig(domain string) weixinPublicAccountBusinessDomainConfig {
key := normalizeWeixinPublicAccountBusinessDomain(domain)
if key == weixinPublicAccountBusinessDomainAuto {
key = weixinPublicAccountBusinessDomainAI
}
if cfg, ok := officialAccountBusinessDomainConfigs[key]; ok {
if cfg, ok := weixinPublicAccountBusinessDomainConfigs[key]; ok {
return cfg
}
return officialAccountBusinessDomainConfigs[officialAccountBusinessDomainGeneric]
return weixinPublicAccountBusinessDomainConfigs[weixinPublicAccountBusinessDomainGeneric]
}
func officialAccountBusinessDomainLabel(domain string) string {
return getOfficialAccountBusinessDomainConfig(domain).Label
func weixinPublicAccountBusinessDomainLabel(domain string) string {
return getWeixinPublicAccountBusinessDomainConfig(domain).Label
}
@@ -1,4 +1,4 @@
package wechatofficialaccountapi
package weixinpublicaccountapi
import (
"crypto/sha1"
@@ -13,20 +13,20 @@ import (
"eai_agentplatform/backend/internal/config"
"eai_agentplatform/backend/internal/model"
officialaccountmodel "eai_agentplatform/backend/internal/specialists/packages/wechat_official_account/model"
weixinpublicaccountmodel "eai_agentplatform/backend/internal/specialists/packages/weixin_public_account/model"
"eai_agentplatform/backend/internal/web"
)
func officialAccountGeneratedImageDir() string {
func weixinPublicAccountGeneratedImageDir() string {
cfg := config.Load()
return filepath.Join(filepath.Dir(cfg.DBPath), "official_account_generated_images")
return filepath.Join(filepath.Dir(cfg.DBPath), "weixin_public_account_generated_images")
}
func normalizeOfficialAccountMediaForResponse(task *model.TaskRecord, workflow *officialAccountWorkflowState, article *officialaccountmodel.OfficialAccountArticle) error {
func normalizeWeixinPublicAccountMediaForResponse(task *model.TaskRecord, workflow *weixinPublicAccountWorkflowState, article *weixinpublicaccountmodel.WeixinPublicAccountArticle) error {
if workflow == nil || article == nil || task == nil {
return nil
}
refs, replacements, changed, err := persistOfficialAccountImageRefs(task.ID, workflow.Shared.ImageRefs)
refs, replacements, changed, err := persistWeixinPublicAccountImageRefs(task.ID, workflow.Shared.ImageRefs)
if err != nil {
return err
}
@@ -35,14 +35,14 @@ func normalizeOfficialAccountMediaForResponse(task *model.TaskRecord, workflow *
}
workflow.Shared.ImageRefs = refs
workflow.Shared.PreviewMarkdown = replaceOfficialAccountURLs(workflow.Shared.PreviewMarkdown, replacements)
workflow.Shared.PreviewHTML = replaceOfficialAccountURLs(workflow.Shared.PreviewHTML, replacements)
workflow.Shared.PageMarkdown = replaceOfficialAccountURLs(workflow.Shared.PageMarkdown, replacements)
workflow.Shared.PreviewMarkdown = replaceWeixinPublicAccountURLs(workflow.Shared.PreviewMarkdown, replacements)
workflow.Shared.PreviewHTML = replaceWeixinPublicAccountURLs(workflow.Shared.PreviewHTML, replacements)
workflow.Shared.PageMarkdown = replaceWeixinPublicAccountURLs(workflow.Shared.PageMarkdown, replacements)
for idx := range workflow.Shared.Pages {
workflow.Shared.Pages[idx].Content = replaceOfficialAccountURLs(workflow.Shared.Pages[idx].Content, replacements)
workflow.Shared.Pages[idx].Content = replaceWeixinPublicAccountURLs(workflow.Shared.Pages[idx].Content, replacements)
}
syncOfficialAccountArticleFromWorkflow(article, *workflow)
task.ContextJSON = marshalOfficialAccountWorkflow(*workflow)
syncWeixinPublicAccountArticleFromWorkflow(article, *workflow)
task.ContextJSON = marshalWeixinPublicAccountWorkflow(*workflow)
if !oaArticleDAO.Update(article) {
return errors.New("保存公众号文章状态失败")
@@ -53,11 +53,11 @@ func normalizeOfficialAccountMediaForResponse(task *model.TaskRecord, workflow *
return nil
}
func persistOfficialAccountImageRefs(taskID uint, refs []officialAccountImageRef) ([]officialAccountImageRef, map[string]string, bool, error) {
func persistWeixinPublicAccountImageRefs(taskID uint, refs []weixinPublicAccountImageRef) ([]weixinPublicAccountImageRef, map[string]string, bool, error) {
if len(refs) == 0 {
return refs, map[string]string{}, false, nil
}
next := make([]officialAccountImageRef, len(refs))
next := make([]weixinPublicAccountImageRef, len(refs))
replacements := map[string]string{}
changed := false
for idx, item := range refs {
@@ -65,7 +65,7 @@ func persistOfficialAccountImageRefs(taskID uint, refs []officialAccountImageRef
if !strings.HasPrefix(strings.TrimSpace(item.URL), "data:image/") {
continue
}
localURL, err := persistOfficialAccountDataURL(taskID, item.Key, item.URL)
localURL, err := persistWeixinPublicAccountDataURL(taskID, item.Key, item.URL)
if err != nil {
return refs, nil, false, err
}
@@ -76,7 +76,7 @@ func persistOfficialAccountImageRefs(taskID uint, refs []officialAccountImageRef
return next, replacements, changed, nil
}
func persistOfficialAccountDataURL(taskID uint, key, raw string) (string, error) {
func persistWeixinPublicAccountDataURL(taskID uint, key, raw string) (string, error) {
parts := strings.SplitN(strings.TrimSpace(raw), ",", 2)
if len(parts) != 2 {
return "", fmt.Errorf("图片数据格式错误")
@@ -99,22 +99,22 @@ func persistOfficialAccountDataURL(taskID uint, key, raw string) (string, error)
if err != nil {
return "", err
}
dir := officialAccountGeneratedImageDir()
dir := weixinPublicAccountGeneratedImageDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", err
}
hash := sha1.Sum(payload)
filename := fmt.Sprintf("task_%d_%s_%x%s", taskID, sanitizeOfficialAccountImageKey(key), hash[:6], ext)
filename := fmt.Sprintf("task_%d_%s_%x%s", taskID, sanitizeWeixinPublicAccountImageKey(key), hash[:6], ext)
fullPath := filepath.Join(dir, filename)
if _, err := os.Stat(fullPath); err != nil {
if writeErr := os.WriteFile(fullPath, payload, 0o644); writeErr != nil {
return "", writeErr
}
}
return "/api/official-account/generated-images/" + filename, nil
return "/api/weixin-public-account/generated-images/" + filename, nil
}
func sanitizeOfficialAccountImageKey(key string) string {
func sanitizeWeixinPublicAccountImageKey(key string) string {
trimmed := strings.TrimSpace(strings.ToLower(key))
trimmed = strings.ReplaceAll(trimmed, " ", "_")
trimmed = strings.ReplaceAll(trimmed, "/", "_")
@@ -125,7 +125,7 @@ func sanitizeOfficialAccountImageKey(key string) string {
return trimmed
}
func replaceOfficialAccountURLs(content string, replacements map[string]string) string {
func replaceWeixinPublicAccountURLs(content string, replacements map[string]string) string {
next := content
for oldURL, newURL := range replacements {
next = strings.ReplaceAll(next, oldURL, newURL)
@@ -133,13 +133,13 @@ func replaceOfficialAccountURLs(content string, replacements map[string]string)
return next
}
func ServeOfficialAccountGeneratedImage(c *gin.Context) {
func ServeWeixinPublicAccountGeneratedImage(c *gin.Context) {
filename := filepath.Base(strings.TrimSpace(c.Param("filename")))
if filename == "." || filename == "" {
web.Fail(c, web.NewNotFoundError("图片不存在"))
return
}
fullPath := filepath.Join(officialAccountGeneratedImageDir(), filename)
fullPath := filepath.Join(weixinPublicAccountGeneratedImageDir(), filename)
if _, err := os.Stat(fullPath); err != nil {
web.Fail(c, web.NewNotFoundError("图片不存在"))
return
@@ -147,7 +147,7 @@ func ServeOfficialAccountGeneratedImage(c *gin.Context) {
c.File(fullPath)
}
func compactOfficialAccountRunsForResponse(items []model.TaskRun) []model.TaskRun {
func compactWeixinPublicAccountRunsForResponse(items []model.TaskRun) []model.TaskRun {
next := make([]model.TaskRun, len(items))
for idx, item := range items {
next[idx] = item
@@ -161,7 +161,7 @@ func compactOfficialAccountRunsForResponse(items []model.TaskRun) []model.TaskRu
return next
}
func compactOfficialAccountArtifactsForResponse(items []model.TaskArtifact) []model.TaskArtifact {
func compactWeixinPublicAccountArtifactsForResponse(items []model.TaskArtifact) []model.TaskArtifact {
next := make([]model.TaskArtifact, len(items))
for idx, item := range items {
next[idx] = item
@@ -1,4 +1,4 @@
package wechatofficialaccountapi
package weixinpublicaccountapi
import (
"bytes"
@@ -14,11 +14,11 @@ import (
"strings"
"time"
officialaccountmodel "eai_agentplatform/backend/internal/specialists/packages/wechat_official_account/model"
weixinpublicaccountmodel "eai_agentplatform/backend/internal/specialists/packages/weixin_public_account/model"
"golang.org/x/net/html/charset"
)
type officialAccountFetchedHotspot struct {
type weixinPublicAccountFetchedHotspot struct {
Title string
URL string
Summary string
@@ -29,12 +29,12 @@ type officialAccountFetchedHotspot struct {
}
var (
officialAccountAnchorRE = regexp.MustCompile(`(?is)<a[^>]+href=["']([^"'#]+)["'][^>]*>(.*?)</a>`)
officialAccountStripTagRE = regexp.MustCompile(`(?is)<[^>]+>`)
officialAccountSpaceRE = regexp.MustCompile(`\s+`)
weixinPublicAccountAnchorRE = regexp.MustCompile(`(?is)<a[^>]+href=["']([^"'#]+)["'][^>]*>(.*?)</a>`)
weixinPublicAccountStripTagRE = regexp.MustCompile(`(?is)<[^>]+>`)
weixinPublicAccountSpaceRE = regexp.MustCompile(`\s+`)
)
type officialAccountRSS struct {
type weixinPublicAccountRSS struct {
Channel struct {
Items []struct {
Title string `xml:"title"`
@@ -56,12 +56,12 @@ type officialAccountRSS struct {
} `xml:"entry"`
}
func ensureOfficialAccountHotspots(form officialAccountForm, force bool) ([]officialaccountmodel.OfficialAccountHotspot, []string, error) {
domainKey := resolveOfficialAccountBusinessDomain(form.BusinessDomain, form.Keyword)
cfg := getOfficialAccountBusinessDomainConfig(domainKey)
func ensureWeixinPublicAccountHotspots(form weixinPublicAccountForm, force bool) ([]weixinpublicaccountmodel.WeixinPublicAccountHotspot, []string, error) {
domainKey := resolveWeixinPublicAccountBusinessDomain(form.BusinessDomain, form.Keyword)
cfg := getWeixinPublicAccountBusinessDomainConfig(domainKey)
logs := []string{}
if !force {
cached, err := loadFreshOfficialAccountHotspots(domainKey, form.Keyword, 90*time.Minute)
cached, err := loadFreshWeixinPublicAccountHotspots(domainKey, form.Keyword, 90*time.Minute)
if err == nil && len(cached) >= 8 {
logs = append(logs, "命中热点缓存,直接复用近 90 分钟"+cfg.Label+"热点。")
return cached, logs, nil
@@ -70,40 +70,40 @@ func ensureOfficialAccountHotspots(form officialAccountForm, force bool) ([]offi
client := &http.Client{Timeout: 12 * time.Second}
totalUpserts := 0
sourceKeywords := buildOfficialAccountSourceKeywords(domainKey, form.Keyword, cfg.FallbackSearchHints)
sourceKeywords := buildWeixinPublicAccountSourceKeywords(domainKey, form.Keyword, cfg.FallbackSearchHints)
for _, source := range cfg.Sources {
items, err := fetchOfficialAccountHotspotsFromSource(client, source, sourceKeywords)
items, err := fetchWeixinPublicAccountHotspotsFromSource(client, source, sourceKeywords)
if err != nil {
logs = append(logs, source.Label+" 拉取失败:"+err.Error())
continue
}
upserts, sourceLogs := upsertOfficialAccountHotspots(domainKey, form.Keyword, items)
upserts, sourceLogs := upsertWeixinPublicAccountHotspots(domainKey, form.Keyword, items)
totalUpserts += upserts
logs = append(logs, sourceLogs...)
}
if totalUpserts == 0 {
logs = append(logs, "本轮未写入新热点,回退读取最近缓存。")
}
latest, err := loadFreshOfficialAccountHotspots(domainKey, form.Keyword, 48*time.Hour)
if err == nil && len(latest) == 0 && domainKey != officialAccountBusinessDomainGeneric {
latest, err := loadFreshWeixinPublicAccountHotspots(domainKey, form.Keyword, 48*time.Hour)
if err == nil && len(latest) == 0 && domainKey != weixinPublicAccountBusinessDomainGeneric {
logs = append(logs, "当前业务域命中热点较少,已自动降级到通用热点检索。")
genericCfg := getOfficialAccountBusinessDomainConfig(officialAccountBusinessDomainGeneric)
genericKeywords := buildOfficialAccountSourceKeywords(
officialAccountBusinessDomainGeneric,
genericCfg := getWeixinPublicAccountBusinessDomainConfig(weixinPublicAccountBusinessDomainGeneric)
genericKeywords := buildWeixinPublicAccountSourceKeywords(
weixinPublicAccountBusinessDomainGeneric,
form.Keyword,
append(cfg.FallbackSearchHints, genericCfg.FallbackSearchHints...),
)
for _, source := range genericCfg.Sources {
items, fetchErr := fetchOfficialAccountHotspotsFromSource(client, source, genericKeywords)
items, fetchErr := fetchWeixinPublicAccountHotspotsFromSource(client, source, genericKeywords)
if fetchErr != nil {
logs = append(logs, source.Label+" 泛检索失败:"+fetchErr.Error())
continue
}
upserts, sourceLogs := upsertOfficialAccountHotspots(officialAccountBusinessDomainGeneric, form.Keyword, items)
upserts, sourceLogs := upsertWeixinPublicAccountHotspots(weixinPublicAccountBusinessDomainGeneric, form.Keyword, items)
totalUpserts += upserts
logs = append(logs, sourceLogs...)
}
latest, err = loadFreshOfficialAccountHotspots(officialAccountBusinessDomainGeneric, form.Keyword, 48*time.Hour)
latest, err = loadFreshWeixinPublicAccountHotspots(weixinPublicAccountBusinessDomainGeneric, form.Keyword, 48*time.Hour)
}
if err != nil {
return nil, logs, err
@@ -111,97 +111,100 @@ func ensureOfficialAccountHotspots(form officialAccountForm, force bool) ([]offi
return latest, logs, nil
}
func loadFreshOfficialAccountHotspots(domainKey, keyword string, maxAge time.Duration) ([]officialaccountmodel.OfficialAccountHotspot, error) {
func loadFreshWeixinPublicAccountHotspots(domainKey, keyword string, maxAge time.Duration) ([]weixinpublicaccountmodel.WeixinPublicAccountHotspot, error) {
// keyword 不在 SQL 里过滤,是取回来在内存里再滤一遍(见下)——
// 库里那 40 条是「该业务域里分数最高的」,关键词只用来在其中挑相关的。
items := oaHotspotDAO.ListFresh(domainKey, time.Now().Add(-maxAge))
filtered := filterOfficialAccountHotspotsByKeyword(items, keyword)
filtered := filterWeixinPublicAccountHotspotsByKeyword(items, keyword)
if len(filtered) > 0 {
return filtered, nil
}
if len(buildWeixinPublicAccountKeywordTerms(keyword)) > 0 {
return nil, nil
}
if len(items) > 20 {
return items[:20], nil
}
return items, nil
}
func fetchOfficialAccountHotspotsFromSource(client *http.Client, source officialAccountHotspotSource, keywords []string) ([]officialAccountFetchedHotspot, error) {
func fetchWeixinPublicAccountHotspotsFromSource(client *http.Client, source weixinPublicAccountHotspotSource, keywords []string) ([]weixinPublicAccountFetchedHotspot, error) {
switch source.Type {
case "rss":
return fetchOfficialAccountRSSHotspots(client, source)
return fetchWeixinPublicAccountRSSHotspots(client, source)
default:
return fetchOfficialAccountWebHotspots(client, source, keywords)
return fetchWeixinPublicAccountWebHotspots(client, source, keywords)
}
}
func fetchOfficialAccountRSSHotspots(client *http.Client, source officialAccountHotspotSource) ([]officialAccountFetchedHotspot, error) {
body, err := fetchOfficialAccountSourceBody(client, source.URL)
func fetchWeixinPublicAccountRSSHotspots(client *http.Client, source weixinPublicAccountHotspotSource) ([]weixinPublicAccountFetchedHotspot, error) {
body, err := fetchWeixinPublicAccountSourceBody(client, source.URL)
if err != nil {
return nil, err
}
var feed officialAccountRSS
var feed weixinPublicAccountRSS
if err := xml.Unmarshal(body, &feed); err != nil {
return nil, err
}
items := make([]officialAccountFetchedHotspot, 0, 24)
items := make([]weixinPublicAccountFetchedHotspot, 0, 24)
for _, item := range feed.Channel.Items {
title := normalizeOfficialAccountText(item.Title)
title := normalizeWeixinPublicAccountText(item.Title)
link := strings.TrimSpace(item.Link)
summary := normalizeOfficialAccountText(item.Description)
summary := normalizeWeixinPublicAccountText(item.Description)
if title == "" || link == "" {
continue
}
items = append(items, officialAccountFetchedHotspot{
items = append(items, weixinPublicAccountFetchedHotspot{
Title: title,
URL: link,
Summary: summary,
PublishedAt: parseOfficialAccountTime(item.PubDate),
PublishedAt: parseWeixinPublicAccountTime(item.PubDate),
SourceKey: source.Key,
SourceLabel: source.Label,
SourceType: source.Type,
})
}
for _, entry := range feed.Entries {
title := normalizeOfficialAccountText(entry.Title)
title := normalizeWeixinPublicAccountText(entry.Title)
link := strings.TrimSpace(entry.ID)
if len(entry.Links) > 0 && strings.TrimSpace(entry.Links[0].Href) != "" {
link = strings.TrimSpace(entry.Links[0].Href)
}
summary := normalizeOfficialAccountText(firstNonEmpty(entry.Summary, entry.Content))
summary := normalizeWeixinPublicAccountText(firstNonEmpty(entry.Summary, entry.Content))
if title == "" || link == "" {
continue
}
items = append(items, officialAccountFetchedHotspot{
items = append(items, weixinPublicAccountFetchedHotspot{
Title: title,
URL: link,
Summary: summary,
PublishedAt: parseOfficialAccountTime(firstNonEmpty(entry.Published, entry.Updated)),
PublishedAt: parseWeixinPublicAccountTime(firstNonEmpty(entry.Published, entry.Updated)),
SourceKey: source.Key,
SourceLabel: source.Label,
SourceType: source.Type,
})
}
return dedupeOfficialAccountFetched(items), nil
return dedupeWeixinPublicAccountFetched(items), nil
}
func fetchOfficialAccountWebHotspots(client *http.Client, source officialAccountHotspotSource, keywords []string) ([]officialAccountFetchedHotspot, error) {
body, err := fetchOfficialAccountSourceBody(client, source.URL)
func fetchWeixinPublicAccountWebHotspots(client *http.Client, source weixinPublicAccountHotspotSource, keywords []string) ([]weixinPublicAccountFetchedHotspot, error) {
body, err := fetchWeixinPublicAccountSourceBody(client, source.URL)
if err != nil {
return nil, err
}
base, _ := neturl.Parse(source.URL)
matches := officialAccountAnchorRE.FindAllStringSubmatch(string(body), -1)
items := make([]officialAccountFetchedHotspot, 0, 24)
matches := weixinPublicAccountAnchorRE.FindAllStringSubmatch(string(body), -1)
items := make([]weixinPublicAccountFetchedHotspot, 0, 24)
for _, match := range matches {
if len(match) < 3 {
continue
}
href := strings.TrimSpace(match[1])
text := normalizeOfficialAccountText(match[2])
text := normalizeWeixinPublicAccountText(match[2])
if text == "" || len([]rune(text)) < 8 || len([]rune(text)) > 90 {
continue
}
if len(keywords) > 0 && !containsOfficialAccountKeywords(text, keywords) {
if len(keywords) > 0 && !containsWeixinPublicAccountKeywords(text, keywords) {
continue
}
if base != nil {
@@ -209,7 +212,7 @@ func fetchOfficialAccountWebHotspots(client *http.Client, source officialAccount
href = base.ResolveReference(ref).String()
}
}
items = append(items, officialAccountFetchedHotspot{
items = append(items, weixinPublicAccountFetchedHotspot{
Title: text,
URL: href,
Summary: "",
@@ -222,15 +225,15 @@ func fetchOfficialAccountWebHotspots(client *http.Client, source officialAccount
break
}
}
return dedupeOfficialAccountFetched(items), nil
return dedupeWeixinPublicAccountFetched(items), nil
}
func fetchOfficialAccountSourceBody(client *http.Client, rawURL string) ([]byte, error) {
func fetchWeixinPublicAccountSourceBody(client *http.Client, rawURL string) ([]byte, error) {
req, err := http.NewRequest(http.MethodGet, rawURL, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", "Mozilla/5.0 official-account-assistant/1.0")
req.Header.Set("User-Agent", "Mozilla/5.0 weixin-public-account-assistant/1.0")
resp, err := client.Do(req)
if err != nil {
return nil, err
@@ -251,21 +254,21 @@ func fetchOfficialAccountSourceBody(client *http.Client, rawURL string) ([]byte,
return decodedBody, nil
}
func upsertOfficialAccountHotspots(domainKey, keyword string, items []officialAccountFetchedHotspot) (int, []string) {
func upsertWeixinPublicAccountHotspots(domainKey, keyword string, items []weixinPublicAccountFetchedHotspot) (int, []string) {
logs := []string{}
if len(items) == 0 {
return 0, logs
}
cfg := getOfficialAccountBusinessDomainConfig(domainKey)
cfg := getWeixinPublicAccountBusinessDomainConfig(domainKey)
now := time.Now()
upserts := make([]officialaccountmodel.OfficialAccountHotspot, 0, len(items))
upserts := make([]weixinpublicaccountmodel.WeixinPublicAccountHotspot, 0, len(items))
for _, item := range items {
rawScore, domainScore, tags := scoreOfficialAccountHotspot(domainKey, keyword, item.Title, item.Summary)
rawScore, domainScore, tags := scoreWeixinPublicAccountHotspot(domainKey, keyword, item.Title, item.Summary)
if domainScore <= 0 {
continue
}
tagsJSON, _ := json.Marshal(tags)
upserts = append(upserts, officialaccountmodel.OfficialAccountHotspot{
upserts = append(upserts, weixinpublicaccountmodel.WeixinPublicAccountHotspot{
BusinessDomain: domainKey,
SourceKey: item.SourceKey,
SourceLabel: item.SourceLabel,
@@ -276,7 +279,7 @@ func upsertOfficialAccountHotspots(domainKey, keyword string, items []officialAc
PublishedAt: item.PublishedAt,
RawScore: rawScore,
DomainScore: domainScore,
HeatLabel: officialAccountHeatLabel(domainScore),
HeatLabel: weixinPublicAccountHeatLabel(domainScore),
TagsJSON: string(tagsJSON),
FetchedAt: now,
})
@@ -292,8 +295,8 @@ func upsertOfficialAccountHotspots(domainKey, keyword string, items []officialAc
return len(upserts), logs
}
func scoreOfficialAccountHotspot(domainKey, keyword, title, summary string) (float64, float64, []string) {
cfg := getOfficialAccountBusinessDomainConfig(domainKey)
func scoreWeixinPublicAccountHotspot(domainKey, keyword, title, summary string) (float64, float64, []string) {
cfg := getWeixinPublicAccountBusinessDomainConfig(domainKey)
text := strings.ToLower(title + "\n" + summary)
rawScore := 0.0
domainScore := 0.0
@@ -318,21 +321,21 @@ func scoreOfficialAccountHotspot(domainKey, keyword, title, summary string) (flo
tags = append(tags, item)
}
}
for _, item := range buildOfficialAccountKeywordTerms(keyword) {
for _, item := range buildWeixinPublicAccountKeywordTerms(keyword) {
if strings.Contains(text, strings.ToLower(item)) {
domainScore += 2.5
tags = append(tags, item)
}
}
if domainKey == officialAccountBusinessDomainGeneric {
if domainKey == weixinPublicAccountBusinessDomainGeneric {
domainScore += 2
}
return rawScore, domainScore, uniqueOfficialAccountStrings(tags)
return rawScore, domainScore, uniqueWeixinPublicAccountStrings(tags)
}
func dedupeOfficialAccountFetched(items []officialAccountFetchedHotspot) []officialAccountFetchedHotspot {
func dedupeWeixinPublicAccountFetched(items []weixinPublicAccountFetchedHotspot) []weixinPublicAccountFetchedHotspot {
seen := map[string]struct{}{}
result := make([]officialAccountFetchedHotspot, 0, len(items))
result := make([]weixinPublicAccountFetchedHotspot, 0, len(items))
for _, item := range items {
key := strings.TrimSpace(item.URL)
if key == "" {
@@ -347,14 +350,14 @@ func dedupeOfficialAccountFetched(items []officialAccountFetchedHotspot) []offic
return result
}
func normalizeOfficialAccountText(value string) string {
func normalizeWeixinPublicAccountText(value string) string {
value = html.UnescapeString(value)
value = officialAccountStripTagRE.ReplaceAllString(value, " ")
value = officialAccountSpaceRE.ReplaceAllString(value, " ")
value = weixinPublicAccountStripTagRE.ReplaceAllString(value, " ")
value = weixinPublicAccountSpaceRE.ReplaceAllString(value, " ")
return strings.TrimSpace(value)
}
func parseOfficialAccountTime(value string) *time.Time {
func parseWeixinPublicAccountTime(value string) *time.Time {
value = strings.TrimSpace(value)
if value == "" {
return nil
@@ -374,7 +377,7 @@ func parseOfficialAccountTime(value string) *time.Time {
return nil
}
func containsOfficialAccountKeywords(text string, keywords []string) bool {
func containsWeixinPublicAccountKeywords(text string, keywords []string) bool {
text = strings.ToLower(text)
for _, keyword := range keywords {
if strings.Contains(text, strings.ToLower(keyword)) {
@@ -384,13 +387,13 @@ func containsOfficialAccountKeywords(text string, keywords []string) bool {
return false
}
func filterOfficialAccountHotspotsByKeyword(items []officialaccountmodel.OfficialAccountHotspot, keyword string) []officialaccountmodel.OfficialAccountHotspot {
terms := buildOfficialAccountKeywordTerms(keyword)
func filterWeixinPublicAccountHotspotsByKeyword(items []weixinpublicaccountmodel.WeixinPublicAccountHotspot, keyword string) []weixinpublicaccountmodel.WeixinPublicAccountHotspot {
terms := buildWeixinPublicAccountKeywordTerms(keyword)
if len(terms) == 0 {
return items
}
type scoredHotspot struct {
item officialaccountmodel.OfficialAccountHotspot
item weixinpublicaccountmodel.WeixinPublicAccountHotspot
score int
}
scored := make([]scoredHotspot, 0, len(items))
@@ -415,7 +418,7 @@ func filterOfficialAccountHotspotsByKeyword(items []officialaccountmodel.Officia
}
return scored[i].score > scored[j].score
})
result := make([]officialaccountmodel.OfficialAccountHotspot, 0, len(scored))
result := make([]weixinpublicaccountmodel.WeixinPublicAccountHotspot, 0, len(scored))
for _, item := range scored {
result = append(result, item.item)
if len(result) >= 20 {
@@ -425,11 +428,12 @@ func filterOfficialAccountHotspotsByKeyword(items []officialaccountmodel.Officia
return result
}
func buildOfficialAccountKeywordTerms(keyword string) []string {
func buildWeixinPublicAccountKeywordTerms(keyword string) []string {
raw := strings.TrimSpace(strings.ToLower(keyword))
if raw == "" {
return nil
}
raw = strings.TrimSuffix(raw, "的")
splitter := func(r rune) bool {
switch r {
case ' ', '\t', '\n', '\r', ',', ',', '、', '/', '|', ';', ';', ':', ':', '(', ')', '(', ')', '+', '-', '_':
@@ -445,30 +449,31 @@ func buildOfficialAccountKeywordTerms(keyword string) []string {
}
for _, part := range parts {
part = strings.TrimSpace(part)
part = strings.TrimSuffix(part, "的")
if utf8RuneCount(part) < 2 {
continue
}
terms = append(terms, part)
}
return uniqueOfficialAccountStrings(terms)
return uniqueWeixinPublicAccountStrings(terms)
}
func buildOfficialAccountSourceKeywords(domainKey, keyword string, extras []string) []string {
cfg := getOfficialAccountBusinessDomainConfig(domainKey)
func buildWeixinPublicAccountSourceKeywords(domainKey, keyword string, extras []string) []string {
cfg := getWeixinPublicAccountBusinessDomainConfig(domainKey)
keywords := append([]string{}, cfg.DomainKeywords...)
keywords = append(keywords, buildOfficialAccountKeywordTerms(keyword)...)
keywords = append(keywords, buildWeixinPublicAccountKeywordTerms(keyword)...)
keywords = append(keywords, extras...)
if domainKey == officialAccountBusinessDomainGeneric && len(buildOfficialAccountKeywordTerms(keyword)) == 0 {
if domainKey == weixinPublicAccountBusinessDomainGeneric && len(buildWeixinPublicAccountKeywordTerms(keyword)) == 0 {
keywords = append(keywords, cfg.FallbackSearchHints...)
}
return uniqueOfficialAccountStrings(keywords)
return uniqueWeixinPublicAccountStrings(keywords)
}
func utf8RuneCount(value string) int {
return len([]rune(value))
}
func officialAccountHeatLabel(score float64) string {
func weixinPublicAccountHeatLabel(score float64) string {
switch {
case score >= 12:
return "爆热"
@@ -481,7 +486,7 @@ func officialAccountHeatLabel(score float64) string {
}
}
func uniqueOfficialAccountStrings(items []string) []string {
func uniqueWeixinPublicAccountStrings(items []string) []string {
if len(items) == 0 {
return nil
}
@@ -1,9 +1,19 @@
package wechatofficialaccount
package weixinpublicaccount
import specialistcontracts "eai_agentplatform/backend/internal/specialists/contracts"
// 目录文案:专员卡片、市场列表、专员提示词(api/specialist_prompt.go)读的都是这几条,
// 所以只能有一份。参考 docs/02_Architecture/AR13_公众号创作专员双层多智能体架构定义.md §5:
// 一级工作流固定 8 个阶段 —— 选题 / 标题 / 提纲 / 正文 / 配图提示词 / 图片生成 / 预览与导出 / 分页 MD。
const (
CatalogSummary = "热点选题、标题、提纲、正文、配图到分页 MD,八步创作工作流"
CatalogWorkStatus = "8 个节点可执行"
CatalogBaseSkills = "推荐热点选题、标题生成、提纲生成、正文创作、配图提示词、图片生成、预览与导出、分页 MD"
CatalogGeneratedSkills = "公众号选题建议、标题建议、文章提纲、正文草稿、配图提示词、图片资源、预览稿、分页 MD"
)
var Manifest = specialistcontracts.Manifest{
Key: "wechat-official-account",
Key: "weixin-public-account",
Label: "公众号创作专员",
RuleFileMarkdown: `# 公众号创作专员
@@ -25,7 +35,7 @@ var Manifest = specialistcontracts.Manifest{
- 不编造案例、数据、用户评价。
- 涉及公司政策、产品承诺的表述,只转述已有材料里的说法。`,
AllowedSkills: []string{"longform-writing", "copy-proofreading", "mind-map"},
OwnedDefinitionKeys: []string{"specialist.wechat-official-account.definition"},
OwnedCatalogEntries: []string{"catalog.specialist.wechat-official-account"},
OwnedDefinitionKeys: []string{"specialist.weixin-public-account.definition"},
OwnedCatalogEntries: []string{"catalog.specialist.weixin-public-account"},
ReferencePolicy: specialistcontracts.SharedReferenceReadonlyHistory,
}
@@ -1,9 +1,9 @@
package wechatofficialaccountmodel
package weixinpublicaccountmodel
import "time"
// OfficialAccountArticle 公众号文章状态表,对齐工作流中的主题/标题/提纲/正文/配图/预览/分页显式传递。
type OfficialAccountArticle struct {
// WeixinPublicAccountArticle 公众号文章状态表,对齐工作流中的主题/标题/提纲/正文/配图/预览/分页显式传递。
type WeixinPublicAccountArticle struct {
ID uint `gorm:"primaryKey" json:"id"`
TaskID uint `gorm:"not null;uniqueIndex" json:"task_id"`
SpecialistKey string `gorm:"size:64;not null;index" json:"specialist_key"`
@@ -37,4 +37,4 @@ type OfficialAccountArticle struct {
UpdatedAt time.Time `json:"updated_at"`
}
func (OfficialAccountArticle) TableName() string { return "official_account_article" }
func (WeixinPublicAccountArticle) TableName() string { return "weixin_public_account_article" }
@@ -1,9 +1,9 @@
package wechatofficialaccountmodel
package weixinpublicaccountmodel
import "time"
// OfficialAccountHotspot 业务域热点缓存表,承接 RSS/网页抓取结果及领域评分。
type OfficialAccountHotspot struct {
// WeixinPublicAccountHotspot 业务域热点缓存表,承接 RSS/网页抓取结果及领域评分。
type WeixinPublicAccountHotspot struct {
ID uint `gorm:"primaryKey" json:"id"`
BusinessDomain string `gorm:"size:32;not null;default:ai;index" json:"business_domain"`
SourceKey string `gorm:"size:64;not null;index;uniqueIndex:idx_hotspot_source_url" json:"source_key"`
@@ -22,4 +22,4 @@ type OfficialAccountHotspot struct {
UpdatedAt time.Time `json:"updated_at"`
}
func (OfficialAccountHotspot) TableName() string { return "official_account_hotspot" }
func (WeixinPublicAccountHotspot) TableName() string { return "weixin_public_account_hotspot" }
@@ -1,6 +1,8 @@
package specialistseeding
import (
"encoding/json"
"errors"
"log"
"strings"
@@ -8,6 +10,7 @@ import (
specialistcore "eai_agentplatform/backend/internal/specialists/core"
specialistmodel "eai_agentplatform/backend/internal/specialists/model"
weixinpublicaccount "eai_agentplatform/backend/internal/specialists/packages/weixin_public_account"
specialistruntime "eai_agentplatform/backend/internal/specialists/runtime"
"eai_agentplatform/backend/internal/store"
)
@@ -39,13 +42,13 @@ func SeedSpecialists(db *gorm.DB) error {
SortOrder: 0,
},
{
Key: "wechat-official-account",
Key: "weixin-public-account",
Label: "公众号创作专员",
Tier: "generic",
SpecialistMode: "dw",
ObjectEntryRoute: "/apps/wechat-official-account",
Summary: "热点选题、标题、提纲、正文四步创作工作流",
WorkStatus: "4 个节点可执行",
ObjectEntryRoute: "/apps/weixin-public-account",
Summary: weixinpublicaccount.CatalogSummary,
WorkStatus: weixinpublicaccount.CatalogWorkStatus,
RiskLabel: "0 个异常",
Color: "#2563eb",
Stage: "正文创作",
@@ -56,9 +59,9 @@ func SeedSpecialists(db *gorm.DB) error {
PermissionScope: "文章任务创建、步骤执行、草稿保存、人工确认",
ResourceBindings: "公众号工作流、知识库、事项运行记录、文章草稿",
InfoSources: "选题关键词、目标受众、文章目标、风格要求",
BaseSkills: "推荐热点选题、标题生成、提纲生成、正文创作",
BaseSkills: weixinpublicaccount.CatalogBaseSkills,
AIAssistance: "按步骤生成中间结果,并展示进度与可追溯输出",
GeneratedSkills: "公众号选题建议、标题建议、文章提纲、正文草稿",
GeneratedSkills: weixinpublicaccount.CatalogGeneratedSkills,
State: "active",
SortOrder: 45,
},
@@ -231,11 +234,115 @@ func SeedSpecialists(db *gorm.DB) error {
}
}
if err := correctWeixinPublicAccountWorkflowCopy(db); err != nil {
return err
}
specialistcore.LogRuleFileCoverage(db)
log.Println("[OK] 专员目录种子已导入")
return nil
}
// 早期版本把公众号创作专员的一级工作流文案写成「四步」,这里给出那一版的原文,
// 只用于按值锚定旧数据 —— 改文案时不要跟着改。
const (
staleWeixinPublicAccountSummary = "热点选题、标题、提纲、正文四步创作工作流"
staleWeixinPublicAccountWorkStatus = "4 个节点可执行"
staleWeixinPublicAccountBaseSkills = "推荐热点选题、标题生成、提纲生成、正文创作"
staleWeixinPublicAccountGeneratedSkills = "公众号选题建议、标题建议、文章提纲、正文草稿"
)
// correctWeixinPublicAccountWorkflowCopy 纠正已经装出去的库里那份写错的四级工作流文案。
//
// 公众号创作专员的一级工作流是 8 个阶段(AR13 §5):选题 / 标题 / 提纲 / 正文 /
// 配图提示词 / 图片生成 / 预览与导出 / 分页 MD。早期文案把它写成「四步」,
// 既漏了后半程三个阶段,又把步数写错。这段文案会同时进专员卡片和专员提示词
// (见 api/specialist_prompt.go),所以光改种子不够 —— 种子的补齐逻辑只填空字段,
// 旧值一旦落库就再也不会被覆盖,必须在这里按值纠正一次。
//
// 只在值等于旧文案时才改写:管理员在后台改过的文案一律不动,重复执行无副作用。
func correctWeixinPublicAccountWorkflowCopy(db *gorm.DB) error {
var item specialistmodel.Specialist
err := db.Where("key = ?", weixinpublicaccount.Manifest.Key).First(&item).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil
}
if err != nil {
return err
}
updates := map[string]any{}
if item.Summary == staleWeixinPublicAccountSummary {
updates["summary"] = weixinpublicaccount.CatalogSummary
}
if item.WorkStatus == staleWeixinPublicAccountWorkStatus {
updates["work_status"] = weixinpublicaccount.CatalogWorkStatus
}
if item.BaseSkills == staleWeixinPublicAccountBaseSkills {
updates["base_skills"] = weixinpublicaccount.CatalogBaseSkills
}
if item.GeneratedSkills == staleWeixinPublicAccountGeneratedSkills {
updates["generated_skills"] = weixinpublicaccount.CatalogGeneratedSkills
}
if card, corrected := correctInteractionCardTagline(item.InteractionCardJSON); corrected {
updates["interaction_card_json"] = card
}
if len(updates) == 0 {
return nil
}
// 动作记录与结果记录是从 base_skills / generated_skills 派生的(见 runtime.EnsureStructuredRecords),
// 技能清单变了就必须清空重算,否则专员详情页和对话右栏的流程还停在四个动作。
rebuildActionRecords := false
if _, changed := updates["base_skills"]; changed {
updates["action_records_json"] = ""
rebuildActionRecords = true
}
if _, changed := updates["generated_skills"]; changed {
updates["result_records_json"] = ""
rebuildActionRecords = true
}
if err := db.Model(&item).Updates(updates).Error; err != nil {
return err
}
log.Printf("[OK] 公众号创作专员旧工作流文案已纠正(summary/work_status/skills/tagline)")
if !rebuildActionRecords {
return nil
}
if err := db.Where("key = ?", weixinpublicaccount.Manifest.Key).First(&item).Error; err != nil {
return err
}
specialistruntime.EnsureStructuredRecords(&item)
return db.Model(&item).Updates(map[string]any{
"action_records_json": item.ActionRecordsJSON,
"result_records_json": item.ResultRecordsJSON,
}).Error
}
// correctInteractionCardTagline 只替换交互卡里的 tagline:整串 JSON 重新解析再写回,
// 不做子串替换 —— 同一份 JSON 里 starter_prompts 等字段也可能含相近文案。
func correctInteractionCardTagline(raw string) (string, bool) {
if strings.TrimSpace(raw) == "" {
return raw, false
}
card := map[string]any{}
if err := json.Unmarshal([]byte(raw), &card); err != nil {
return raw, false
}
tagline, _ := card["tagline"].(string)
if tagline != staleWeixinPublicAccountSummary {
return raw, false
}
card["tagline"] = weixinpublicaccount.CatalogSummary
encoded, err := json.Marshal(card)
if err != nil {
return raw, false
}
return string(encoded), true
}
func applySpecialistCodes(items []specialistmodel.Specialist) []specialistmodel.Specialist {
visibleIndex := 1
for i := range items {
@@ -0,0 +1,228 @@
package specialistseeding
import (
"encoding/json"
"path/filepath"
"strings"
"testing"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
specialistmodel "eai_agentplatform/backend/internal/specialists/model"
weixinpublicaccount "eai_agentplatform/backend/internal/specialists/packages/weixin_public_account"
)
func openSeedingTestDB(t *testing.T) *gorm.DB {
t.Helper()
dbPath := filepath.Join(t.TempDir(), "seeding.db")
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
t.Fatalf("打开测试库失败: %v", err)
}
if err := db.AutoMigrate(&specialistmodel.Specialist{}); err != nil {
t.Fatalf("迁移失败: %v", err)
}
return db
}
// staleWeixinPublicAccountRow 造一条「旧版四步文案」的专员记录,
// 五个载体全部取旧值,外加一份与旧 base_skills 对不上的派生记录。
func staleWeixinPublicAccountRow(t *testing.T, db *gorm.DB) specialistmodel.Specialist {
t.Helper()
card := map[string]any{
"name": "公众号创作专员",
"tagline": staleWeixinPublicAccountSummary,
"greeting": "我是公众号创作专员,已经准备好接手这条任务并继续推进。",
"starter_prompts": []string{"帮我处理:推荐热点选题", "帮我处理:标题生成"},
}
cardJSON, err := json.Marshal(card)
if err != nil {
t.Fatalf("序列化交互卡失败: %v", err)
}
item := specialistmodel.Specialist{
Key: weixinpublicaccount.Manifest.Key,
Label: "公众号创作专员",
Tier: "generic",
SpecialistMode: "dw",
ObjectEntryRoute: "/apps/weixin-public-account",
Summary: staleWeixinPublicAccountSummary,
WorkStatus: staleWeixinPublicAccountWorkStatus,
BaseSkills: staleWeixinPublicAccountBaseSkills,
GeneratedSkills: staleWeixinPublicAccountGeneratedSkills,
MarketTag: "已安装",
State: "active",
InteractionCardJSON: string(cardJSON),
// 派生记录:四步版本产生的四个动作、四个产物。
ActionRecordsJSON: `[{"action_name":"推荐热点选题"},{"action_name":"标题生成"},{"action_name":"提纲生成"},{"action_name":"正文创作"}]`,
ResultRecordsJSON: `[{"result_title":"公众号选题建议"},{"result_title":"标题建议"},{"result_title":"文章提纲"},{"result_title":"正文草稿"}]`,
}
if err := db.Create(&item).Error; err != nil {
t.Fatalf("建专员失败: %v", err)
}
return item
}
func reloadWeixinPublicAccount(t *testing.T, db *gorm.DB) specialistmodel.Specialist {
t.Helper()
var item specialistmodel.Specialist
if err := db.Where("key = ?", weixinpublicaccount.Manifest.Key).First(&item).Error; err != nil {
t.Fatalf("读专员失败: %v", err)
}
return item
}
func taglineOf(t *testing.T, interactionCardJSON string) string {
t.Helper()
card := map[string]any{}
if err := json.Unmarshal([]byte(interactionCardJSON), &card); err != nil {
t.Fatalf("解析交互卡失败: %v", err)
}
tagline, _ := card["tagline"].(string)
return tagline
}
// TestCorrectWeixinPublicAccountWorkflowCopy 五个载体(summary / work_status /
// base_skills / generated_skills / 交互卡 tagline)全部从四步改成八步,
// 并且两份派生记录按新技能清单重建。
func TestCorrectWeixinPublicAccountWorkflowCopy(t *testing.T) {
db := openSeedingTestDB(t)
staleWeixinPublicAccountRow(t, db)
if err := correctWeixinPublicAccountWorkflowCopy(db); err != nil {
t.Fatalf("纠正失败: %v", err)
}
got := reloadWeixinPublicAccount(t, db)
if got.Summary != weixinpublicaccount.CatalogSummary {
t.Errorf("summary 未纠正: %q", got.Summary)
}
if got.WorkStatus != weixinpublicaccount.CatalogWorkStatus {
t.Errorf("work_status 未纠正: %q", got.WorkStatus)
}
if got.BaseSkills != weixinpublicaccount.CatalogBaseSkills {
t.Errorf("base_skills 未纠正: %q", got.BaseSkills)
}
if got.GeneratedSkills != weixinpublicaccount.CatalogGeneratedSkills {
t.Errorf("generated_skills 未纠正: %q", got.GeneratedSkills)
}
if tagline := taglineOf(t, got.InteractionCardJSON); tagline != weixinpublicaccount.CatalogSummary {
t.Errorf("交互卡 tagline 未纠正: %q", tagline)
}
// 交互卡其余字段必须原样保留 —— 整串重写只该动 tagline。
card := map[string]any{}
if err := json.Unmarshal([]byte(got.InteractionCardJSON), &card); err != nil {
t.Fatalf("解析交互卡失败: %v", err)
}
if card["greeting"] != "我是公众号创作专员,已经准备好接手这条任务并继续推进。" {
t.Errorf("greeting 被改动了: %v", card["greeting"])
}
if prompts, ok := card["starter_prompts"].([]any); !ok || len(prompts) != 2 {
t.Errorf("starter_prompts 被改动了: %v", card["starter_prompts"])
}
// 派生记录:动作数应等于技能条数,且补上了后半程阶段。
var actions []map[string]any
if err := json.Unmarshal([]byte(got.ActionRecordsJSON), &actions); err != nil {
t.Fatalf("解析动作记录失败: %v", err)
}
if len(actions) != 8 {
t.Errorf("动作记录条数 = %d,期望 8(每条 base_skill 一个动作)", len(actions))
}
if !strings.Contains(got.ActionRecordsJSON, "分页 MD") {
t.Errorf("动作记录未补上分页 MD: %s", got.ActionRecordsJSON)
}
if strings.Contains(got.ActionRecordsJSON, `"action_name":"正文创作"}])`) {
t.Errorf("动作记录仍是旧的四条骨架: %s", got.ActionRecordsJSON)
}
var results []map[string]any
if err := json.Unmarshal([]byte(got.ResultRecordsJSON), &results); err != nil {
t.Fatalf("解析结果记录失败: %v", err)
}
if len(results) != 8 {
t.Errorf("结果记录条数 = %d,期望 8(每条 generated_skill 一个产物)", len(results))
}
}
// TestCorrectWeixinPublicAccountWorkflowCopyKeepsAdminEdits 管理员改过的字段不动:
// 纠正只认旧文案本身,不是无条件覆盖。
func TestCorrectWeixinPublicAccountWorkflowCopyKeepsAdminEdits(t *testing.T) {
db := openSeedingTestDB(t)
staleWeixinPublicAccountRow(t, db)
const adminSummary = "我自己改过的说明"
if err := db.Model(&specialistmodel.Specialist{}).
Where("key = ?", weixinpublicaccount.Manifest.Key).
Update("summary", adminSummary).Error; err != nil {
t.Fatalf("模拟管理员改动失败: %v", err)
}
if err := correctWeixinPublicAccountWorkflowCopy(db); err != nil {
t.Fatalf("纠正失败: %v", err)
}
got := reloadWeixinPublicAccount(t, db)
if got.Summary != adminSummary {
t.Errorf("管理员改过的 summary 被覆盖: %q", got.Summary)
}
// 其余仍是旧值的载体照常纠正,说明是按字段锚定而不是整行跳过。
if got.WorkStatus != weixinpublicaccount.CatalogWorkStatus {
t.Errorf("work_status 未纠正: %q", got.WorkStatus)
}
}
// TestCorrectWeixinPublicAccountWorkflowCopyIsIdempotent 重复执行无副作用:
// 第二次跑不再产生更新,派生记录也不会被反复清空重建。
func TestCorrectWeixinPublicAccountWorkflowCopyIsIdempotent(t *testing.T) {
db := openSeedingTestDB(t)
staleWeixinPublicAccountRow(t, db)
if err := correctWeixinPublicAccountWorkflowCopy(db); err != nil {
t.Fatalf("第一次纠正失败: %v", err)
}
first := reloadWeixinPublicAccount(t, db)
if err := correctWeixinPublicAccountWorkflowCopy(db); err != nil {
t.Fatalf("第二次纠正失败: %v", err)
}
second := reloadWeixinPublicAccount(t, db)
if first.ActionRecordsJSON != second.ActionRecordsJSON {
t.Errorf("动作记录被二次改写:\n第一次 %s\n第二次 %s", first.ActionRecordsJSON, second.ActionRecordsJSON)
}
if first.ResultRecordsJSON != second.ResultRecordsJSON {
t.Errorf("结果记录被二次改写:\n第一次 %s\n第二次 %s", first.ResultRecordsJSON, second.ResultRecordsJSON)
}
if first.InteractionCardJSON != second.InteractionCardJSON {
t.Errorf("交互卡被二次改写:\n第一次 %s\n第二次 %s", first.InteractionCardJSON, second.InteractionCardJSON)
}
}
// TestCorrectWeixinPublicAccountWorkflowCopyMissingRow 库里没有这个专员时直接放行,
// 新库不该因为这条纠正而报错。
func TestCorrectWeixinPublicAccountWorkflowCopyMissingRow(t *testing.T) {
db := openSeedingTestDB(t)
if err := correctWeixinPublicAccountWorkflowCopy(db); err != nil {
t.Fatalf("空库纠正报错: %v", err)
}
}
// TestCorrectInteractionCardTaglineIgnoresUnrelatedJSON 交互卡不是 JSON、
// 或 tagline 已是新文案时,原样返回。
func TestCorrectInteractionCardTaglineIgnoresUnrelatedJSON(t *testing.T) {
for _, raw := range []string{"", "not json", `{"tagline":"用户自己写的标语"}`} {
got, corrected := correctInteractionCardTagline(raw)
if corrected {
t.Errorf("不该纠正: %q", raw)
}
if got != raw {
t.Errorf("原文被改动: %q → %q", raw, got)
}
}
}
@@ -11,7 +11,7 @@ import (
"eai_agentplatform/backend/internal/model"
skillmodel "eai_agentplatform/backend/internal/skills/model"
officialaccountmodel "eai_agentplatform/backend/internal/specialists/packages/wechat_official_account/model"
weixinpublicaccountmodel "eai_agentplatform/backend/internal/specialists/packages/weixin_public_account/model"
specialistmodel "eai_agentplatform/backend/internal/specialists/model"
)
@@ -45,6 +45,9 @@ func Init(dbPath string) (*gorm.DB, error) {
if err := migrateObjectModeColumns(db); err != nil {
return nil, err
}
if err := purgeLegacyWeixinPublicAccountData(db); err != nil {
return nil, err
}
if err := db.AutoMigrate(
&model.User{},
@@ -60,6 +63,8 @@ func Init(dbPath string) (*gorm.DB, error) {
&model.SystemConfig{},
&model.KnowledgeSource{},
&model.AiCallLog{},
&model.ChatConversation{},
&model.ChatMessage{},
&model.LearningProgress{},
&model.Position{},
&model.PositionKnowledge{},
@@ -78,8 +83,8 @@ func Init(dbPath string) (*gorm.DB, error) {
&model.TaskRun{},
&model.UserXAppCenter{},
&model.XAppDefinition{},
&officialaccountmodel.OfficialAccountArticle{},
&officialaccountmodel.OfficialAccountHotspot{},
&weixinpublicaccountmodel.WeixinPublicAccountArticle{},
&weixinpublicaccountmodel.WeixinPublicAccountHotspot{},
); err != nil {
return nil, err
}
@@ -288,6 +293,74 @@ func normalizeSkillObjectKinds(db *gorm.DB) error {
`).Error
}
func purgeLegacyWeixinPublicAccountData(db *gorm.DB) error {
const legacySpecialistKey = "wechat-official-account"
changed := false
if db.Migrator().HasTable("task_artifact") {
if result := db.Exec(`DELETE FROM task_artifact WHERE specialist_key = ?`, legacySpecialistKey); result.Error != nil {
return result.Error
} else if result.RowsAffected > 0 {
changed = true
}
}
if db.Migrator().HasTable("task_run") {
if result := db.Exec(`DELETE FROM task_run WHERE specialist_key = ?`, legacySpecialistKey); result.Error != nil {
return result.Error
} else if result.RowsAffected > 0 {
changed = true
}
}
if db.Migrator().HasTable("task_record") {
if result := db.Exec(`DELETE FROM task_record WHERE specialist_key = ?`, legacySpecialistKey); result.Error != nil {
return result.Error
} else if result.RowsAffected > 0 {
changed = true
}
}
if db.Migrator().HasTable("weixin_public_account_article") {
if result := db.Exec(`DELETE FROM weixin_public_account_article WHERE specialist_key = ?`, legacySpecialistKey); result.Error != nil {
return result.Error
} else if result.RowsAffected > 0 {
changed = true
}
}
if db.Migrator().HasTable("specialist") {
if result := db.Exec(`DELETE FROM specialist WHERE key = ?`, legacySpecialistKey); result.Error != nil {
return result.Error
} else if result.RowsAffected > 0 {
changed = true
}
}
if db.Migrator().HasTable("official_account_article") {
if err := db.Migrator().DropTable("official_account_article"); err != nil {
return err
}
changed = true
}
if db.Migrator().HasTable("official_account_hotspot") {
if err := db.Migrator().DropTable("official_account_hotspot"); err != nil {
return err
}
changed = true
}
if changed {
if err := db.Exec(`VACUUM`).Error; err != nil {
return err
}
}
return nil
}
func migrateColumnData(db *gorm.DB, tableName, targetColumn, legacyColumn string) error {
if !db.Migrator().HasColumn(tableName, legacyColumn) {
return nil
@@ -9,6 +9,7 @@ import (
"eai_agentplatform/backend/internal/model"
skillmodel "eai_agentplatform/backend/internal/skills/model"
weixinpublicaccountmodel "eai_agentplatform/backend/internal/specialists/packages/weixin_public_account/model"
specialistmodel "eai_agentplatform/backend/internal/specialists/model"
)
@@ -258,6 +259,198 @@ func TestMigrateAIUsageKindColumnDropsLegacyColumn(t *testing.T) {
}
}
func TestPurgeLegacyWeixinPublicAccountDataRemovesOldRowsAndTables(t *testing.T) {
db := openMigrationTestDB(t)
if err := db.AutoMigrate(
&specialistmodel.Specialist{},
&model.TaskRecord{},
&model.TaskArtifact{},
&model.TaskRun{},
&weixinpublicaccountmodel.WeixinPublicAccountArticle{},
); err != nil {
t.Fatalf("auto migrate: %v", err)
}
if err := db.Exec(`
CREATE TABLE official_account_article (
id INTEGER PRIMARY KEY AUTOINCREMENT,
specialist_key TEXT NOT NULL
)
`).Error; err != nil {
t.Fatalf("create official_account_article: %v", err)
}
if err := db.Exec(`
CREATE TABLE official_account_hotspot (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source_key TEXT NOT NULL
)
`).Error; err != nil {
t.Fatalf("create official_account_hotspot: %v", err)
}
legacySpecialist := specialistmodel.Specialist{
Key: "wechat-official-account",
Label: "公众号助手",
Tier: "generic",
SpecialistMode: "dw",
ObjectEntryRoute: "/apps/weixin-public-account",
State: "active",
MarketTag: "installed",
}
if err := db.Create(&legacySpecialist).Error; err != nil {
t.Fatalf("create legacy specialist: %v", err)
}
currentSpecialist := specialistmodel.Specialist{
Key: "weixin-public-account",
Label: "公众号创作专员",
Tier: "generic",
SpecialistMode: "dw",
ObjectEntryRoute: "/apps/weixin-public-account",
State: "active",
MarketTag: "installed",
}
if err := db.Create(&currentSpecialist).Error; err != nil {
t.Fatalf("create current specialist: %v", err)
}
legacyTask := model.TaskRecord{
SpecialistKey: "wechat-official-account",
Title: "旧公众号任务",
Priority: "P2",
Status: "待处理",
}
if err := db.Create(&legacyTask).Error; err != nil {
t.Fatalf("create legacy task: %v", err)
}
keepTask := model.TaskRecord{
SpecialistKey: "weixin-public-account",
Title: "新公众号任务",
Priority: "P2",
Status: "待处理",
}
if err := db.Create(&keepTask).Error; err != nil {
t.Fatalf("create current task: %v", err)
}
legacyArtifact := model.TaskArtifact{
TaskID: legacyTask.ID,
SpecialistKey: "wechat-official-account",
Title: "旧产物",
}
if err := db.Create(&legacyArtifact).Error; err != nil {
t.Fatalf("create legacy artifact: %v", err)
}
keepArtifact := model.TaskArtifact{
TaskID: keepTask.ID,
SpecialistKey: "weixin-public-account",
Title: "新产物",
}
if err := db.Create(&keepArtifact).Error; err != nil {
t.Fatalf("create current artifact: %v", err)
}
legacyRun := model.TaskRun{
TaskID: legacyTask.ID,
SpecialistKey: "wechat-official-account",
ActionKey: "topic",
ActionTitle: "旧运行",
}
if err := db.Create(&legacyRun).Error; err != nil {
t.Fatalf("create legacy run: %v", err)
}
keepRun := model.TaskRun{
TaskID: keepTask.ID,
SpecialistKey: "weixin-public-account",
ActionKey: "topic",
ActionTitle: "新运行",
}
if err := db.Create(&keepRun).Error; err != nil {
t.Fatalf("create current run: %v", err)
}
legacyArticle := weixinpublicaccountmodel.WeixinPublicAccountArticle{
TaskID: legacyTask.ID,
SpecialistKey: "wechat-official-account",
Keyword: "外骨骼机器人",
Audience: "合作方",
Goal: "推广",
Tone: "专业",
}
if err := db.Create(&legacyArticle).Error; err != nil {
t.Fatalf("create legacy article: %v", err)
}
keepArticle := weixinpublicaccountmodel.WeixinPublicAccountArticle{
TaskID: keepTask.ID,
SpecialistKey: "weixin-public-account",
Keyword: "外骨骼机器人",
Audience: "合作方",
Goal: "推广",
Tone: "专业",
}
if err := db.Create(&keepArticle).Error; err != nil {
t.Fatalf("create current article: %v", err)
}
if err := purgeLegacyWeixinPublicAccountData(db); err != nil {
t.Fatalf("purge legacy weixin public account data: %v", err)
}
var count int64
if err := db.Model(&specialistmodel.Specialist{}).Where("key = ?", "wechat-official-account").Count(&count).Error; err != nil {
t.Fatalf("count legacy specialist: %v", err)
}
if count != 0 {
t.Fatalf("legacy specialist should be removed, count=%d", count)
}
if err := db.Model(&specialistmodel.Specialist{}).Where("key = ?", "weixin-public-account").Count(&count).Error; err != nil {
t.Fatalf("count current specialist: %v", err)
}
if count != 1 {
t.Fatalf("current specialist should remain, count=%d", count)
}
if err := db.Model(&model.TaskRecord{}).Where("specialist_key = ?", "wechat-official-account").Count(&count).Error; err != nil {
t.Fatalf("count legacy tasks: %v", err)
}
if count != 0 {
t.Fatalf("legacy tasks should be removed, count=%d", count)
}
if err := db.Model(&model.TaskRecord{}).Where("specialist_key = ?", "weixin-public-account").Count(&count).Error; err != nil {
t.Fatalf("count current tasks: %v", err)
}
if count != 1 {
t.Fatalf("current tasks should remain, count=%d", count)
}
if err := db.Model(&model.TaskArtifact{}).Where("specialist_key = ?", "wechat-official-account").Count(&count).Error; err != nil {
t.Fatalf("count legacy artifacts: %v", err)
}
if count != 0 {
t.Fatalf("legacy artifacts should be removed, count=%d", count)
}
if err := db.Model(&model.TaskRun{}).Where("specialist_key = ?", "wechat-official-account").Count(&count).Error; err != nil {
t.Fatalf("count legacy runs: %v", err)
}
if count != 0 {
t.Fatalf("legacy runs should be removed, count=%d", count)
}
if err := db.Model(&weixinpublicaccountmodel.WeixinPublicAccountArticle{}).Where("specialist_key = ?", "wechat-official-account").Count(&count).Error; err != nil {
t.Fatalf("count legacy articles: %v", err)
}
if count != 0 {
t.Fatalf("legacy articles should be removed, count=%d", count)
}
if db.Migrator().HasTable("official_account_article") {
t.Fatalf("official_account_article should be dropped")
}
if db.Migrator().HasTable("official_account_hotspot") {
t.Fatalf("official_account_hotspot should be dropped")
}
}
func TestNormalizeSkillObjectKindsMapsAssistantToSkill(t *testing.T) {
db := openMigrationTestDB(t)
if err := db.AutoMigrate(&skillmodel.SkillDefinition{}); err != nil {
@@ -2,36 +2,117 @@ package web
import "net/http"
// AppError 业务错误(对齐 Python core/errors.py 的错误码与状态码)
// 错误分类(category)—— 表达展示策略、跳转策略、重试策略、监控聚类,使用小写 snake_case。
const (
CatValidation = "validation" // 参数不合法 / 表单缺失 / 格式错误
CatAuth = "auth" // 认证失败:未登录、会话过期、令牌无效
CatPermission = "permission" // 权限不足
CatNotFound = "not_found" // 资源不存在
CatConflict = "conflict" // 状态冲突:重复创建、并发更新
CatRateLimit = "rate_limit" // 限流:请求过快
CatDependency = "dependency" // 外部依赖错误:第三方 API、数据库、LLM
CatProviderQuota = "provider_quota" // 第三方额度不足:AI 积分耗尽
CatServer = "server" // 系统内部异常:未捕获、代码 bug
)
// 稳定业务错误码 —— 分段:1000 通用 / 2000 认证 / 3000 权限 / 6000 AI / 9000 系统。
// 同一语义稳定复用,不允许同一错误码被不同语义复用。
const (
CodeOK = 0 // 成功(response.go OK 信封使用)
CodeValidation = 1000 // 通用参数错误
CodeNotFound = 1001 // 资源不存在
CodeConflict = 1002 // 状态冲突 / 重复创建
CodeRateLimit = 1003 // 限流
CodeUnauthorized = 2000 // 未认证 / 会话过期 / 令牌无效
CodeForbidden = 3000 // 权限不足
CodeLLMNotConfigured = 6001 // LLM 未配置
CodeLLMRequestFailed = 6002 // LLM 请求失败
CodePointsExhausted = 6003 // AI 积分耗尽
CodeInternal = 9000 // 系统内部错误(未捕获异常兜底)
)
// 各分类默认的下一步操作建议(hint):message 回答"发生了什么",hint 回答"下一步怎么做"。
const (
DefaultHintValidation = "请检查输入内容、必填项和字段格式后重试。"
DefaultHintAuth = "请重新登录后再试。"
DefaultHintPermission = "请确认当前账号是否拥有相应权限,或联系管理员授权。"
DefaultHintNotFound = "请确认资源标识是否正确,或刷新列表后再试。"
DefaultHintConflict = "请刷新页面确认当前状态后,再重试操作。"
DefaultHintRateLimit = "操作过于频繁,请稍后重试。"
DefaultHintLLMNotConfig = "请先在系统配置中启用并配置 AI 模型路由后重试。"
DefaultHintLLMRequest = "请稍后重试;如持续失败,请检查 AI 服务商状态与网络链路。"
DefaultHintPointsExhaust = "请检查账户积分余额,或联系管理员补充后重试。"
DefaultHintInternal = "请稍后重试;如持续失败,请将请求编号反馈给开发排查。"
)
// AppError 统一业务错误模型:HTTP 状态码 + 稳定业务错误码 + 分类 + 用户提示。
// 对齐项目错误治理规范,为"协议语义(状态码) / 稳定编号(code) / 行为分流(category)
// / 用户可理解(message+hint) / 开发可定位(request_id+details)"四层模型。
type AppError struct {
StatusCode int
Code string
Code int
Message string
Category string
Hint string
Details any
RequestID string
}
func (e *AppError) Error() string { return e.Message }
// NewBadRequest 参数错误(保持旧签名,向后兼容,内部自动携带稳定 code/category/hint)。
func NewBadRequest(msg string) *AppError {
return &AppError{http.StatusBadRequest, "bad_request", msg}
return &AppError{http.StatusBadRequest, CodeValidation, msg, CatValidation, DefaultHintValidation, nil, ""}
}
// NewAuthError 认证失败(未登录 / 会话过期 / 令牌无效)。
func NewAuthError(msg string) *AppError {
return &AppError{http.StatusUnauthorized, "unauthorized", msg}
return &AppError{http.StatusUnauthorized, CodeUnauthorized, msg, CatAuth, DefaultHintAuth, nil, ""}
}
// NewForbiddenError 权限不足。
func NewForbiddenError(msg string) *AppError {
return &AppError{http.StatusForbidden, "forbidden", msg}
return &AppError{http.StatusForbidden, CodeForbidden, msg, CatPermission, DefaultHintPermission, nil, ""}
}
// NewNotFoundError 资源不存在。
func NewNotFoundError(msg string) *AppError {
return &AppError{http.StatusNotFound, "not_found", msg}
return &AppError{http.StatusNotFound, CodeNotFound, msg, CatNotFound, DefaultHintNotFound, nil, ""}
}
// NewConflictError 状态冲突 / 重复创建。
func NewConflictError(msg string) *AppError {
return &AppError{http.StatusConflict, "conflict", msg}
return &AppError{http.StatusConflict, CodeConflict, msg, CatConflict, DefaultHintConflict, nil, ""}
}
// NewLLMNotConfigured LLM 路由未配置。
func NewLLMNotConfigured(msg string) *AppError {
return &AppError{http.StatusNotImplemented, "llm_not_configured", msg}
return &AppError{http.StatusNotImplemented, CodeLLMNotConfigured, msg, CatDependency, DefaultHintLLMNotConfig, nil, ""}
}
// NewLLMError LLM 请求失败(外部依赖)。
func NewLLMError(msg string) *AppError {
return &AppError{http.StatusBadGateway, "llm_request_failed", msg}
return &AppError{http.StatusBadGateway, CodeLLMRequestFailed, msg, CatDependency, DefaultHintLLMRequest, nil, ""}
}
// NewPointsExhausted AI 积分耗尽。
func NewPointsExhausted(msg string) *AppError {
return &AppError{http.StatusPaymentRequired, "ai_points_exhausted", msg}
return &AppError{http.StatusPaymentRequired, CodePointsExhausted, msg, CatProviderQuota, DefaultHintPointsExhaust, nil, ""}
}
// NewServerError 系统内部错误(未捕获异常兜底)。
func NewServerError(msg string, details any) *AppError {
return &AppError{http.StatusInternalServerError, CodeInternal, msg, CatServer, DefaultHintInternal, details, ""}
}
// WithHint 覆写提示语(链式),用于需要给出更具体下一步建议的场景。
func (e *AppError) WithHint(hint string) *AppError {
e.Hint = hint
return e
}
// WithDetails 附带结构化开发上下文(链式),禁止塞大段日志/堆栈。
func (e *AppError) WithDetails(details any) *AppError {
e.Details = details
return e
}
@@ -1,17 +1,78 @@
package web
import (
"log"
"net/http"
"github.com/gin-gonic/gin"
)
// OK 成功响应信封 {"data": ..., "error": null, "message": "success"}
func OK(c *gin.Context, data any) {
c.JSON(http.StatusOK, gin.H{"data": data, "error": nil, "message": "success"})
// CtxRequestID Gin Context 中存放 request_id 的键(由 middleware.RequestID 写入)。
const CtxRequestID = "req_id"
// RequestIDOf 从 Gin Context 读取本次请求的 request_id(无则返回空串)。
func RequestIDOf(c *gin.Context) string {
if v, ok := c.Get(CtxRequestID); ok {
if s, ok := v.(string); ok {
return s
}
}
return ""
}
// Fail 错误响应信封 {"data": null, "error": ..., "message": ...}
// OK 成功响应信封 {"code":0,"message":"success","data":...,"error":null}
func OK(c *gin.Context, data any) {
c.JSON(http.StatusOK, gin.H{
"code": CodeOK,
"message": "success",
"data": data,
"error": nil,
})
}
// Fail 错误响应信封,统一输出四层模型:
//
// {"code": 稳定业务错误码, "message": 用户主提示, "data": null,
// "error": {"category":分类, "status":HTTP状态码, "hint":下一步建议, "request_id":追踪编号, "details":开发上下文}}
//
// 并回写 X-Request-ID 响应头,便于前后端与日志联查。
func Fail(c *gin.Context, e *AppError) {
c.JSON(e.StatusCode, gin.H{"data": nil, "error": e.Code, "message": e.Message})
requestID := RequestIDOf(c)
e.RequestID = requestID
c.Header("X-Request-ID", requestID)
errorObj := gin.H{
"category": e.Category,
"status": e.StatusCode,
"hint": e.Hint,
"request_id": requestID,
}
if e.Details != nil {
errorObj["details"] = e.Details
}
logFail(c, e)
c.JSON(e.StatusCode, gin.H{
"code": e.Code,
"message": e.Message,
"data": nil,
"error": errorObj,
})
}
// logFail 记录结构化错误日志,满足可定位要求:request_id / method / path / status / code / category。
func logFail(c *gin.Context, e *AppError) {
if e == nil {
return
}
req := c.Request
method := ""
path := ""
if req != nil {
method = req.Method
path = req.URL.Path
}
log.Printf("HTTP ERROR [%s] status=%d code=%d %s %s -> %s request_id=%s",
e.Category, e.StatusCode, e.Code, method, path, e.Message, e.RequestID)
}