Files
eaiadminandClaude Code 593323a934 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>
2026-09-23 09:01:28 +08:00

272 lines
7.9 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package api
import (
"encoding/json"
"errors"
"io"
"strings"
"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"
"eai_agentplatform/backend/internal/web"
)
// 项目名的长度上限。前端弹窗的计数器读的也是这个数 —— 两边必须一致,
// 否则前端数到 40 说没超,后端一剪,用户看到的名字和自己打的不一样。
const projectNameMaxLen = 40
type projectReq struct {
Name string `json:"name"`
TemplateKey string `json:"template_key"`
// Instruction 用指针:值类型分不出「没传」和「传了空串」,指令就永远清不掉 ——
// 详情页把指令删空再保存会被当成「没改」,用户看到的是白删一次。
// Pinned 同理(分不出「没传」和「传了 false」,取消置顶永远取消不掉)。
Instruction *string `json:"instruction"`
SpecialistKeys []string `json:"specialist_keys"`
SkillKeys []string `json:"skill_keys"`
ConnectorKeys []string `json:"connector_keys"`
Pinned *bool `json:"pinned"`
}
// encodeKeys 把 key 列表存成 JSON 字符串。空列表存空串而不是 "[]",
// 读的时候一眼能看出「没配」和「配了但为空」的区别不大,但空串更省。
func encodeKeys(keys []string) string {
cleaned := make([]string, 0, len(keys))
for _, key := range keys {
if trimmed := strings.TrimSpace(key); trimmed != "" {
cleaned = append(cleaned, trimmed)
}
}
if len(cleaned) == 0 {
return ""
}
b, err := json.Marshal(cleaned)
if err != nil {
return ""
}
return string(b)
}
// ListProjects 当前用户的项目,置顶在前、最近动过的靠前。
func ListProjects(c *gin.Context) {
user := middleware.CurrentUser(c)
if user == nil {
web.Fail(c, web.NewAuthError("未登录"))
return
}
web.OK(c, projectDAO.ListByOwners(specialistruntime.MyTaskOwners(user), 50))
}
// CreateProject 建一个项目。空 body 也收(跟「新建任务」一样,全走默认值),
// 但名字是必填的 —— 一个没名字的项目在列表里没法认。
func CreateProject(c *gin.Context) {
user := middleware.CurrentUser(c)
if user == nil {
web.Fail(c, web.NewAuthError("未登录"))
return
}
var req projectReq
if err := c.ShouldBindJSON(&req); err != nil && !errors.Is(err, io.EOF) {
web.Fail(c, web.NewBadRequest("请求参数错误"))
return
}
name := strings.TrimSpace(req.Name)
if name == "" {
web.Fail(c, web.NewBadRequest("项目名称不能为空"))
return
}
if len([]rune(name)) > projectNameMaxLen {
name = string([]rune(name)[:projectNameMaxLen])
}
if err := validateSpecialistKeys(req.SpecialistKeys); err != nil {
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 {
instruction = strings.TrimSpace(*req.Instruction)
}
project := model.Project{
Name: name,
Instruction: instruction,
TemplateKey: strings.TrimSpace(req.TemplateKey),
// 归属由服务端定死,不读请求里的 owner —— 否则谁都能替别人建项目。
Owner: myTaskOwnerName(user),
SpecialistKeys: encodeKeys(req.SpecialistKeys),
SkillKeys: encodeKeys(req.SkillKeys),
ConnectorKeys: encodeKeys(req.ConnectorKeys),
}
if !projectDAO.Insert(&project) {
web.Fail(c, web.NewBadRequest("创建项目失败"))
return
}
web.OK(c, project)
}
// UpdateProject 改名 / 改指令 / 置顶。只有显式传了的字段才覆盖:
// 没传 name 就别把名字清空,没传 pinned 就别把它当 false。
func UpdateProject(c *gin.Context) {
user := middleware.CurrentUser(c)
if user == nil {
web.Fail(c, web.NewAuthError("未登录"))
return
}
id, ok := parseID(c, "id")
if !ok {
return
}
project, found := projectDAO.GetByIDForOwners(id, specialistruntime.MyTaskOwners(user))
if !found {
web.Fail(c, web.NewNotFoundError("项目不存在"))
return
}
var req projectReq
if err := c.ShouldBindJSON(&req); err != nil {
web.Fail(c, web.NewBadRequest("请求参数错误"))
return
}
if name := strings.TrimSpace(req.Name); name != "" {
if len([]rune(name)) > projectNameMaxLen {
name = string([]rune(name)[:projectNameMaxLen])
}
project.Name = name
}
// 传了空串就是「清空指令」,得让它生效 —— 见 projectReq 里 Instruction 的注释。
if req.Instruction != nil {
project.Instruction = strings.TrimSpace(*req.Instruction)
}
if req.TemplateKey != "" {
project.TemplateKey = strings.TrimSpace(req.TemplateKey)
}
if req.Pinned != nil {
project.Pinned = *req.Pinned
}
// 三个对象列表:给了就用给的(空数组也算给了,表示清空),
// 没给就保持原样。用 nil 判断,跟 Pinned 是同一套规矩。
if req.SpecialistKeys != nil {
if err := validateSpecialistKeys(req.SpecialistKeys); err != nil {
web.Fail(c, web.NewNotFoundError(err.Error()))
return
}
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 {
project.ConnectorKeys = encodeKeys(req.ConnectorKeys)
}
if !projectDAO.Update(&project) {
web.Fail(c, web.NewBadRequest("更新项目失败"))
return
}
web.OK(c, project)
}
// DeleteProject 删项目。
//
// 项目里的任务**不删**,只把 project_id 置空 —— 任务是「做过的事」,
// 删一个分组不该把它一起抹掉。前端确认框里必须把这一点说明白,
// 否则用户会以为连任务一起没了。
func DeleteProject(c *gin.Context) {
user := middleware.CurrentUser(c)
if user == nil {
web.Fail(c, web.NewAuthError("未登录"))
return
}
id, ok := parseID(c, "id")
if !ok {
return
}
project, found := projectDAO.GetByIDForOwners(id, specialistruntime.MyTaskOwners(user))
if !found {
web.Fail(c, web.NewNotFoundError("项目不存在"))
return
}
if !taskRecordDAO.ClearProject(project.ID) {
web.Fail(c, web.NewBadRequest("解除任务归属失败"))
return
}
if !projectDAO.Delete(&project) {
web.Fail(c, web.NewBadRequest("删除项目失败"))
return
}
web.OK(c, gin.H{"id": id})
}
// ListProjectTasks 项目下的任务。项目必须是自己的 —— 别人的项目直接 404,
// 不区分「不存在」和「不是你的」。
func ListProjectTasks(c *gin.Context) {
user := middleware.CurrentUser(c)
if user == nil {
web.Fail(c, web.NewAuthError("未登录"))
return
}
id, ok := parseID(c, "id")
if !ok {
return
}
project, found := projectDAO.GetByIDForOwners(id, specialistruntime.MyTaskOwners(user))
if !found {
web.Fail(c, web.NewNotFoundError("项目不存在"))
return
}
web.OK(c, taskRecordDAO.ListByProject(project.ID, 100))
}
// validateSpecialistKeys 专员 key 得真实存在才让存 —— 项目卡片上要显示专员名,
// 存一个查不到的 key 进去,卡片上就会出现一行认不出来的东西。
// 工具和连接器暂时不校验:工具是前端路由表里的常量,连接器清单以后会变,
// 校验它们只会让老项目在清单变动后改不动。
func validateSpecialistKeys(keys []string) error {
for _, key := range keys {
trimmed := strings.TrimSpace(key)
if trimmed == "" {
continue
}
if _, found := specialistDAO.GetByKey(trimmed); !found {
return errors.New("专员不存在:" + trimmed)
}
}
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
}