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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user