refactor: 专员目录收敛为 6 个,重整 AI 路由与系统管理页

一次性提交当天全部改动(110 个文件)。

**未按 TOP_CODING_RULES.md G14.5「一次提交只装一件事」拆分** —— 用户明确要求
单一提交,此处如实记录,不静默忽略该冲突。

提交前验证:后端 go build / go vet / go test ./... 全绿,前端 npm run build
exit 0,/api/health 与管理员 login 均返回 200。

- 专员目录收敛为 6 个:下线 knowledge-operations / process-coordination /
  presentation-briefing / report-generation 四个专员(前后端 manifest 与 seed
  同步删除),新增 general-assistant。专员的归属关系(拥有哪些技能定义、
  哪些目录项、提示词与绑定从哪来)改由 specialists/core 的 ownership.go、
  prompt_provider.go、binding_provider.go 统一提供,seed 与 admin_handlers
  随之内置化,卸载路径统一走 uninstall.go。
- 技能:补齐 text-to-speech 的前端 manifest(后端包在 HEAD 已存在),
  skillcore/ownership.go 提供与专员对称的归属查询。
- AI 路由:ai_config.json 由 OpenRouter/Ollama 切到 LMUAI / SiliconFlow /
  llama.cpp 本地路由,ai_secrets.example.json 与部署 env 样例同步新增
  SILICONFLOW_API_KEY。
- 系统管理页重整:新增 AiAdminPage、OrganizationManagementPage,删除
  AdminOverviewPage、CompanyConfigPage,SystemConfigPage 精简,nav / router /
  config/workbench.js 同步调整。依 G05.5,开发阶段直接收口到新结构,不留旧路由。
- 新增 internal/objectrefs:统一统计对象(专员 / 技能 / xapp)的运行时引用
  (被多少 xapp、项目、任务引用),供管理页做删除前的影响面判断。
- 公众号创作专员:新增 OfficialAccountSpecialistPanel,workflow 与投递链路调整。
- XApp:考试 / 培训 Shell 扩展,XAppDirectoryPage 与 xappDefinition 同步。
- 文档:新增 GW01–GW05 工作台演进系列与 AR12 对话驱动与结构化交互架构;
  同步 AR05 / SY17 / SY23 / SY25 / PL04;TOP_CODING_RULES.md 增补 G05.5。

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
eaiadmin
2026-09-23 09:01:28 +08:00
co-authored by Claude Code
parent 7fc2903827
commit 593323a934
110 changed files with 8295 additions and 1715 deletions
@@ -1 +1,172 @@
{"agent_routes": {"embed_gen": "embed_route_openrouter_text_v3", "image_gen": "image_route_openrouter_gpt_image_2", "path_coach": "chat_route_openrouter_deepseek_v4_flash", "title_gen": "chat_route_openrouter_deepseek_v4_flash"}, "chat_routes": {"chat_route_ollama_qwen": {"description": "本地 Ollama Qwen2.5:7b", "endpoint": "/chat/completions", "max_tokens": 2048, "model": "qwen2.5:7b", "provider": "ollama", "short_model_name": "Qwen", "short_route_name": "本地", "temperature": 0.7}, "chat_route_ollama_qwen_fast": {"description": "本地快速对话 (Ollama Qwen2.5:7b 轻量)", "endpoint": "/chat/completions", "max_tokens": 512, "model": "qwen2.5:7b", "provider": "ollama", "short_model_name": "Qwen", "short_route_name": "快聊", "temperature": 0.3}, "chat_route_openrouter_deepseek_v3": {"description": "DeepSeek V3.2 (OpenRouter)", "endpoint": "/chat/completions", "max_tokens": 4096, "model": "deepseek/deepseek-v3.2", "provider": "openrouter", "short_model_name": "DSV3", "short_route_name": "均衡", "temperature": 0.6}, "chat_route_openrouter_deepseek_v4_flash": {"description": "DeepSeek V4 Flash (OpenRouter)", "endpoint": "/chat/completions", "max_tokens": 4096, "model": "deepseek/deepseek-v4-flash", "provider": "openrouter", "short_model_name": "DS4F", "short_route_name": "默认", "temperature": 0.7}, "chat_route_openrouter_gpt_5": {"description": "GPT 5.4 (OpenRouter)", "endpoint": "/chat/completions", "max_tokens": 8192, "model": "openai/gpt-5.4", "provider": "openrouter", "short_model_name": "GPT5", "short_route_name": "全能", "temperature": 0.7}, "chat_route_openrouter_grok_4": {"description": "Grok 4.20 (OpenRouter)", "endpoint": "/chat/completions", "max_tokens": 4096, "model": "x-ai/grok-4.20", "provider": "openrouter", "short_model_name": "Grok4", "short_route_name": "推理", "temperature": 0.6}}, "default_embed_route": "embed_route_openrouter_text_v3", "default_route": "chat_route_openrouter_deepseek_v4_flash", "description": "eai_agentplatform AI 路由配置 — 按场景分类定义多条路由,支持 provider/模型切换", "embed_routes": {"embed_route_ollama_bge_m3": {"description": "本地 Ollama bge-m3", "endpoint": "/embeddings", "model": "bge-m3", "provider": "ollama", "short_model_name": "BGE", "short_route_name": "本地"}, "embed_route_openrouter_text_v3": {"description": "OpenAI text-embedding-3-small (OpenRouter)", "endpoint": "/embeddings", "model": "openai/text-embedding-3-small", "provider": "openrouter", "short_model_name": "Emb3", "short_route_name": "默认"}}, "fallback_routes": {"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"], "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": "FLUX 1.1 Pro (OpenRouter)", "endpoint": "/images/generations", "max_tokens": 1024, "model": "black-forest-labs/flux-1.1-pro", "provider": "openrouter", "short_model_name": "FLUX", "short_route_name": "绘图", "temperature": 1}, "image_route_openrouter_gpt_image_2": {"description": "GPT 5.4 Image 2 (OpenRouter)", "endpoint": "/images/generations", "max_tokens": 4096, "model": "openai/gpt-5.4-image-2", "provider": "openrouter", "short_model_name": "G图2", "short_route_name": "默认", "temperature": 1}}, "version": "2.1.0"}
{
"agent_routes": {
"embed_gen": "embed_route_openrouter_text_v3",
"image_gen": "image_route_openrouter_gpt_image_2",
"path_coach": "chat_route_lmuai_deepseek_flash",
"title_gen": "chat_route_lmuai_deepseek_flash"
},
"chat_routes": {
"chat_route_lmuai_deepseek_flash": {
"description": "LMUAI / DeepSeek Flash",
"endpoint": "/chat/completions",
"max_tokens": 4096,
"model": "deepseek-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",
"temperature": 0.7
},
"chat_route_ollama_qwen": {
"description": "本地对话 · llama.cpp / Qwen3.5 4B",
"endpoint": "/chat/completions",
"max_tokens": 2048,
"model": "Qwen3.5-4B-Q4_K_M.gguf",
"provider": "llamacpp",
"short_model_name": "Qwen3.5 4B",
"short_route_name": "本地对话",
"temperature": 0.7
},
"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
}
},
"default_embed_route": "embed_route_openrouter_text_v3",
"default_route": "chat_route_lmuai_deepseek_flash",
"description": "eai_agentplatform AI 路由配置 — 按场景分类定义多条路由,支持 provider/模型切换",
"embed_routes": {
"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_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"
],
"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"
],
"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
}
},
"version": "2.1.0"
}
@@ -1,6 +1,7 @@
{
"VECTORENGINE_API_KEY": "sk-...",
"OPENROUTER_API_KEY": "sk-or-...",
"SILICONFLOW_API_KEY": "sk-...",
"VOLCES_API_KEY": "",
"LMUAI_API_KEY": "sk-...",
"ALIYUN_API_KEY": "sk-...",
@@ -9,4 +10,4 @@
"ANTHROPIC_API_KEY": "",
"OPENAI_API_KEY": "",
"BRAVE_SEARCH_API_KEY": "BSA..."
}
}
@@ -12,9 +12,9 @@ JWT_SECRET=__CHANGE_ME__GENERATE_48_CHARS_RANDOM__
JWT_EXPIRE_MINUTES=480
# 内网 LLM(OpenAI 兼容接口)
LLM_BASE_URL=http://127.0.0.1:11434/v1
LLM_API_KEY=ollama
LLM_MODEL=qwen2.5:7b
LLM_BASE_URL=http://127.0.0.1:8080/v1
LLM_API_KEY=local
LLM_MODEL=Qwen3.5-4B-Q4_K_M.gguf
EMBED_MODEL=bge-m3
# 数据目录
@@ -8,6 +8,7 @@ import (
"github.com/gin-gonic/gin"
"eai_agentplatform/backend/internal/dal"
"eai_agentplatform/backend/internal/middleware"
"eai_agentplatform/backend/internal/model"
specialistruntime "eai_agentplatform/backend/internal/specialists/runtime"
@@ -89,6 +90,10 @@ func CreateProject(c *gin.Context) {
web.Fail(c, web.NewNotFoundError(err.Error()))
return
}
if err := validateSkillKeys(req.SkillKeys); err != nil {
web.Fail(c, web.NewNotFoundError(err.Error()))
return
}
instruction := ""
if req.Instruction != nil {
@@ -163,6 +168,10 @@ func UpdateProject(c *gin.Context) {
project.SpecialistKeys = encodeKeys(req.SpecialistKeys)
}
if req.SkillKeys != nil {
if err := validateSkillKeys(req.SkillKeys); err != nil {
web.Fail(c, web.NewNotFoundError(err.Error()))
return
}
project.SkillKeys = encodeKeys(req.SkillKeys)
}
if req.ConnectorKeys != nil {
@@ -246,3 +255,17 @@ func validateSpecialistKeys(keys []string) error {
}
return nil
}
func validateSkillKeys(keys []string) error {
dao := dal.SkillDefinitionDAO{}
for _, key := range keys {
trimmed := strings.TrimSpace(key)
if trimmed == "" {
continue
}
if _, found := dao.GetByKey(trimmed); !found {
return errors.New("技能不存在:" + trimmed)
}
}
return nil
}
@@ -11,8 +11,8 @@ import (
"eai_agentplatform/backend/internal/middleware"
skillapi "eai_agentplatform/backend/internal/skills/api"
specialistapi "eai_agentplatform/backend/internal/specialists/api"
"eai_agentplatform/backend/internal/specialists/runtime"
wechatofficialaccountapi "eai_agentplatform/backend/internal/specialists/packages/wechat_official_account/api"
"eai_agentplatform/backend/internal/specialists/runtime"
xappapi "eai_agentplatform/backend/internal/xapps/api"
)
@@ -206,12 +206,15 @@ func RegisterRoutes(r *gin.Engine, cfg *config.Config) {
admin.DELETE("/positions/:id", DeletePosition)
admin.POST("/specialists", specialistapi.CreateSpecialist)
admin.PUT("/specialists/:id", specialistapi.UpdateSpecialist)
admin.GET("/specialists/:id/remove-plan", specialistapi.PreviewSpecialistRemovePlan)
admin.DELETE("/specialists/:id", specialistapi.DeleteSpecialist)
admin.POST("/skills", skillapi.CreateSkillDefinition)
admin.PUT("/skills/:id", skillapi.UpdateSkillDefinition)
admin.GET("/skills/:id/remove-plan", skillapi.PreviewSkillRemovePlan)
admin.DELETE("/skills/:id", skillapi.DeleteSkillDefinition)
admin.POST("/xapps", xappapi.CreateXAppDefinition)
admin.PUT("/xapps/:id", xappapi.UpdateXAppDefinition)
admin.GET("/xapps/:id/remove-plan", xappapi.PreviewXAppRemovePlan)
admin.DELETE("/xapps/:id", xappapi.DeleteXAppDefinition)
admin.POST("/actions", CreateActionDefinition)
admin.PUT("/actions/:id", UpdateActionDefinition)
@@ -4,7 +4,7 @@ import (
"fmt"
"strings"
skillcore "eai_agentplatform/backend/internal/skills/core"
specialistcore "eai_agentplatform/backend/internal/specialists/core"
specialistmodel "eai_agentplatform/backend/internal/specialists/model"
)
@@ -63,7 +63,8 @@ func resolveSpecialist(req ChatMessageRequest) *specialistmodel.Specialist {
// 专员为 nil 或什么都没配时返回空串,调用方不做任何拼接——
// 这样「没选专员」与「选了但没配说明书」都不改变原有行为。
func specialistPromptSection(s *specialistmodel.Specialist) string {
if s == nil {
ctx := specialistcore.PromptContextFor(s)
if ctx.Key == "" {
return ""
}
@@ -71,27 +72,27 @@ func specialistPromptSection(s *specialistmodel.Specialist) string {
// 身份
b.WriteString("【当前专员】\n")
label := strings.TrimSpace(s.Label)
label := ctx.Label
if label == "" {
label = s.Key
label = ctx.Key
}
fmt.Fprintf(&b, "%s(%s)\n", label, s.Key)
if summary := strings.TrimSpace(s.Summary); summary != "" {
fmt.Fprintf(&b, "%s(%s)\n", label, ctx.Key)
if summary := ctx.Summary; summary != "" {
b.WriteString(summary + "\n")
}
if workStatus := strings.TrimSpace(s.WorkStatus); workStatus != "" {
if workStatus := ctx.WorkStatus; workStatus != "" {
b.WriteString("当前状态:" + workStatus + "\n")
}
// 可用技能
if skills := skillcore.ParseAllowedSkills(s.AllowedSkills); len(skills) > 0 {
if len(ctx.AllowedSkills) > 0 {
b.WriteString("\n【可用技能】\n")
b.WriteString(strings.Join(skills, "、"))
b.WriteString(strings.Join(ctx.AllowedSkills, "、"))
b.WriteString("\n首个是主技能。只在这些技能范围内承诺能力,范围外的需求如实说做不了。\n")
}
// 岗位说明书
if rule := strings.TrimSpace(s.RuleFileMarkdown); rule != "" {
if rule := ctx.RuleFileMarkdown; rule != "" {
b.WriteString("\n【岗位说明书】\n")
b.WriteString(rule)
b.WriteString("\n")
@@ -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: "report-generation", Label: "报告生成专员", State: "active", Tier: "generic", ObjectEntryRoute: "/apps/report-generation"},
{Key: "wechat-official-account", Label: "公众号创作专员", State: "active", Tier: "generic", ObjectEntryRoute: "/apps/wechat-official-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: "report-generation"})
got := resolveSpecialist(ChatMessageRequest{TaskID: task.ID, SpecialistKey: "wechat-official-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: "report-generation"})
if got == nil || got.Key != "report-generation" {
got := resolveSpecialist(ChatMessageRequest{SpecialistKey: "wechat-official-account"})
if got == nil || got.Key != "wechat-official-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: "report-generation"})
if got == nil || got.Key != "report-generation" {
got := resolveSpecialist(ChatMessageRequest{TaskID: task.ID, SpecialistKey: "wechat-official-account"})
if got == nil || got.Key != "wechat-official-account" {
t.Fatalf("任务专员失效时应回退到请求里的 key,实际 %v", got)
}
}
@@ -58,9 +58,9 @@ func Load() *Config {
JWTSecret: getenv("JWT_SECRET", "change-this-to-a-strong-secret-in-production"),
JWTExpireMin: getenvInt("JWT_EXPIRE_MINUTES", 480),
LLMBaseURL: getenv("LLM_BASE_URL", "http://127.0.0.1:11434/v1"),
LLMAPIKey: getenv("LLM_API_KEY", "ollama"),
LLMModel: getenv("LLM_MODEL", "qwen2.5:7b"),
LLMBaseURL: getenv("LLM_BASE_URL", "http://127.0.0.1:8080/v1"),
LLMAPIKey: getenv("LLM_API_KEY", "local"),
LLMModel: getenv("LLM_MODEL", "Qwen3.5-4B-Q4_K_M.gguf"),
EmbedModel: getenv("EMBED_MODEL", "bge-m3"),
AssetRootDir: assetRootDir,
@@ -63,6 +63,7 @@ type AIConfig struct {
type AISecrets struct {
VECTORENGINE_API_KEY string `json:"VECTORENGINE_API_KEY,omitempty"`
OPENROUTER_API_KEY string `json:"OPENROUTER_API_KEY,omitempty"`
SILICONFLOW_API_KEY string `json:"SILICONFLOW_API_KEY,omitempty"`
VOLCES_API_KEY string `json:"VOLCES_API_KEY,omitempty"`
LMUAI_API_KEY string `json:"LMUAI_API_KEY,omitempty"`
ALIYUN_API_KEY string `json:"ALIYUN_API_KEY,omitempty"`
@@ -91,6 +92,7 @@ type PlatformConfig struct {
var ProviderSecretKey = map[string]string{
"vectorengine": "VECTORENGINE_API_KEY",
"openrouter": "OPENROUTER_API_KEY",
"siliconflow": "SILICONFLOW_API_KEY",
"volces": "VOLCES_API_KEY",
"lmuai": "LMUAI_API_KEY",
"aliyun": "ALIYUN_API_KEY",
@@ -104,7 +106,9 @@ var ProviderSecretKey = map[string]string{
// ProviderDefaultBaseURL provider 默认 base_url(当 secrets 未提供时)
var ProviderDefaultBaseURL = map[string]string{
"ollama": "http://127.0.0.1:11434/v1",
"llamacpp": "http://127.0.0.1:8080/v1",
"openrouter": "https://openrouter.ai/api/v1",
"siliconflow": "https://api.siliconflow.cn/v1",
"openai": "https://api.openai.com/v1",
"vectorengine": "https://api.vectorengine.ai/v1",
"volces": "https://ark.cn-beijing.volces.com/api/v3",
@@ -362,6 +366,8 @@ func getSecretByField(s *AISecrets, field string) string {
return s.VECTORENGINE_API_KEY
case "OPENROUTER_API_KEY":
return s.OPENROUTER_API_KEY
case "SILICONFLOW_API_KEY":
return s.SILICONFLOW_API_KEY
case "VOLCES_API_KEY":
return s.VOLCES_API_KEY
case "LMUAI_API_KEY":
@@ -107,7 +107,7 @@ func (r ProductDAO) NamesByIDs(ids []uint) map[uint]string {
// ProductsByStatus 按状态筛选产品列表。
func (r ProductDAO) ProductsByStatus(status string, filter map[string]string) []model.Product {
q := r.Type(&model.Product{})
if cat, ok := filter["category"]; ok {
if cat, ok := filter["category"]; ok && cat != "" {
q = q.Where("category = ?", cat)
}
switch status {
@@ -0,0 +1,209 @@
package objectrefs
import (
"encoding/json"
"sort"
"strings"
"eai_agentplatform/backend/internal/dal"
"eai_agentplatform/backend/internal/model"
specialistmodel "eai_agentplatform/backend/internal/specialists/model"
xappmodel "eai_agentplatform/backend/internal/xapps/model"
)
type SpecialistRuntimeRefs struct {
XAppKeys []string
ProjectIDs []uint
TaskCount int64
TaskRunCount int64
ArtifactCount int64
AICallCount int64
}
type SkillRuntimeRefs struct {
SpecialistKeys []string
XAppKeys []string
ProjectIDs []uint
}
type XAppRuntimeRefs struct {
FavoriteUserIDs []uint
RecentUserIDs []uint
CustomUserIDs []uint
}
func ParseKeyList(raw string) []string {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return nil
}
var keys []string
if err := json.Unmarshal([]byte(trimmed), &keys); err != nil {
return nil
}
out := make([]string, 0, len(keys))
seen := make(map[string]bool, len(keys))
for _, key := range keys {
key = strings.TrimSpace(key)
if key == "" || seen[key] {
continue
}
seen[key] = true
out = append(out, key)
}
return out
}
func listProjectIDsByKeyField(field string, key string) []uint {
key = strings.TrimSpace(key)
if key == "" || dal.DB == nil {
return nil
}
var projects []model.Project
if err := dal.DB.Select("id, " + field).Find(&projects).Error; err != nil {
return nil
}
ids := make([]uint, 0)
for _, project := range projects {
var raw string
switch field {
case "specialist_keys":
raw = project.SpecialistKeys
case "skill_keys":
raw = project.SkillKeys
default:
continue
}
for _, item := range ParseKeyList(raw) {
if item == key {
ids = append(ids, project.ID)
break
}
}
}
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
return ids
}
func listXAppKeysByField(field string, key string) []string {
key = strings.TrimSpace(key)
if key == "" || dal.DB == nil {
return nil
}
var items []xappmodel.XAppDefinition
if err := dal.DB.Select("key, "+field).Where(field+" = ?", key).Find(&items).Error; err != nil {
return nil
}
out := make([]string, 0, len(items))
for _, item := range items {
if trimmed := strings.TrimSpace(item.Key); trimmed != "" {
out = append(out, trimmed)
}
}
sort.Strings(out)
return out
}
func listSpecialistKeysBindingSkill(skillKey string) []string {
skillKey = strings.TrimSpace(skillKey)
if skillKey == "" || dal.DB == nil {
return nil
}
var items []specialistmodel.Specialist
if err := dal.DB.Select("key, allowed_skills").Find(&items).Error; err != nil {
return nil
}
out := make([]string, 0)
for _, item := range items {
for _, key := range ParseKeyList(item.AllowedSkills) {
if key == skillKey {
out = append(out, strings.TrimSpace(item.Key))
break
}
}
}
sort.Strings(out)
return out
}
func CollectSpecialistRuntimeRefs(key string) SpecialistRuntimeRefs {
key = strings.TrimSpace(key)
refs := SpecialistRuntimeRefs{
XAppKeys: listXAppKeysByField("specialist_key", key),
ProjectIDs: listProjectIDsByKeyField("specialist_keys", key),
}
if key == "" || dal.DB == nil {
return refs
}
dal.DB.Model(&model.TaskRecord{}).Where("specialist_key = ?", key).Count(&refs.TaskCount)
dal.DB.Model(&model.TaskRun{}).Where("specialist_key = ?", key).Count(&refs.TaskRunCount)
dal.DB.Model(&model.TaskArtifact{}).Where("specialist_key = ?", key).Count(&refs.ArtifactCount)
dal.DB.Model(&model.AiCallLog{}).Where("specialist_key = ?", key).Count(&refs.AICallCount)
return refs
}
func CollectSkillRuntimeRefs(key string) SkillRuntimeRefs {
key = strings.TrimSpace(key)
return SkillRuntimeRefs{
SpecialistKeys: listSpecialistKeysBindingSkill(key),
XAppKeys: listXAppKeysByField("skill_key", key),
ProjectIDs: listProjectIDsByKeyField("skill_keys", key),
}
}
func listUserIDsByXAppCenterField(field string, key string) []uint {
key = strings.TrimSpace(key)
if key == "" || dal.DB == nil {
return nil
}
var rows []xappmodel.UserXAppCenter
if err := dal.DB.Select("user_id, favorite_keys, recent_keys, custom_xapps").Find(&rows).Error; err != nil {
return nil
}
userIDs := make([]uint, 0)
for _, row := range rows {
var matched bool
switch field {
case "favorite_keys":
for _, item := range ParseKeyList(row.FavoriteKeys) {
if item == key {
matched = true
break
}
}
case "recent_keys":
for _, item := range ParseKeyList(row.RecentKeys) {
if item == key {
matched = true
break
}
}
case "custom_xapps":
var items []struct {
Key string `json:"key"`
}
if err := json.Unmarshal([]byte(strings.TrimSpace(row.CustomXApps)), &items); err == nil {
for _, item := range items {
if strings.TrimSpace(item.Key) == key {
matched = true
break
}
}
}
}
if matched {
userIDs = append(userIDs, row.UserID)
}
}
sort.Slice(userIDs, func(i, j int) bool { return userIDs[i] < userIDs[j] })
return userIDs
}
func CollectXAppRuntimeRefs(key string) XAppRuntimeRefs {
key = strings.TrimSpace(key)
return XAppRuntimeRefs{
FavoriteUserIDs: listUserIDsByXAppCenterField("favorite_keys", key),
RecentUserIDs: listUserIDsByXAppCenterField("recent_keys", key),
CustomUserIDs: listUserIDsByXAppCenterField("custom_xapps", key),
}
}
@@ -6,6 +6,8 @@ import (
"github.com/gin-gonic/gin"
"eai_agentplatform/backend/internal/dal"
"eai_agentplatform/backend/internal/objectrefs"
skillcore "eai_agentplatform/backend/internal/skills/core"
skillmodel "eai_agentplatform/backend/internal/skills/model"
"eai_agentplatform/backend/internal/web"
)
@@ -117,6 +119,10 @@ func DeleteSkillDefinition(c *gin.Context) {
web.Fail(c, web.NewNotFoundError("技能定义不存在"))
return
}
if plan, ok := skillcore.BuildRemovePlanByKey(item.Key); ok && plan.Blocked {
web.Fail(c, web.NewConflictError("技能仍被专员、应用或项目引用,请先处理 remove-plan 中的关联项"))
return
}
if !skillDefinitionDAO.Delete(&item) {
web.Fail(c, web.NewBadRequest("删除技能定义失败"))
return
@@ -124,6 +130,52 @@ func DeleteSkillDefinition(c *gin.Context) {
web.OK(c, gin.H{"id": id, "deleted": true})
}
// PreviewSkillRemovePlan GET /api/admin/skills/:id/remove-plan (admin)
func PreviewSkillRemovePlan(c *gin.Context) {
id, ok := parseID(c, "id")
if !ok {
return
}
item, found := skillDefinitionDAO.GetByID(id)
if !found {
web.Fail(c, web.NewNotFoundError("技能定义不存在"))
return
}
if plan, ok := skillcore.BuildRemovePlanByKey(item.Key); ok {
web.OK(c, gin.H{
"id": item.ID,
"key": item.Key,
"label": item.Label,
"source": item.Source,
"state": item.State,
"is_builtin": true,
"plan": plan,
})
return
}
refs := objectrefs.CollectSkillRuntimeRefs(item.Key)
web.OK(c, gin.H{
"id": item.ID,
"key": item.Key,
"label": item.Label,
"source": item.Source,
"state": item.State,
"is_builtin": false,
"plan": gin.H{
"key": item.Key,
"label": item.Label,
"definition_keys": []string{},
"catalog_entries": []string{},
"policy": "",
"runtime_refs": refs,
"blocked": false,
},
})
}
func parseID(c *gin.Context, name string) (uint, bool) {
id, err := strconv.ParseUint(c.Param(name), 10, 64)
if err != nil || id == 0 {
@@ -5,11 +5,15 @@ import (
"fmt"
"sort"
"strings"
"eai_agentplatform/backend/internal/dal"
skillmodel "eai_agentplatform/backend/internal/skills/model"
)
var ValidSkillKeys = buildValidSkillKeys()
var ValidSkillKeys = buildBuiltinSkillKeys()
var allowedSkillKeyProvider = buildAllowedSkillKeys
func buildValidSkillKeys() map[string]bool {
func buildBuiltinSkillKeys() map[string]bool {
keys := make(map[string]bool)
for _, manifest := range builtinRegistry.Manifests() {
if strings.TrimSpace(manifest.Key) == "" {
@@ -21,8 +25,31 @@ func buildValidSkillKeys() map[string]bool {
}
func SortedValidSkillKeys() []string {
keys := make([]string, 0, len(ValidSkillKeys))
return sortedSkillKeys(ValidSkillKeys)
}
func buildAllowedSkillKeys() map[string]bool {
keys := make(map[string]bool, len(ValidSkillKeys))
for k := range ValidSkillKeys {
keys[k] = true
}
if dal.DB == nil {
return keys
}
if !dal.DB.Migrator().HasTable(&skillmodel.SkillDefinition{}) {
return keys
}
for _, item := range (dal.SkillDefinitionDAO{}).List("", nil) {
if key := strings.TrimSpace(item.Key); key != "" {
keys[key] = true
}
}
return keys
}
func sortedSkillKeys(set map[string]bool) []string {
keys := make([]string, 0, len(set))
for k := range set {
keys = append(keys, k)
}
sort.Strings(keys)
@@ -59,14 +86,16 @@ func MarshalAllowedSkills(keys []string) (string, error) {
}
func ValidateAllowedSkills(keys []string) error {
validKeys := allowedSkillKeyProvider()
sortedKeys := sortedSkillKeys(validKeys)
seen := make(map[string]bool, len(keys))
for _, k := range keys {
k = strings.TrimSpace(k)
if k == "" {
continue
}
if !ValidSkillKeys[k] {
return fmt.Errorf("技能 key %q 非法,可选值:%s", k, strings.Join(SortedValidSkillKeys(), " / "))
if !validKeys[k] {
return fmt.Errorf("技能 key %q 非法,可选值:%s", k, strings.Join(sortedKeys, " / "))
}
if seen[k] {
return fmt.Errorf("技能 key %q 重复绑定", k)
@@ -110,6 +110,11 @@ func TestMarshalAllowedSkills(t *testing.T) {
}
func TestValidateAllowedSkills(t *testing.T) {
originalProvider := allowedSkillKeyProvider
t.Cleanup(func() {
allowedSkillKeyProvider = originalProvider
})
t.Run("合法列表通过", func(t *testing.T) {
if err := ValidateAllowedSkills([]string{"contract-review", "batch-extract"}); err != nil {
t.Errorf("不应报错: %v", err)
@@ -138,6 +143,21 @@ func TestValidateAllowedSkills(t *testing.T) {
t.Fatal("重复绑定应报错(首个为主技能的语义会变得不确定)")
}
})
t.Run("运行时技能定义可放宽内置白名单", func(t *testing.T) {
allowedSkillKeyProvider = func() map[string]bool {
return map[string]bool{
"contract-review": true,
"runtime-skill": true,
}
}
if err := ValidateAllowedSkills([]string{"runtime-skill"}); err != nil {
t.Fatalf("运行时技能应允许绑定: %v", err)
}
if ValidSkillKeys["runtime-skill"] {
t.Fatal("内置技能清单不应被运行时定义污染")
}
})
}
func readFrontendFile(t *testing.T, relPath string) []byte {
@@ -0,0 +1,38 @@
package skillcore
import skillcontracts "eai_agentplatform/backend/internal/skills/contracts"
type OwnershipInfo struct {
Key string
Label string
DefinitionKeys []string
CatalogEntries []string
Policy skillcontracts.SharedReferencePolicy
}
func OwnershipCatalog() map[string]OwnershipInfo {
items := make(map[string]OwnershipInfo)
for _, manifest := range builtinRegistry.Manifests() {
items[manifest.Key] = ownershipFromManifest(manifest)
}
return items
}
func OwnershipByKey(key string) (OwnershipInfo, bool) {
manifest, ok := builtinRegistry.ByKey(key)
if !ok {
return OwnershipInfo{}, false
}
return ownershipFromManifest(manifest), true
}
func ownershipFromManifest(manifest skillcontracts.Manifest) OwnershipInfo {
meta := manifest.UninstallMeta()
return OwnershipInfo{
Key: manifest.Key,
Label: manifest.Label,
DefinitionKeys: meta.OwnedDefinitionKeys,
CatalogEntries: meta.OwnedCatalogEntries,
Policy: meta.ReferencePolicy,
}
}
@@ -1,6 +1,10 @@
package skillcore
import skillcontracts "eai_agentplatform/backend/internal/skills/contracts"
import (
skillcontracts "eai_agentplatform/backend/internal/skills/contracts"
"eai_agentplatform/backend/internal/objectrefs"
)
type UninstallPreview struct {
DefinitionKeys []string
@@ -8,6 +12,16 @@ type UninstallPreview struct {
Policy skillcontracts.SharedReferencePolicy
}
type RemovePlan struct {
Key string
Label string
DefinitionKeys []string
CatalogEntries []string
Policy skillcontracts.SharedReferencePolicy
RuntimeRefs objectrefs.SkillRuntimeRefs
Blocked bool
}
func BuildUninstallPreview(manifest skillcontracts.Manifest) UninstallPreview {
meta := manifest.UninstallMeta()
return UninstallPreview{
@@ -16,3 +30,22 @@ func BuildUninstallPreview(manifest skillcontracts.Manifest) UninstallPreview {
Policy: meta.ReferencePolicy,
}
}
func BuildRemovePlanByKey(key string) (RemovePlan, bool) {
ownership, ok := OwnershipByKey(key)
if !ok {
return RemovePlan{}, false
}
refs := objectrefs.CollectSkillRuntimeRefs(key)
blocked := ownership.Policy == skillcontracts.SharedReferenceBlockUninstall &&
(len(refs.SpecialistKeys) > 0 || len(refs.XAppKeys) > 0 || len(refs.ProjectIDs) > 0)
return RemovePlan{
Key: ownership.Key,
Label: ownership.Label,
DefinitionKeys: ownership.DefinitionKeys,
CatalogEntries: ownership.CatalogEntries,
Policy: ownership.Policy,
RuntimeRefs: refs,
Blocked: blocked,
}, true
}
@@ -2,10 +2,13 @@ package specialistapi
import (
"strconv"
"strings"
"github.com/gin-gonic/gin"
"eai_agentplatform/backend/internal/dal"
"eai_agentplatform/backend/internal/objectrefs"
specialistcore "eai_agentplatform/backend/internal/specialists/core"
specialistmodel "eai_agentplatform/backend/internal/specialists/model"
specialistruntime "eai_agentplatform/backend/internal/specialists/runtime"
"eai_agentplatform/backend/internal/web"
@@ -150,10 +153,19 @@ func DeleteSpecialist(c *gin.Context) {
if !ok {
return
}
if _, found := specialistDAO.GetByID(id); !found {
item, found := specialistDAO.GetByID(id)
if !found {
web.Fail(c, web.NewNotFoundError("专员不存在"))
return
}
if strings.TrimSpace(item.Key) == "general-assistant" || strings.TrimSpace(item.State) == "system" {
web.Fail(c, web.NewConflictError("通用专员是系统保留专员,不能删除"))
return
}
if plan, ok := specialistcore.BuildRemovePlanByKey(item.Key); ok && plan.Blocked {
web.Fail(c, web.NewConflictError("专员仍被运行时对象引用,请先处理 remove-plan 中的关联项"))
return
}
if !specialistDAO.Delete(id) {
web.Fail(c, web.NewBadRequest("删除专员失败"))
return
@@ -161,6 +173,49 @@ func DeleteSpecialist(c *gin.Context) {
web.OK(c, gin.H{"id": id, "deleted": true})
}
// PreviewSpecialistRemovePlan GET /api/admin/specialists/:id/remove-plan (admin)
func PreviewSpecialistRemovePlan(c *gin.Context) {
id, ok := parseID(c, "id")
if !ok {
return
}
item, found := specialistDAO.GetByID(id)
if !found {
web.Fail(c, web.NewNotFoundError("专员不存在"))
return
}
if plan, ok := specialistcore.BuildRemovePlanByKey(item.Key); ok {
web.OK(c, gin.H{
"id": item.ID,
"key": item.Key,
"label": item.Label,
"state": item.State,
"is_builtin": true,
"plan": plan,
})
return
}
refs := objectrefs.CollectSpecialistRuntimeRefs(item.Key)
web.OK(c, gin.H{
"id": item.ID,
"key": item.Key,
"label": item.Label,
"state": item.State,
"is_builtin": false,
"plan": gin.H{
"key": item.Key,
"label": item.Label,
"definition_keys": []string{},
"catalog_entries": []string{},
"policy": "",
"runtime_refs": refs,
"blocked": false,
},
})
}
func parseID(c *gin.Context, name string) (uint, bool) {
id, err := strconv.ParseUint(c.Param(name), 10, 64)
if err != nil || id == 0 {
@@ -22,8 +22,8 @@ func RuleFileDrafts() map[string]string {
func SkillBindings() map[string][]string {
items := make(map[string][]string)
for _, manifest := range builtinRegistry.Manifests() {
if len(manifest.AllowedSkills) > 0 {
items[manifest.Key] = append([]string(nil), manifest.AllowedSkills...)
if skills := DefaultAllowedSkillsForKey(manifest.Key); len(skills) > 0 {
items[manifest.Key] = skills
}
}
return items
@@ -38,13 +38,14 @@ func ApplyRuleFiles(items []specialistmodel.Specialist) error {
if manifest.RuleFileMarkdown != "" {
items[i].RuleFileMarkdown = manifest.RuleFileMarkdown
}
if len(manifest.AllowedSkills) == 0 {
skills := DefaultAllowedSkillsForKey(items[i].Key)
if len(skills) == 0 {
continue
}
if err := skillcore.ValidateAllowedSkills(manifest.AllowedSkills); err != nil {
if err := skillcore.ValidateAllowedSkills(skills); err != nil {
return fmt.Errorf("专员 %s 的技能绑定非法: %w", items[i].Key, err)
}
raw, err := skillcore.MarshalAllowedSkills(manifest.AllowedSkills)
raw, err := MarshalAllowedSkillBindingsFor(&items[i])
if err != nil {
return fmt.Errorf("专员 %s 的技能绑定序列化失败: %w", items[i].Key, err)
}
@@ -0,0 +1,37 @@
package specialistcore
import (
"strings"
skillcore "eai_agentplatform/backend/internal/skills/core"
specialistmodel "eai_agentplatform/backend/internal/specialists/model"
)
// DefaultAllowedSkillsForKey 返回包定义里的默认技能绑定。
//
// 它表达的是 specialist 包默认 own 的绑定,不看运行时数据库。
func DefaultAllowedSkillsForKey(key string) []string {
manifest, ok := builtinRegistry.ByKey(strings.TrimSpace(key))
if !ok || len(manifest.AllowedSkills) == 0 {
return nil
}
return append([]string(nil), manifest.AllowedSkills...)
}
// AllowedSkillBindingsFor 返回专员当前有效的技能绑定。
//
// 运行时持久化层优先,包内 manifest 作为兜底默认值。
func AllowedSkillBindingsFor(s *specialistmodel.Specialist) []string {
if s == nil {
return nil
}
if skills := skillcore.ParseAllowedSkills(s.AllowedSkills); len(skills) > 0 {
return skills
}
return DefaultAllowedSkillsForKey(s.Key)
}
// MarshalAllowedSkillBindingsFor 返回专员当前有效技能绑定的持久化表示。
func MarshalAllowedSkillBindingsFor(s *specialistmodel.Specialist) (string, error) {
return skillcore.MarshalAllowedSkills(AllowedSkillBindingsFor(s))
}
@@ -0,0 +1,52 @@
package specialistcore
import (
"reflect"
"testing"
specialistmodel "eai_agentplatform/backend/internal/specialists/model"
)
func TestDefaultAllowedSkillsForKey(t *testing.T) {
got := DefaultAllowedSkillsForKey("contract-review")
want := []string{"contract-review", "contract-brief", "batch-extract"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("默认技能绑定不对,实际 %v,期望 %v", got, want)
}
}
func TestAllowedSkillBindingsForPrefersPersistedValues(t *testing.T) {
s := &specialistmodel.Specialist{
Key: "contract-review",
AllowedSkills: `["runtime-skill","contract-review"]`,
}
got := AllowedSkillBindingsFor(s)
want := []string{"runtime-skill", "contract-review"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("应优先使用数据库技能绑定,实际 %v,期望 %v", got, want)
}
}
func TestAllowedSkillBindingsForFallsBackToManifest(t *testing.T) {
s := &specialistmodel.Specialist{Key: "contract-review"}
got := AllowedSkillBindingsFor(s)
want := []string{"contract-review", "contract-brief", "batch-extract"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("数据库技能绑定为空时应回退 manifest,实际 %v,期望 %v", got, want)
}
}
func TestMarshalAllowedSkillBindingsFor(t *testing.T) {
s := &specialistmodel.Specialist{Key: "contract-review"}
got, err := MarshalAllowedSkillBindingsFor(s)
if err != nil {
t.Fatalf("序列化默认技能绑定失败: %v", err)
}
want := `["contract-review","contract-brief","batch-extract"]`
if got != want {
t.Fatalf("序列化结果不对,实际 %s,期望 %s", got, want)
}
}
@@ -0,0 +1,38 @@
package specialistcore
import specialistcontracts "eai_agentplatform/backend/internal/specialists/contracts"
type OwnershipInfo struct {
Key string
Label string
DefinitionKeys []string
CatalogEntries []string
Policy specialistcontracts.SharedReferencePolicy
}
func OwnershipCatalog() map[string]OwnershipInfo {
items := make(map[string]OwnershipInfo)
for _, manifest := range builtinRegistry.Manifests() {
items[manifest.Key] = ownershipFromManifest(manifest)
}
return items
}
func OwnershipByKey(key string) (OwnershipInfo, bool) {
manifest, ok := builtinRegistry.ByKey(key)
if !ok {
return OwnershipInfo{}, false
}
return ownershipFromManifest(manifest), true
}
func ownershipFromManifest(manifest specialistcontracts.Manifest) OwnershipInfo {
meta := manifest.UninstallMeta()
return OwnershipInfo{
Key: manifest.Key,
Label: manifest.Label,
DefinitionKeys: meta.OwnedDefinitionKeys,
CatalogEntries: meta.OwnedCatalogEntries,
Policy: meta.ReferencePolicy,
}
}
@@ -0,0 +1,52 @@
package specialistcore
import (
"strings"
specialistmodel "eai_agentplatform/backend/internal/specialists/model"
)
// PromptContext 是平台层真正需要的专员提示词上下文。
//
// 平台不必知道 rule_file_markdown / allowed_skills 落在哪个字段,
// 只消费这里收口后的结果。
type PromptContext struct {
Key string
Label string
Summary string
WorkStatus string
AllowedSkills []string
RuleFileMarkdown string
}
// PromptContextFor 返回专员当前有效的提示词上下文。
//
// 运行时持久化层优先,包内 manifest 作为兜底默认值:
// 1. 管理员在线编辑后的岗位说明书 / 技能绑定以数据库为准
// 2. 数据库为空时,再回退到内置专员 manifest
func PromptContextFor(s *specialistmodel.Specialist) PromptContext {
if s == nil {
return PromptContext{}
}
ctx := PromptContext{
Key: strings.TrimSpace(s.Key),
Label: strings.TrimSpace(s.Label),
Summary: strings.TrimSpace(s.Summary),
WorkStatus: strings.TrimSpace(s.WorkStatus),
AllowedSkills: AllowedSkillBindingsFor(s),
RuleFileMarkdown: strings.TrimSpace(s.RuleFileMarkdown),
}
manifest, ok := builtinRegistry.ByKey(ctx.Key)
if !ok {
return ctx
}
if ctx.Label == "" {
ctx.Label = strings.TrimSpace(manifest.Label)
}
if ctx.RuleFileMarkdown == "" {
ctx.RuleFileMarkdown = strings.TrimSpace(manifest.RuleFileMarkdown)
}
return ctx
}
@@ -0,0 +1,58 @@
package specialistcore
import (
"reflect"
"testing"
specialistmodel "eai_agentplatform/backend/internal/specialists/model"
)
func TestPromptContextForPrefersPersistedValues(t *testing.T) {
s := &specialistmodel.Specialist{
Key: "contract-review",
Label: "合同审查专员(自定义)",
Summary: "线上编辑后的摘要",
WorkStatus: "2 份待复核",
AllowedSkills: `["runtime-skill","contract-review"]`,
RuleFileMarkdown: "# 自定义岗位说明书",
}
got := PromptContextFor(s)
if got.Label != "合同审查专员(自定义)" {
t.Fatalf("应优先使用数据库 label,实际 %q", got.Label)
}
if got.RuleFileMarkdown != "# 自定义岗位说明书" {
t.Fatalf("应优先使用数据库岗位说明书,实际 %q", got.RuleFileMarkdown)
}
wantSkills := []string{"runtime-skill", "contract-review"}
if !reflect.DeepEqual(got.AllowedSkills, wantSkills) {
t.Fatalf("应优先使用数据库技能绑定,实际 %v,期望 %v", got.AllowedSkills, wantSkills)
}
}
func TestPromptContextForFallsBackToManifest(t *testing.T) {
s := &specialistmodel.Specialist{
Key: "contract-review",
Label: "",
}
got := PromptContextFor(s)
if got.Label != "合同审查专员" {
t.Fatalf("数据库 label 为空时应回退 manifest,实际 %q", got.Label)
}
if got.RuleFileMarkdown == "" {
t.Fatal("数据库岗位说明书为空时应回退 manifest")
}
wantSkills := []string{"contract-review", "contract-brief", "batch-extract"}
if !reflect.DeepEqual(got.AllowedSkills, wantSkills) {
t.Fatalf("数据库技能绑定为空时应回退 manifest,实际 %v,期望 %v", got.AllowedSkills, wantSkills)
}
}
func TestPromptContextForNil(t *testing.T) {
if got := PromptContextFor(nil); !reflect.DeepEqual(got, PromptContext{}) {
t.Fatalf("nil 专员应返回空上下文,实际 %#v", got)
}
}
@@ -1,6 +1,9 @@
package specialistcore
import specialistcontracts "eai_agentplatform/backend/internal/specialists/contracts"
import (
"eai_agentplatform/backend/internal/objectrefs"
specialistcontracts "eai_agentplatform/backend/internal/specialists/contracts"
)
type UninstallPreview struct {
DefinitionKeys []string
@@ -8,6 +11,16 @@ type UninstallPreview struct {
Policy specialistcontracts.SharedReferencePolicy
}
type RemovePlan struct {
Key string
Label string
DefinitionKeys []string
CatalogEntries []string
Policy specialistcontracts.SharedReferencePolicy
RuntimeRefs objectrefs.SpecialistRuntimeRefs
Blocked bool
}
func BuildUninstallPreview(manifest specialistcontracts.Manifest) UninstallPreview {
meta := manifest.UninstallMeta()
return UninstallPreview{
@@ -16,3 +29,22 @@ func BuildUninstallPreview(manifest specialistcontracts.Manifest) UninstallPrevi
Policy: meta.ReferencePolicy,
}
}
func BuildRemovePlanByKey(key string) (RemovePlan, bool) {
ownership, ok := OwnershipByKey(key)
if !ok {
return RemovePlan{}, false
}
refs := objectrefs.CollectSpecialistRuntimeRefs(key)
blocked := ownership.Policy == specialistcontracts.SharedReferenceBlockUninstall &&
(len(refs.XAppKeys) > 0 || len(refs.ProjectIDs) > 0 || refs.TaskCount > 0 || refs.TaskRunCount > 0 || refs.ArtifactCount > 0 || refs.AICallCount > 0)
return RemovePlan{
Key: ownership.Key,
Label: ownership.Label,
DefinitionKeys: ownership.DefinitionKeys,
CatalogEntries: ownership.CatalogEntries,
Policy: ownership.Policy,
RuntimeRefs: refs,
Blocked: blocked,
}, true
}
@@ -1,30 +0,0 @@
package knowledgeoperations
import specialistcontracts "eai_agentplatform/backend/internal/specialists/contracts"
var Manifest = specialistcontracts.Manifest{
Key: "knowledge-operations",
Label: "知识运营专员",
RuleFileMarkdown: `# 知识运营专员
## 你是谁
你负责知识资产的整理:对象映射、证据抽取、规则同步、发布检查。
你的产出要能被人复核,所以每条结论都必须带回原文出处。
## 你怎么推进
1. 先确认这次处理的是哪份知识资产、目标对象字典是哪一版。
2. 抽取时逐条给出「字段 / 取值 / 证据片段 / 出处位置」,拿不准的单独列成「待确认」。
3. 收尾做发布检查:字段有没有缺、同一字段前后取值是否冲突、有没有遗留占位符。
## 输出要求
- 证据片段必须原文照抄,不要改写、不要概括。
- 冲突项要给出两个取值各自的出处,让管理员自己判。
## 边界
- 不自作主张删改知识资产原文;发现该改的,列成建议交管理员。
- 映射不确定时宁可标「待确认」,不要猜一个填上。`,
AllowedSkills: []string{"batch-extract", "table-cleanup", "policy-rewrite"},
OwnedDefinitionKeys: []string{"specialist.knowledge-operations.definition"},
OwnedCatalogEntries: []string{"catalog.specialist.knowledge-operations"},
ReferencePolicy: specialistcontracts.SharedReferenceReadonlyHistory,
}
@@ -1,33 +0,0 @@
package presentationbriefing
import specialistcontracts "eai_agentplatform/backend/internal/specialists/contracts"
var Manifest = specialistcontracts.Manifest{
Key: "presentation-briefing",
Label: "汇报专员",
RuleFileMarkdown: `# 汇报专员
## 你是谁
你负责把零散材料收成能讲、能交、能继续细化的汇报稿:经营汇报、方案汇报、培训课件、项目复盘。
你面对的是马上要上台讲的人,他要的是一版结构清楚、页数合适、重点能讲明白的演示稿。
## 你怎么推进
1. 先确认汇报对象、场景和页数限制:给管理层、给客户、给培训学员,写法完全不同。
2. 先出结构,不急着写满内容:按「开场结论 → 现状 / 问题 → 方案 / 进展 → 风险 / 下一步」组织。
3. 每页只保留一层主结论,宁可留白,不要把 Word 直接搬进 PPT。
4. 最后补讲稿备注:每页写清楚要讲什么、强调什么、哪些数字必须口径一致。
## 输出要求
- 先给页纲,再给每页要点,再补讲稿备注。
- 所有数字、时间、范围要带口径;没有依据的地方写「待补」,不要自己补齐。
- 如果页数明显超了,优先帮用户压缩,而不是机械地继续加页。
## 边界
- 不编造数据、案例、客户反馈。
- 不把未确认方案写成既定事实。
- 视觉美化可以建议,但当前重点是结构与表达,不承诺设计成品。`,
AllowedSkills: []string{"ppt-generation", "mind-map", "progress-report"},
OwnedDefinitionKeys: []string{"specialist.presentation-briefing.definition"},
OwnedCatalogEntries: []string{"catalog.specialist.presentation-briefing"},
ReferencePolicy: specialistcontracts.SharedReferenceReadonlyHistory,
}
@@ -1,30 +0,0 @@
package processcoordination
import specialistcontracts "eai_agentplatform/backend/internal/specialists/contracts"
var Manifest = specialistcontracts.Manifest{
Key: "process-coordination",
Label: "流程推进专员",
RuleFileMarkdown: `# 流程推进专员
## 你是谁
你负责跨部门事项的推进:催办、checkpoint 跟进、阻塞清理、责任路由。
你面对的是被卡住的事,用户要的是「下一步谁做什么」。
## 你怎么推进
1. 先定位卡在哪一个 checkpoint、卡在谁那里、卡了多久。
2. 判断这是「没看到」「没时间」还是「有分歧」——三者的催法完全不同。
3. 给出升级路径:本级催办无效时,下一步找谁、以什么理由。
## 输出要求
- 每条待办写全「事项 / 责任人 / 当前卡点 / 建议动作 / 建议时限」。
- 催办话术要能直接复制发出去,不要写成「请尽快处理」这种空话。
## 边界
- 只起草催办与升级建议,不代替用户对外发送。
- 不评价同事的工作态度,只陈述事项状态与时间事实。`,
AllowedSkills: []string{"progress-report"},
OwnedDefinitionKeys: []string{"specialist.process-coordination.definition"},
OwnedCatalogEntries: []string{"catalog.specialist.process-coordination"},
ReferencePolicy: specialistcontracts.SharedReferenceReadonlyHistory,
}
@@ -1,30 +0,0 @@
package reportgeneration
import specialistcontracts "eai_agentplatform/backend/internal/specialists/contracts"
var Manifest = specialistcontracts.Manifest{
Key: "report-generation",
Label: "报告生成专员",
RuleFileMarkdown: `# 报告生成专员
## 你是谁
你负责把散落的进展整理成能交上去的报告:日报、周报、复盘摘要、交付说明。
你面对的是要拿这份报告去汇报的人。
## 你怎么推进
1. 先问清报告的读者和用途——给客户看和给自己团队看,写法完全不同。
2. 按「结论 → 依据 → 下一步」组织,结论放最前面。
3. 数据只用手上有出处的,缺的地方写「待补」,不要用估计值填空。
## 输出要求
- 一份报告一个主线,不要把三件事揉成一段。
- 风险与未完成项不要藏,单独成节。
## 边界
- 不编造未发生的进展,不把「计划做」写成「已做」。
- 封面/落款等信息缺就问,不要自己拟一个。`,
AllowedSkills: []string{"report-generation", "progress-report", "ppt-generation", "meeting-minutes"},
OwnedDefinitionKeys: []string{"specialist.report-generation.definition"},
OwnedCatalogEntries: []string{"catalog.specialist.report-generation"},
ReferencePolicy: specialistcontracts.SharedReferenceReadonlyHistory,
}
@@ -4,8 +4,8 @@ import specialistcontracts "eai_agentplatform/backend/internal/specialists/contr
var Manifest = specialistcontracts.Manifest{
Key: "resume-processor",
Label: "招聘筛选专员",
RuleFileMarkdown: `# 招聘筛选专员
Label: "简历筛选与招聘专员",
RuleFileMarkdown: `# 简历筛选与招聘专员
## 你是谁
你负责从招聘入口整理到候选人筛选的闭环:邮件归档、候选人抽取、评分排序、跟进标记、面试安排。
@@ -194,7 +194,7 @@ func resetOfficialAccountArticle(article *officialaccountmodel.OfficialAccountAr
func buildOfficialAccountTopicCandidatesFromHotspots(form officialAccountForm, hotspots []officialaccountmodel.OfficialAccountHotspot) []officialAccountTopicCandidate {
domainLabel := officialAccountBusinessDomainLabel(form.BusinessDomain)
candidates := make([]officialAccountTopicCandidate, 0, 4)
candidates := make([]officialAccountTopicCandidate, 0, 3)
for _, item := range hotspots {
topic := deriveOfficialAccountTopic(form.Keyword, item.Title)
if topic == "" {
@@ -206,7 +206,7 @@ func buildOfficialAccountTopicCandidatesFromHotspots(form officialAccountForm, h
Reason: firstNonEmpty(item.Summary, "来自公开"+domainLabel+"资讯源,已按业务域相关性筛选。"),
Heat: firstNonEmpty(item.HeatLabel, "中高"),
})
if len(candidates) >= 4 {
if len(candidates) >= 3 {
break
}
}
@@ -273,7 +273,7 @@ func marshalOfficialAccountHotspots(hotspots []officialaccountmodel.OfficialAcco
func buildOfficialAccountHotspotPrompt(form officialAccountForm, hotspots []officialaccountmodel.OfficialAccountHotspot) string {
cfg := getOfficialAccountBusinessDomainConfig(form.BusinessDomain)
lines := []string{
fmt.Sprintf("你是公众号选题策划编辑,请基于下面的%s热点,为公众号生成 4 个值得写的热点选题。", cfg.PromptLabel),
fmt.Sprintf("你是公众号选题策划编辑,请基于下面的%s热点,为公众号生成 3 个值得写的热点选题。", cfg.PromptLabel),
"要求:",
fmt.Sprintf("1. 主题必须适合%s方向的公众号写作;", cfg.PromptLabel),
"2. 要能写成公众号文章,不是简单复述新闻;",
@@ -284,6 +284,7 @@ func buildOfficialAccountHotspotPrompt(form officialAccountForm, hotspots []offi
fmt.Sprintf("面向人群:%s", firstNonEmpty(form.Audience, "公众号读者")),
fmt.Sprintf("内容目标:%s", firstNonEmpty(form.Goal, "输出一篇可发布的公众号文章")),
fmt.Sprintf("语气:%s", firstNonEmpty(form.Tone, "专业但好懂")),
fmt.Sprintf("额外要求:%s", firstNonEmpty(form.Requirements, "无")),
"热点列表:",
}
for idx, item := range hotspots {
@@ -302,6 +303,7 @@ func buildOfficialAccountTopicEvalPrompt(form officialAccountForm, topic string,
fmt.Sprintf("选题:%s", topic),
fmt.Sprintf("业务域:%s", cfg.Label),
fmt.Sprintf("关键词:%s", firstNonEmpty(form.Keyword, cfg.DefaultKeyword)),
fmt.Sprintf("额外要求:%s", firstNonEmpty(form.Requirements, "无")),
"参考热点:",
}
for idx, item := range hotspots {
@@ -327,6 +329,7 @@ func buildOfficialAccountTopicBatchEvalPrompt(form officialAccountForm, candidat
fmt.Sprintf("关键词:%s", firstNonEmpty(form.Keyword, cfg.DefaultKeyword)),
fmt.Sprintf("面向人群:%s", firstNonEmpty(form.Audience, "公众号读者")),
fmt.Sprintf("内容目标:%s", firstNonEmpty(form.Goal, "输出一篇可发布的公众号文章")),
fmt.Sprintf("额外要求:%s", firstNonEmpty(form.Requirements, "无")),
"待评估选题:",
}
for idx, item := range candidates {
@@ -573,7 +573,7 @@ func ExportOfficialAccountDocument(c *gin.Context) {
return
}
if task.SpecialistKey != officialAccountSpecialistKey {
web.Fail(c, web.NewNotFoundError("当前事项不属于公众号专员"))
web.Fail(c, web.NewNotFoundError("当前事项不属于公众号创作专员"))
return
}
@@ -125,6 +125,11 @@ type officialAccountTopicCandidate struct {
Heat string `json:"heat"`
}
type officialAccountTitleChoice struct {
Title string `json:"title"`
Positioning string `json:"positioning"`
}
type officialAccountImagePrompt struct {
Key string `json:"key"`
Section string `json:"section"`
@@ -186,7 +191,7 @@ func CreateOfficialAccountTask(c *gin.Context) {
// 任务挂在已下线的专员上,历史记录仍要解释得通。
specialist, found := specialistDAO.GetByKey(officialAccountSpecialistKey)
if !found {
web.Fail(c, web.NewNotFoundError("公众号专员尚未配置"))
web.Fail(c, web.NewNotFoundError("公众号创作专员尚未配置"))
return
}
@@ -230,7 +235,7 @@ func GetOfficialAccountWorkflow(c *gin.Context) {
return
}
if task.SpecialistKey != officialAccountSpecialistKey {
web.Fail(c, web.NewNotFoundError("当前事项不属于公众号专员"))
web.Fail(c, web.NewNotFoundError("当前事项不属于公众号创作专员"))
return
}
respondOfficialAccountWorkflow(c, task)
@@ -243,7 +248,7 @@ func UpdateOfficialAccountTask(c *gin.Context) {
return
}
if task.SpecialistKey != officialAccountSpecialistKey {
web.Fail(c, web.NewNotFoundError("当前事项不属于公众号专员"))
web.Fail(c, web.NewNotFoundError("当前事项不属于公众号创作专员"))
return
}
@@ -393,7 +398,7 @@ func ExecuteOfficialAccountWorkflowStep(c *gin.Context) {
return
}
if task.SpecialistKey != officialAccountSpecialistKey {
web.Fail(c, web.NewNotFoundError("当前事项不属于公众号专员"))
web.Fail(c, web.NewNotFoundError("当前事项不属于公众号创作专员"))
return
}
@@ -524,7 +529,7 @@ func RegenerateOfficialAccountImage(c *gin.Context) {
return
}
if task.SpecialistKey != officialAccountSpecialistKey {
web.Fail(c, web.NewNotFoundError("当前事项不属于公众号专员"))
web.Fail(c, web.NewNotFoundError("当前事项不属于公众号创作专员"))
return
}
@@ -1072,11 +1077,15 @@ func runOfficialAccountStep(stepKey string, workflow *officialAccountWorkflowSta
}
func executeOfficialAccountTopicStep(workflow *officialAccountWorkflowState, article *officialaccountmodel.OfficialAccountArticle, user *model.User, now time.Time) officialAccountStepExecution {
hotspots, hotspotLogs, _ := ensureOfficialAccountHotspots(workflow.Form, false)
hotspots, hotspotLogs, hotspotErr := ensureOfficialAccountHotspots(workflow.Form, false)
hotspots = sortOfficialAccountHotspots(hotspots)
fallbackUsed := hotspotErr != nil || len(hotspots) == 0
candidates := buildOfficialAccountTopicCandidatesFromHotspots(workflow.Form, hotspots)
_ = tryFillOfficialAccountTopicsWithAI(workflow.Form, hotspots, user, &candidates)
evaluatedCount := evaluateOfficialAccountTopics(workflow.Form, hotspots, user, &candidates)
evaluatedCount := 0
if !fallbackUsed {
_ = tryFillOfficialAccountTopicsWithAI(workflow.Form, hotspots, user, &candidates)
evaluatedCount = evaluateOfficialAccountTopics(workflow.Form, hotspots, user, &candidates)
}
if workflow.Shared.SelectedTopic == "" && len(candidates) > 0 {
workflow.Shared.SelectedTopic = candidates[0].Topic
}
@@ -1090,20 +1099,30 @@ func executeOfficialAccountTopicStep(workflow *officialAccountWorkflowState, art
"candidates": candidates,
"selected_topic": workflow.Shared.SelectedTopic,
"hotspots": hotspots,
"fallback_used": fallbackUsed,
}
summary := fmt.Sprintf("已完成热点选题推荐,当前推荐题目为「%s」。", workflow.Shared.SelectedTopic)
logs := append([]string{
fmt.Sprintf("%s 已基于关键词「%s」生成候选选题", now.Format("15:04:05"), workflow.Form.Keyword),
fmt.Sprintf("%s 已完成 %d 个选题的热度与可写性评判", now.Format("15:04:05"), evaluatedCount),
fmt.Sprintf("%s 默认选中「%s」进入下一步", now.Format("15:04:05"), workflow.Shared.SelectedTopic),
}, hotspotLogs...)
if fallbackUsed {
summary = fmt.Sprintf("热点源暂不可用,已回退为通用选题建议;当前推荐题目为「%s」。", workflow.Shared.SelectedTopic)
output["hotspot_error"] = errorMessage(hotspotErr)
logs = append(logs,
fmt.Sprintf("%s 未获取到可用热点,已回退为通用选题模板。", now.Format("15:04:05")),
)
}
return officialAccountStepExecution{
Summary: fmt.Sprintf("已完成热点选题推荐,当前推荐题目为「%s」。", workflow.Shared.SelectedTopic),
Summary: summary,
TaskStatus: taskStatusInProgress,
ArtifactTitle: "公众号选题建议",
ArtifactType: "topics",
ArtifactStatus: artifactStatusDraft,
ArtifactContent: buildTopicArtifactContent(candidates, workflow.Shared.SelectedTopic),
Output: output,
Logs: append([]string{
fmt.Sprintf("%s 已基于关键词「%s」生成候选选题", now.Format("15:04:05"), workflow.Form.Keyword),
fmt.Sprintf("%s 已完成 %d 个选题的热度与可写性评判", now.Format("15:04:05"), evaluatedCount),
fmt.Sprintf("%s 默认选中「%s」进入下一步", now.Format("15:04:05"), workflow.Shared.SelectedTopic),
}, hotspotLogs...),
Logs: logs,
}
}
@@ -1122,7 +1141,7 @@ func executeOfficialAccountTitleStep(workflow *officialAccountWorkflowState, art
}
output := map[string]any{
"titles": titles,
"titles": buildOfficialAccountTitleChoices(titles),
"selected_title": workflow.Shared.SelectedTitle,
"selected_topic": workflow.Shared.SelectedTopic,
}
@@ -1227,12 +1246,6 @@ func buildOfficialAccountTopicCandidates(form officialAccountForm) []officialAcc
Reason: "更贴近业务落地,适合输出步骤化内容。",
Heat: "中高",
},
{
Topic: fmt.Sprintf("%s这件事,外行和内行到底差在哪?", keyword),
Angle: "认知对比",
Reason: "利于写出观点型文章,适合做公众号风格表达。",
Heat: "中",
},
}
}
@@ -1242,8 +1255,6 @@ func buildOfficialAccountTitles(form officialAccountForm, topic string) []string
fmt.Sprintf("%s最近又火了,但大多数人根本没看懂", topic),
fmt.Sprintf("别急着谈%s,先把这 3 件事看明白", keyword),
fmt.Sprintf("%s最容易踩的坑,我一次讲透", keyword),
fmt.Sprintf("为什么说%s不是不能做,而是你做错了顺序", keyword),
fmt.Sprintf("关于%s,外行最爱问的 5 个问题", keyword),
}
}
@@ -1251,11 +1262,13 @@ func buildOfficialAccountOutline(form officialAccountForm, title string) string
keyword := firstNonEmpty(form.Keyword, "这个主题")
outlineStyle := normalizeOfficialAccountOutlineStyle(form.OutlineStyle)
outlineWords := normalizeOfficialAccountOutlineWords(form.OutlineWords)
requirements := firstNonEmpty(form.Requirements, "无")
return strings.TrimSpace(fmt.Sprintf(
"# 标题\n%s\n\n> 提纲风格:%s | 目标字数:约 %d 字\n\n## 一、为什么现在值得聊%s\n- 当前背景和触发原因\n- 为什么读者现在会关心\n\n## 二、大多数人最容易误解的地方\n- 常见认知偏差 1\n- 常见认知偏差 2\n- 常见认知偏差 3\n\n## 三、真正可落地的做法\n- 第一步:先明确目标\n- 第二步:先从小范围验证\n- 第三步:沉淀方法和复用模板\n\n## 四、最后给读者的建议\n- 什么情况应该立刻开始\n- 什么情况应该先补前置条件\n- 一句话总结\n",
"# 标题\n%s\n\n> 提纲风格:%s | 目标字数:约 %d 字\n> 额外要求:%s\n\n## 一、为什么现在值得聊%s\n- 当前背景和触发原因\n- 为什么读者现在会关心\n\n## 二、大多数人最容易误解的地方\n- 常见认知偏差 1\n- 常见认知偏差 2\n- 常见认知偏差 3\n\n## 三、真正可落地的做法\n- 第一步:先明确目标\n- 第二步:先从小范围验证\n- 第三步:沉淀方法和复用模板\n\n## 四、最后给读者的建议\n- 什么情况应该立刻开始\n- 什么情况应该先补前置条件\n- 一句话总结\n",
title,
outlineStyle,
outlineWords,
requirements,
keyword,
))
}
@@ -1265,10 +1278,12 @@ func buildOfficialAccountContent(form officialAccountForm, title, outline string
audience := firstNonEmpty(form.Audience, "公众号读者")
tone := firstNonEmpty(form.Tone, "专业但好懂")
targetWords := normalizeOfficialAccountContentTargetWords(form.ContentTargetWords)
requirements := firstNonEmpty(form.Requirements, "无")
return strings.TrimSpace(fmt.Sprintf(
"# %s\n\n> 正文字数目标:约 %d 字\n\n很多%s最近都在关注“%s”,但真正把这件事讲清楚的人并不多。今天这篇文章,我想用尽量%s的方式,把这个问题讲透。\n\n## 为什么现在值得聊\n\n第一,是这个话题本身已经进入更多人的工作和决策场景;第二,是不少人已经开始行动,但做法还比较碎;第三,大家更需要的不是空泛观点,而是能拿去用的方法。\n\n## 最容易踩的坑\n\n第一个坑,是一上来就追求大而全,结果什么都想做,最后什么都落不下去。\n\n第二个坑,是只看表面热度,不看自己真实业务是否适配。\n\n第三个坑,是做完一次就结束,没有形成可复用的内容资产和方法模板。\n\n## 真正可落地的做法\n\n先明确目标:你是要做传播、做转化,还是做认知教育?目标不同,内容结构完全不同。\n\n再做小范围验证:不要一开始就上最重的版本,先验证选题、标题和读者反馈。\n\n最后沉淀模板:把好用的标题结构、提纲结构和正文风格保留下来,后面才能越做越快。\n\n## 最后想说\n\n如果你也在做%s,别急着一次把所有事情做完。先抓住一个明确问题,把它讲明白、讲透、讲到读者愿意继续往下看,这篇文章就已经成功了一半。\n\n这篇内容可以继续细化成你自己的案例版、行业版、客户版。后面只要沿着这个骨架迭代,文章生产就会越来越稳。\n",
"# %s\n\n> 正文字数目标:约 %d 字\n> 额外要求:%s\n\n很多%s最近都在关注“%s”,但真正把这件事讲清楚的人并不多。今天这篇文章,我想用尽量%s的方式,把这个问题讲透。\n\n## 为什么现在值得聊\n\n第一,是这个话题本身已经进入更多人的工作和决策场景;第二,是不少人已经开始行动,但做法还比较碎;第三,大家更需要的不是空泛观点,而是能拿去用的方法。\n\n## 最容易踩的坑\n\n第一个坑,是一上来就追求大而全,结果什么都想做,最后什么都落不下去。\n\n第二个坑,是只看表面热度,不看自己真实业务是否适配。\n\n第三个坑,是做完一次就结束,没有形成可复用的内容资产和方法模板。\n\n## 真正可落地的做法\n\n先明确目标:你是要做传播、做转化,还是做认知教育?目标不同,内容结构完全不同。\n\n再做小范围验证:不要一开始就上最重的版本,先验证选题、标题和读者反馈。\n\n最后沉淀模板:把好用的标题结构、提纲结构和正文风格保留下来,后面才能越做越快。\n\n## 最后想说\n\n如果你也在做%s,别急着一次把所有事情做完。先抓住一个明确问题,把它讲明白、讲透、讲到读者愿意继续往下看,这篇文章就已经成功了一半。\n\n这篇内容可以继续细化成你自己的案例版、行业版、客户版。后面只要沿着这个骨架迭代,文章生产就会越来越稳。\n",
firstNonEmpty(title, keyword),
targetWords,
requirements,
audience,
keyword,
tone,
@@ -1289,11 +1304,12 @@ func tryFillOfficialAccountTopicsWithAI(form officialAccountForm, hotspots []off
func tryFillOfficialAccountTitlesWithAI(form officialAccountForm, topic string, user *model.User, target *[]string) bool {
var items []string
prompt := fmt.Sprintf(
"请基于选题“%s”为公众号生成 5 个标题候选,关键词是“%s”,面向%s,语气%s。只返回 JSON 数组字符串,不要输出 markdown。",
"请基于选题“%s”为公众号生成 3 个标题候选,分别覆盖偏点击、偏稳重、偏平衡三种取向。关键词是“%s”,面向%s,语气%s,额外要求:%s。只返回 JSON 数组字符串,不要输出 markdown。",
topic,
form.Keyword,
form.Audience,
form.Tone,
firstNonEmpty(form.Requirements, "无"),
)
if !generateOfficialAccountJSON(prompt, user, &items) || len(items) == 0 {
return false
@@ -1304,24 +1320,26 @@ func tryFillOfficialAccountTitlesWithAI(form officialAccountForm, topic string,
func tryGenerateOfficialAccountOutlineWithAI(form officialAccountForm, title string, user *model.User) (string, bool) {
prompt := fmt.Sprintf(
"请基于公众号标题“%s”生成一份适合中文公众号的文章提纲,输出 markdown。要求有 4-6 个二级部分,每部分带 2-4 个要点,语气%s,受众%s,提纲风格%s,目标提纲字数约%d字。",
"请基于公众号标题“%s”生成一份适合中文公众号的文章提纲,输出 markdown。要求有 4-6 个二级部分,每部分带 2-4 个要点,语气%s,受众%s,提纲风格%s,目标提纲字数约%d字。额外要求:%s",
title,
form.Tone,
form.Audience,
normalizeOfficialAccountOutlineStyle(form.OutlineStyle),
normalizeOfficialAccountOutlineWords(form.OutlineWords),
firstNonEmpty(form.Requirements, "无"),
)
return generateOfficialAccountText(prompt, user)
}
func tryGenerateOfficialAccountContentWithAI(form officialAccountForm, title, outline string, user *model.User) (string, bool) {
prompt := fmt.Sprintf(
"请根据以下公众号文章信息输出一篇可发布的中文正文草稿。\n标题:%s\n受众:%s\n目标:%s\n语气:%s\n提纲:\n%s\n要求:约%d字,语言自然,不要编造成数据,不要输出多余解释。",
"请根据以下公众号文章信息输出一篇可发布的中文正文草稿。\n标题:%s\n受众:%s\n目标:%s\n语气:%s\n提纲:\n%s\n额外要求:%s\n要求:约%d字,语言自然,不要编造成数据,不要输出多余解释。",
title,
form.Audience,
form.Goal,
form.Tone,
outline,
firstNonEmpty(form.Requirements, "无"),
normalizeOfficialAccountContentTargetWords(form.ContentTargetWords),
)
return generateOfficialAccountText(prompt, user)
@@ -1519,8 +1537,8 @@ func buildTopicArtifactContent(candidates []officialAccountTopicCandidate, selec
func buildTitleArtifactContent(titles []string, selected string) string {
lines := []string{"# 标题候选", ""}
for idx, item := range titles {
lines = append(lines, fmt.Sprintf("%d. %s", idx+1, item))
for idx, item := range buildOfficialAccountTitleChoices(titles) {
lines = append(lines, fmt.Sprintf("%d. [%s] %s", idx+1, item.Positioning, item.Title))
}
lines = append(lines, "", fmt.Sprintf("当前默认标题:%s", selected))
return strings.Join(lines, "\n")
@@ -1531,6 +1549,9 @@ func buildOfficialAccountSourceRefs(workflow officialAccountWorkflowState, stepK
"关键词:" + firstNonEmpty(workflow.Form.Keyword, "待补充"),
"受众:" + firstNonEmpty(workflow.Form.Audience, "公众号读者"),
}
if workflow.Form.Requirements != "" {
refs = append(refs, "写作要求:"+workflow.Form.Requirements)
}
if workflow.Shared.SelectedTopic != "" {
refs = append(refs, "选题:"+workflow.Shared.SelectedTopic)
}
@@ -1543,6 +1564,22 @@ func buildOfficialAccountSourceRefs(workflow officialAccountWorkflowState, stepK
return refs
}
func buildOfficialAccountTitleChoices(titles []string) []officialAccountTitleChoice {
labels := []string{"偏点击", "偏稳重", "偏平衡"}
choices := make([]officialAccountTitleChoice, 0, len(titles))
for idx, item := range titles {
positioning := "候选"
if idx < len(labels) {
positioning = labels[idx]
}
choices = append(choices, officialAccountTitleChoice{
Title: item,
Positioning: positioning,
})
}
return choices
}
func cloneOutputMap(source map[string]any) map[string]any {
result := map[string]any{}
for key, value := range source {
@@ -4,8 +4,8 @@ import specialistcontracts "eai_agentplatform/backend/internal/specialists/contr
var Manifest = specialistcontracts.Manifest{
Key: "wechat-official-account",
Label: "公众号专员",
RuleFileMarkdown: `# 公众号专员
Label: "公众号创作专员",
RuleFileMarkdown: `# 公众号创作专员
## 你是谁
你负责公众号内容从选题到成稿:热点选题、标题、提纲、正文。
@@ -38,81 +38,9 @@ func SeedSpecialists(db *gorm.DB) error {
State: "system",
SortOrder: 0,
},
{
Key: "knowledge-operations",
Label: "知识运营专员",
Tier: "generic",
SpecialistMode: "dw",
ObjectEntryRoute: "/apps/knowledge-operations",
Summary: "对象字典、证据索引与规则同步",
WorkStatus: "4 个待整理",
RiskLabel: "1 个映射待确认",
Color: "#8e6cf2",
Stage: "证据整理",
Progress: 63,
MarketTag: "已安装",
Version: "v1.0",
ConnectorScope: "连接知识资产 / 文档系统",
PermissionScope: "知识资产读取、规则发布建议、证据索引维护",
ResourceBindings: "知识库、文档系统、规则字典、对象映射表",
InfoSources: "对象字典、规则文档、证据片段、变更记录",
BaseSkills: "对象映射、证据抽取、规则同步、发布检查",
AIAssistance: "自动识别字段冲突并给出映射建议",
GeneratedSkills: "把高频清洗与映射动作沉淀为新规则技能",
State: "active",
SortOrder: 20,
},
{
Key: "process-coordination",
Label: "流程推进专员",
Tier: "generic",
SpecialistMode: "adw",
ObjectEntryRoute: "/apps/process-coordination",
Summary: "跨部门催办、checkpoint 跟进与阻塞升级",
WorkStatus: "6 个待跟进",
RiskLabel: "2 个阻塞",
Color: "#14b8a6",
Stage: "阻塞清理",
Progress: 57,
MarketTag: "可升级",
Version: "v1.0",
ConnectorScope: "连接审批流 / 企微 / 项目事项",
PermissionScope: "事项读写、催办通知、阻塞升级、审批触发",
ResourceBindings: "审批流、项目事项、企微、日程提醒",
InfoSources: "任务看板、流程 checkpoint、阻塞日志、责任人状态",
BaseSkills: "催办升级、checkpoint 跟进、阻塞清理、责任路由",
AIAssistance: "自动判断升级路径并规划下一步协同动作",
GeneratedSkills: "把成功的跨部门推进套路沉淀为自动推进技能",
State: "active",
SortOrder: 30,
},
{
Key: "report-generation",
Label: "报告生成专员",
Tier: "generic",
SpecialistMode: "dw",
ObjectEntryRoute: "/apps/report-generation",
Summary: "日报、周报、复盘和交付说明自动生成",
WorkStatus: "3 个待输出",
RiskLabel: "0 个异常",
Color: "#ec4899",
Stage: "报告汇总",
Progress: 76,
MarketTag: "可升级",
Version: "v1.0",
ConnectorScope: "连接交付物 / 知识库 / 模板中心",
PermissionScope: "交付物读取、模板套用、报告产出",
ResourceBindings: "交付模板、知识库、任务结果、证据引用",
InfoSources: "日报数据、周进度、复盘纪要、交付清单",
BaseSkills: "日报生成、周报汇总、复盘摘要、交付说明编排",
AIAssistance: "自动抽取重点变化并生成适配受众的版本",
GeneratedSkills: "把高质量报告结构沉淀为新模板技能",
State: "active",
SortOrder: 40,
},
{
Key: "wechat-official-account",
Label: "公众号专员",
Label: "公众号创作专员",
Tier: "generic",
SpecialistMode: "dw",
ObjectEntryRoute: "/apps/wechat-official-account",
@@ -208,7 +136,7 @@ func SeedSpecialists(db *gorm.DB) error {
},
{
Key: "resume-processor",
Label: "招聘筛选专员",
Label: "简历筛选与招聘专员",
Tier: "industry",
SpecialistMode: "dw",
ObjectEntryRoute: "/apps/resume-processor",
@@ -230,30 +158,6 @@ func SeedSpecialists(db *gorm.DB) error {
State: "active",
SortOrder: 80,
},
{
Key: "presentation-briefing",
Label: "汇报专员",
Tier: "generic",
SpecialistMode: "dw",
ObjectEntryRoute: "/apps/presentation-briefing",
Summary: "经营汇报、方案汇报与培训课件统一出稿",
WorkStatus: "2 个待成稿",
RiskLabel: "1 个页数待压缩",
Color: "#4f46e5",
Stage: "演示编排",
Progress: 68,
MarketTag: "已安装",
Version: "v1.0",
ConnectorScope: "连接任务结果 / 知识库 / 模板中心",
PermissionScope: "读取材料、生成演示稿、导出讲稿备注",
ResourceBindings: "演示模板、知识库、任务产物、汇报素材",
InfoSources: "主题目标、受众、汇报材料、页数限制",
BaseSkills: "演示结构规划、页面要点提炼、讲稿备注生成、口径统一",
AIAssistance: "自动压缩材料并给出适合管理层、客户或培训场景的页面结构",
GeneratedSkills: "把高质量汇报结构沉淀为可复用的演示模板",
State: "active",
SortOrder: 100,
},
})
// 岗位说明书与技能绑定按 key 挂载(见 specialists/core.RuleFileDrafts / SkillBindings)
if err := specialistcore.ApplyRuleFiles(items); err != nil {
@@ -327,23 +231,6 @@ func SeedSpecialists(db *gorm.DB) error {
}
}
if err := deleteSpecialists(db, []string{
"training-delivery",
}); err != nil {
return err
}
if err := retireSpecialists(db, []string{
"knowledge-operations",
"process-coordination",
"report-generation",
"presentation-briefing",
"hr-email-sorter",
"logistics-fulfillment",
}); err != nil {
return err
}
specialistcore.LogRuleFileCoverage(db)
log.Println("[OK] 专员目录种子已导入")
return nil
@@ -366,20 +253,3 @@ func applySpecialistCodes(items []specialistmodel.Specialist) []specialistmodel.
}
return items
}
func deleteSpecialists(db *gorm.DB, keys []string) error {
if len(keys) == 0 {
return nil
}
return db.Where("key IN ?", keys).Delete(&specialistmodel.Specialist{}).Error
}
func retireSpecialists(db *gorm.DB, keys []string) error {
if len(keys) == 0 {
return nil
}
return db.Model(&specialistmodel.Specialist{}).
Where("key IN ?", keys).
Where("state = ?", "active").
Update("state", "retired").Error
}
@@ -66,9 +66,9 @@ func SeedDefaults() error {
{"password_min_length", "6", "密码最小长度"},
{"session_timeout_minutes", "480", "会话超时分钟数(不活动自动退出)"},
// 7.2.4 AI 配置
{"llm_base_url", "http://localhost:11434/v1", "LLM 服务地址"},
{"llm_api_key", "sk-xxx", "LLM API Key(敏感信息,建议写入 ai_secrets.json)"},
{"llm_model", "qwen2.5:7b", "LLM 模型名"},
{"llm_base_url", "http://localhost:8080/v1", "LLM 服务地址"},
{"llm_api_key", "local", "LLM API Key(本地服务可留占位值,远程服务建议写入 ai_secrets.json)"},
{"llm_model", "Qwen3.5-4B-Q4_K_M.gguf", "LLM 模型名"},
{"embed_model", "bge-m3", "Embedding 模型名"},
{"llm_max_tokens", "2048", "LLM 最大输出 Token 数"},
{"llm_temperature", "0.7", "LLM 温度参数(0.0~1.0)"},
@@ -91,6 +91,18 @@ func SeedDefaults() error {
if err := seedCoursesFromJSON(); err != nil {
return err
}
if err := seedProductsFromJSON(); err != nil {
return err
}
if err := seedCompanyIntroContent(); err != nil {
return err
}
if err := seedQuestionsFromJSON(); err != nil {
return err
}
if err := seedDefaultExamPapers(); err != nil {
return err
}
if err := seedTrainingMedia(); err != nil {
return err
}
@@ -135,6 +147,40 @@ type courseSeedFile struct {
Data []courseSeedItem `json:"data"`
}
type productSeedFile struct {
Data []productSeedItem `json:"data"`
}
type productSeedItem struct {
Code string `json:"code"`
Name string `json:"name"`
Category string `json:"category"`
Tags string `json:"tags"`
Description string `json:"description"`
Pricing string `json:"pricing"`
CommissionRecommend string `json:"commission_recommend"`
CommissionNegotiate string `json:"commission_negotiate"`
PublicCourseBonus string `json:"public_course_bonus"`
VersionRisk string `json:"version_risk"`
ReportRules string `json:"report_rules"`
Status string `json:"status"`
}
type questionSeedFile struct {
Data []questionSeedItem `json:"data"`
}
type questionSeedItem struct {
Domain string `json:"domain"`
CourseID *uint `json:"course_id"`
Type string `json:"type"`
Stem string `json:"stem"`
Options []model.QuestionOption `json:"options"`
Answer []string `json:"answer"`
Explanation string `json:"explanation"`
Status string `json:"status"`
}
type courseSeedItem struct {
Code string `json:"code"`
Name string `json:"name"`
@@ -197,6 +243,172 @@ func seedCoursesFromJSON() error {
return nil
}
func seedProductsFromJSON() error {
var count int64
DB.Model(&model.Product{}).Count(&count)
if count > 0 {
return nil
}
cfg := config.Load()
path := filepath.Join(cfg.TrainingMaterialsDir, "work", "products.json")
if _, err := os.Stat(path); err != nil {
return nil
}
data, err := os.ReadFile(path)
if err != nil {
return err
}
var payload productSeedFile
if err := json.Unmarshal(data, &payload); err != nil {
return err
}
for _, item := range payload.Data {
if item.Code == "" || item.Name == "" || item.Category == "" {
continue
}
status := item.Status
if strings.TrimSpace(status) == "" {
status = "active"
}
product := model.Product{
Code: item.Code,
Name: item.Name,
Category: item.Category,
Tags: item.Tags,
Description: item.Description,
Pricing: item.Pricing,
CommissionRecommend: item.CommissionRecommend,
CommissionNegotiate: item.CommissionNegotiate,
PublicCourseBonus: item.PublicCourseBonus,
VersionRisk: item.VersionRisk,
ReportRules: item.ReportRules,
Status: status,
}
if err := DB.Create(&product).Error; err != nil {
return err
}
}
log.Println("[OK] 产品知识种子已导入")
return nil
}
func seedCompanyIntroContent() error {
var cfg model.SystemConfig
if err := DB.Where("config_key = ?", "company_intro").First(&cfg).Error; err != nil {
return nil
}
if strings.TrimSpace(cfg.ConfigValue) != "" {
return nil
}
cfg.ConfigValue = strings.Join([]string{
"博昇,聚焦企业增长与 AI 落地的综合服务商。",
"",
"我们围绕企业从资本运作到资质认定,再到 AI 转型的完整成长链路,提供四大板块服务:",
"1. 资本咨询:股权规划、融资顾问、IPO 辅导,帮助企业走通资本路径。",
"2. 资质认定辅导:高新技术企业、专精特新、专精特新小巨人的申报陪跑。",
"3. AI 咨询与实施:AI 公开课、落地诊断、软件授权与定制开发。",
"4. 企业级 AI 工具与平台:按行业匹配知识库、办公智能体、工业/医疗 AI、内容生成与电商运营方案。",
"",
"本培训平台面向新员工入职培训与内部持续学习,统一承接公司认知、产品知识、销售方法与考试验收。",
}, "\n")
return DB.Save(&cfg).Error
}
func seedQuestionsFromJSON() error {
var count int64
DB.Model(&model.Question{}).Count(&count)
if count > 0 {
return nil
}
cfg := config.Load()
path := filepath.Join(cfg.TrainingMaterialsDir, "work", "questions.json")
if _, err := os.Stat(path); err != nil {
return nil
}
data, err := os.ReadFile(path)
if err != nil {
return err
}
var payload questionSeedFile
if err := json.Unmarshal(data, &payload); err != nil {
return err
}
for _, item := range payload.Data {
if item.Domain == "" || item.Type == "" || item.Stem == "" || len(item.Answer) == 0 {
continue
}
status := item.Status
if strings.TrimSpace(status) == "" {
status = "active"
}
question := model.Question{
Domain: item.Domain,
CourseID: item.CourseID,
Type: item.Type,
Stem: item.Stem,
Options: mustJSON(item.Options),
Answer: mustJSON(item.Answer),
Explanation: item.Explanation,
Status: status,
}
if err := DB.Create(&question).Error; err != nil {
return err
}
}
log.Println("[OK] 考试题库种子已导入")
return nil
}
func seedDefaultExamPapers() error {
var count int64
DB.Model(&model.ExamPaper{}).Count(&count)
if count > 0 {
return nil
}
papers := []model.ExamPaper{
{
Name: "内部培训综合自测",
Type: "self_test",
Domain: "company,product,sales",
QuestionCount: 20,
TotalScore: 100,
PassScore: 60,
DurationMinutes: 30,
Randomize: true,
Status: "active",
},
{
Name: "内部培训综合考试",
Type: "formal",
Domain: "company,product,sales",
QuestionCount: 30,
TotalScore: 100,
PassScore: 80,
DurationMinutes: 45,
Randomize: true,
Status: "active",
},
}
for _, paper := range papers {
if err := DB.Create(&paper).Error; err != nil {
return err
}
}
log.Println("[OK] 默认考试配置已导入")
return nil
}
type mediaSeed struct {
filename string
sourcePath string
@@ -345,8 +557,8 @@ func seedSkillDefinitions() error {
"opening_prompt": "直接告诉我目标,或者把材料贴给我,我来帮你起步。",
"starter_prompts": []string{"帮我拆解这个任务,并列出下一步", "把这段产品介绍整理成正式文案", "先帮我判断这个问题该找哪个专员或技能"},
}),
State: "active",
SortOrder: 10,
State: "active",
SortOrder: 10,
},
{
Key: "document-translate",
@@ -373,8 +585,8 @@ func seedSkillDefinitions() error {
"opening_prompt": "把原文贴给我,并说明目标语言、受众和语气要求;如果有术语表,也一起给我。",
"starter_prompts": []string{"把这段产品说明翻译成英文,保持专业语气", "把这封邮件翻译成日文,语气礼貌一点", "把这份中文文档整理成英文摘要"},
}),
State: "active",
SortOrder: 20,
State: "active",
SortOrder: 20,
},
{
Key: "copy-proofreading",
@@ -401,8 +613,8 @@ func seedSkillDefinitions() error {
"opening_prompt": "把文案贴给我,并告诉我这段内容要发给谁、希望保留什么语气。",
"starter_prompts": []string{"检查这段销售话术里的错别字和语病", "把这段产品介绍润得更正式一点", "帮我校对这封对外邮件,保留原意"},
}),
State: "active",
SortOrder: 30,
State: "active",
SortOrder: 30,
},
{
Key: "audio-transcribe",
@@ -429,8 +641,8 @@ func seedSkillDefinitions() error {
"opening_prompt": "把音频内容发给我,或者告诉我语言、场景和你最终想拿到什么结果。",
"starter_prompts": []string{"把这段会议录音转成文字,并分段整理", "先转写这段采访,再提取重点", "把语音内容整理成可发群里的纪要"},
}),
State: "active",
SortOrder: 40,
State: "active",
SortOrder: 40,
},
{
Key: "batch-extract",
@@ -457,8 +669,8 @@ func seedSkillDefinitions() error {
"opening_prompt": "告诉我想抽哪些字段、输出成什么格式、哪些情况需要标记为异常。",
"starter_prompts": []string{"从这批简历里提取姓名、岗位和年限", "把这些合同里的付款条款统一抽出来", "帮我批量提取文章标题、作者和发布时间"},
}),
State: "active",
SortOrder: 50,
State: "active",
SortOrder: 50,
},
{
Key: "contract-review",
@@ -485,8 +697,8 @@ func seedSkillDefinitions() error {
"opening_prompt": "把合同发给我,并告诉我这次重点想看哪些条款,或者你站在哪一方立场。",
"starter_prompts": []string{"审查这份采购合同的风险点", "重点看赔偿责任和终止条款", "帮我整理一版待人工确认的风险清单"},
}),
State: "active",
SortOrder: 60,
State: "active",
SortOrder: 60,
},
{
Key: "report-generation",
@@ -513,8 +725,8 @@ func seedSkillDefinitions() error {
"opening_prompt": "把材料给我,并告诉我报告类型、受众、周期和你最想突出哪些重点。",
"starter_prompts": []string{"把今天的工作记录整理成日报", "根据这些节点生成项目周报", "帮我把这次交付过程整理成复盘说明"},
}),
State: "active",
SortOrder: 70,
State: "active",
SortOrder: 70,
},
})
@@ -894,9 +1106,9 @@ func seedXAppDefinitions() error {
Color: "#4f46e5",
IconText: "P",
CoverTone: "linear-gradient(135deg, #4338ca 0%, #818cf8 100%)",
Summary: "直接进入汇报 PPT 应用,默认挂上汇报专员,快速拿到页面结构、讲稿备注和演示骨架。",
Description: "适合经营汇报、方案汇报、培训课件和项目复盘。打开就进入“汇报专员 + PPT 技能”的样板链路。",
SpecialistKey: "presentation-briefing",
Summary: "直接进入汇报 PPT 应用,快速拿到页面结构、讲稿备注和演示骨架。",
Description: "适合经营汇报、方案汇报、培训课件和项目复盘。",
SpecialistKey: "",
SkillKey: "ppt-generation",
DefaultPrompt: "帮我做一版 8 页管理层汇报 PPT",
PromptsJSON: mustJSON([]string{"帮我做一版 8 页管理层汇报 PPT", "按汇报对象输出页面大纲和讲稿备注", "把这批材料压成一版培训课件骨架"}),
@@ -13,6 +13,7 @@ import (
"eai_agentplatform/backend/internal/jsonutil"
"eai_agentplatform/backend/internal/middleware"
"eai_agentplatform/backend/internal/web"
xappcore "eai_agentplatform/backend/internal/xapps/core"
xappdefs "eai_agentplatform/backend/internal/xapps/model"
)
@@ -132,6 +133,16 @@ func validateDefinitionReq(req *definitionReq) *web.AppError {
if !jsonutil.ValidateStringArrayJSON(req.TagsJSON) {
return web.NewBadRequest("tags_json 必须是字符串数组")
}
if req.SpecialistKey != "" {
if _, found := (dal.SpecialistDAO{}).GetByKey(req.SpecialistKey); !found {
return web.NewBadRequest("specialist_key 指向的专员不存在")
}
}
if req.SkillKey != "" {
if _, found := (dal.SkillDefinitionDAO{}).GetByKey(req.SkillKey); !found {
return web.NewBadRequest("skill_key 指向的技能不存在")
}
}
return nil
}
@@ -270,6 +281,10 @@ func DeleteXAppDefinition(c *gin.Context) {
web.Fail(c, web.NewNotFoundError("应用定义不存在"))
return
}
if plan := xappcore.BuildRemovePlan(item); plan.Blocked {
web.Fail(c, web.NewConflictError("应用仍被运行时对象引用,请先处理 remove-plan 中的关联项"))
return
}
if !xAppDefinitionDAO.Delete(&item) {
web.Fail(c, web.NewBadRequest("删除应用定义失败"))
return
@@ -277,6 +292,28 @@ func DeleteXAppDefinition(c *gin.Context) {
web.OK(c, gin.H{"id": id, "deleted": true})
}
func PreviewXAppRemovePlan(c *gin.Context) {
id, ok := parseID(c, "id")
if !ok {
return
}
item, found := xAppDefinitionDAO.GetByID(id)
if !found {
web.Fail(c, web.NewNotFoundError("应用定义不存在"))
return
}
plan := xappcore.BuildRemovePlan(item)
web.OK(c, gin.H{
"id": item.ID,
"key": item.Key,
"label": item.Label,
"source": item.Source,
"state": item.State,
"is_builtin": false,
"plan": plan,
})
}
const (
maxFavoriteXAppCount = 24
maxRecentXAppCount = 8
@@ -1,11 +1,26 @@
package xappcore
import (
"eai_agentplatform/backend/internal/objectrefs"
xappmodel "eai_agentplatform/backend/internal/xapps/model"
)
type UninstallPreview struct {
DefinitionKeys []string
CatalogEntries []string
Policy SharedReferencePolicy
}
type RemovePlan struct {
Key string
Label string
DefinitionKeys []string
CatalogEntries []string
Policy SharedReferencePolicy
RuntimeRefs objectrefs.XAppRuntimeRefs
Blocked bool
}
func BuildUninstallPreview(manifest Manifest) UninstallPreview {
meta := manifest.UninstallMeta()
return UninstallPreview{
@@ -14,3 +29,21 @@ func BuildUninstallPreview(manifest Manifest) UninstallPreview {
Policy: meta.ReferencePolicy,
}
}
func BuildRemovePlan(item xappmodel.XAppDefinition) RemovePlan {
definitionKeys := []string{}
catalogEntries := []string{}
if item.Key != "" {
definitionKeys = []string{"xapp." + item.Key + ".definition"}
catalogEntries = []string{"catalog.xapp." + item.Key}
}
return RemovePlan{
Key: item.Key,
Label: item.Label,
DefinitionKeys: definitionKeys,
CatalogEntries: catalogEntries,
Policy: "",
RuntimeRefs: objectrefs.CollectXAppRuntimeRefs(item.Key),
Blocked: false,
}
}