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:
@@ -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
|
||||
}
|
||||
|
||||
-30
@@ -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,
|
||||
}
|
||||
-33
@@ -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,
|
||||
}
|
||||
-30
@@ -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,
|
||||
}
|
||||
-30
@@ -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,
|
||||
}
|
||||
+2
-2
@@ -4,8 +4,8 @@ import specialistcontracts "eai_agentplatform/backend/internal/specialists/contr
|
||||
|
||||
var Manifest = specialistcontracts.Manifest{
|
||||
Key: "resume-processor",
|
||||
Label: "招聘筛选专员",
|
||||
RuleFileMarkdown: `# 招聘筛选专员
|
||||
Label: "简历筛选与招聘专员",
|
||||
RuleFileMarkdown: `# 简历筛选与招聘专员
|
||||
|
||||
## 你是谁
|
||||
你负责从招聘入口整理到候选人筛选的闭环:邮件归档、候选人抽取、评分排序、跟进标记、面试安排。
|
||||
|
||||
+6
-3
@@ -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 {
|
||||
|
||||
+1
-1
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+67
-30
@@ -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 {
|
||||
|
||||
+2
-2
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user