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
@@ -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()