package api import ( "encoding/json" "errors" "io" "strings" "github.com/gin-gonic/gin" "eai_agentplatform/backend/internal/middleware" "eai_agentplatform/backend/internal/model" specialistmodel "eai_agentplatform/backend/internal/specialists/model" specialistruntime "eai_agentplatform/backend/internal/specialists/runtime" "eai_agentplatform/backend/internal/store" "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 } var items []model.Project if err := specialistruntime.ProjectQuery(user). Order("pinned DESC, updated_at DESC, id DESC"). Limit(50). Find(&items).Error; err != nil { web.Fail(c, web.NewBadRequest("查询项目失败")) return } web.OK(c, items) } // 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 } 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 err := store.DB.Create(&project).Error; err != nil { 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 } var project model.Project if err := specialistruntime.ProjectQuery(user).Where("id = ?", id).First(&project).Error; err != nil { 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 { project.SkillKeys = encodeKeys(req.SkillKeys) } if req.ConnectorKeys != nil { project.ConnectorKeys = encodeKeys(req.ConnectorKeys) } if err := store.DB.Save(&project).Error; err != nil { 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 } var project model.Project if err := specialistruntime.ProjectQuery(user).Where("id = ?", id).First(&project).Error; err != nil { web.Fail(c, web.NewNotFoundError("项目不存在")) return } if err := store.DB.Model(&model.TaskRecord{}). Where("project_id = ?", project.ID). Update("project_id", nil).Error; err != nil { web.Fail(c, web.NewBadRequest("解除任务归属失败")) return } if err := store.DB.Delete(&project).Error; err != nil { 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 } var project model.Project if err := specialistruntime.ProjectQuery(user).Where("id = ?", id).First(&project).Error; err != nil { web.Fail(c, web.NewNotFoundError("项目不存在")) return } var items []model.TaskRecord if err := store.DB.Where("project_id = ?", project.ID). Order("updated_at DESC, id DESC"). Limit(100). Find(&items).Error; err != nil { web.Fail(c, web.NewBadRequest("查询项目任务失败")) return } web.OK(c, items) } // validateSpecialistKeys 专员 key 得真实存在才让存 —— 项目卡片上要显示专员名, // 存一个查不到的 key 进去,卡片上就会出现一行认不出来的东西。 // 工具和连接器暂时不校验:工具是前端路由表里的常量,连接器清单以后会变, // 校验它们只会让老项目在清单变动后改不动。 func validateSpecialistKeys(keys []string) error { for _, key := range keys { trimmed := strings.TrimSpace(key) if trimmed == "" { continue } var specialist specialistmodel.Specialist if err := store.DB.Where("key = ?", trimmed).First(&specialist).Error; err != nil { return errors.New("专员不存在:" + trimmed) } } return nil }