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
@@ -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{