Files
agent/backend/internal/logic/projects/workspace.go

290 lines
10 KiB
Go
Raw 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 projects
import (
"errors"
"strings"
"time"
"gorm.io/gorm"
"senlinai-agent/backend/internal/models"
)
type workspaceCounts struct {
inbox int64
tasks int64
aiSessions int64
documents int64
cronPlans int64
}
// Workspace 在确认项目归属后执行首屏聚合;后续子查询只接收已授权的内部主键。
func (s *Service) Workspace(ownerID uint, projectIdentity string) (WorkspaceDTO, error) {
project, err := FindOwnedProject(ownerID, projectIdentity)
if err != nil {
return WorkspaceDTO{}, err
}
counts, err := s.workspaceCounts(project.ID)
if err != nil {
return WorkspaceDTO{}, err
}
tags, err := s.workspaceTags(project.ID)
if err != nil {
return WorkspaceDTO{}, err
}
channels, err := s.workspaceChannels(project.ID, project.Identity, counts)
if err != nil {
return WorkspaceDTO{}, err
}
inboxItems, err := s.workspaceInbox(project.ID, project.Identity)
if err != nil {
return WorkspaceDTO{}, err
}
tasks, err := s.workspaceTasks(project.ID, project.Identity)
if err != nil {
return WorkspaceDTO{}, err
}
aiSessions, err := s.workspaceAISessions(project.ID, project.Identity)
if err != nil {
return WorkspaceDTO{}, err
}
documents, err := s.workspaceDocuments(project.ID, project.Identity)
if err != nil {
return WorkspaceDTO{}, err
}
cronPlans, err := s.workspaceCronPlans(project.ID, project.Identity)
if err != nil {
return WorkspaceDTO{}, err
}
return WorkspaceDTO{
Project: WorkspaceProjectDTO{
ID: project.Identity,
Name: project.Name,
Identifier: project.Identifier,
Icon: project.Icon,
Background: project.Background,
Description: project.Description,
Initials: defaultString(project.Identifier, projectInitials(project.Name)),
UnreadCount: counts.inbox,
},
Channels: channels,
Tags: tags,
RecentSessions: aiSessions,
Inbox: inboxItems,
Tasks: tasks,
AISessions: aiSessions,
Documents: documents,
CronPlans: cronPlans,
}, nil
}
func (s *Service) workspaceCounts(projectID uint) (workspaceCounts, error) {
var counts workspaceCounts
if err := models.DBService.Model(&models.SaInboxItem{}).Where("project_id = ? AND status = ?", projectID, "open").Count(&counts.inbox).Error; err != nil {
return counts, err
}
if err := models.DBService.Model(&models.SaTask{}).Where("project_id = ? AND status <> ?", projectID, "done").Count(&counts.tasks).Error; err != nil {
return counts, err
}
if err := models.DBService.Model(&models.SaAISession{}).Where("project_id = ?", projectID).Count(&counts.aiSessions).Error; err != nil {
return counts, err
}
if err := models.DBService.Model(&models.SaDocument{}).Where("project_id = ?", projectID).Count(&counts.documents).Error; err != nil {
return counts, err
}
if err := models.DBService.Model(&models.SaCronPlan{}).Where("project_id = ?", projectID).Count(&counts.cronPlans).Error; err != nil {
return counts, err
}
return counts, nil
}
func (s *Service) workspaceTags(projectID uint) ([]WorkspaceTagDTO, error) {
var tags []models.SaTag
if err := models.DBService.Where("project_id = ?", projectID).Order("name asc").Find(&tags).Error; err != nil {
return nil, err
}
items := make([]WorkspaceTagDTO, 0, len(tags))
for _, tag := range tags {
items = append(items, WorkspaceTagDTO{ID: tag.Identity, Name: tag.Name})
}
return items, nil
}
func (s *Service) workspaceChannels(projectID uint, projectIdentity string, counts workspaceCounts) ([]WorkspaceChannelDTO, error) {
total := counts.inbox + counts.tasks + counts.aiSessions + counts.documents + counts.cronPlans
channels := []WorkspaceChannelDTO{
{ID: projectIdentity + ":overview", ProjectID: projectIdentity, Type: "overview", Title: "Overview", Icon: "home", Count: total, URL: "", SortOrder: 1},
{ID: projectIdentity + ":inbox", ProjectID: projectIdentity, Type: "inbox", Title: "Message Flow", Icon: "mail", Count: counts.inbox, URL: "", SortOrder: 2},
{ID: projectIdentity + ":tasks", ProjectID: projectIdentity, Type: "tasks", Title: "Work Plan", Icon: "list", Count: counts.tasks, URL: "", SortOrder: 3},
{ID: projectIdentity + ":ai", ProjectID: projectIdentity, Type: "ai_sessions", Title: "AI Sessions", Icon: "sparkles", Count: counts.aiSessions, URL: "", SortOrder: 4},
{ID: projectIdentity + ":documents", ProjectID: projectIdentity, Type: "documents", Title: "Documents", Icon: "file", Count: counts.documents, URL: "", SortOrder: 5},
{ID: projectIdentity + ":cron", ProjectID: projectIdentity, Type: "cron", Title: "Cron Plans", Icon: "clock", Count: counts.cronPlans, URL: "", SortOrder: 6},
}
var customChannels []models.SaProjectChannel
if err := models.DBService.Where("project_id = ?", projectID).Order("sort_order asc, id asc").Find(&customChannels).Error; err != nil {
return nil, err
}
for _, channel := range customChannels {
channels = append(channels, WorkspaceChannelDTO{
ID: channel.Identity, ProjectID: projectIdentity, Type: "custom_link", Title: channel.Title,
Icon: channel.Icon, Count: 0, URL: channel.URL, SortOrder: channel.SortOrder,
})
}
return channels, nil
}
func (s *Service) workspaceInbox(projectID uint, projectIdentity string) ([]WorkspaceInboxDTO, error) {
var records []models.SaInboxItem
if err := models.DBService.Where("project_id = ?", projectID).Order("updated_at desc, id desc").Limit(50).Find(&records).Error; err != nil {
return nil, err
}
items := make([]WorkspaceInboxDTO, 0, len(records))
for _, record := range records {
items = append(items, WorkspaceInboxDTO{
ID: record.Identity, ProjectID: projectIdentity, Source: record.SourceType, Title: record.Title,
Summary: record.Body, Status: record.Status, Time: record.UpdatedAt.UTC(),
})
}
return items, nil
}
func (s *Service) workspaceTasks(projectID uint, projectIdentity string) ([]WorkspaceTaskDTO, error) {
var tasks []models.SaTask
if err := models.DBService.Where("project_id = ?", projectID).Order("CASE WHEN status = 'done' THEN 1 ELSE 0 END asc").Order("sort_order asc, updated_at desc, id desc").Limit(50).Find(&tasks).Error; err != nil {
return nil, err
}
tags, err := workspaceTaskTags(projectID, tasks)
if err != nil {
return nil, err
}
items := make([]WorkspaceTaskDTO, 0, len(tasks))
for _, task := range tasks {
owner, err := userDisplayName(task.AssigneeID, task.CreatedBy)
if err != nil {
return nil, err
}
// 当前模型没有真实完成时间字段不能用更新时间伪装CompletedAt 保持 nil。
item := WorkspaceTaskDTO{
ID: task.Identity, ProjectID: projectIdentity, Title: task.Title, Summary: task.Description,
Completed: task.Status == "done", Owner: owner, Due: utcOptionalTime(task.DueAt), CreatedAt: task.CreatedAt.UTC(),
}
if tag, ok := tags[task.ID]; ok {
item.TagID = &tag.ID
item.Tag = tag.Name
}
items = append(items, item)
}
return items, nil
}
// workspaceTaskTags 同时限定 tag 主键和项目主键,避免损坏数据把其他项目标签带入任务响应。
func workspaceTaskTags(projectID uint, tasks []models.SaTask) (map[uint]WorkspaceTagDTO, error) {
tagIDs := make([]uint, 0, len(tasks))
for _, task := range tasks {
if task.TagID != nil {
tagIDs = append(tagIDs, *task.TagID)
}
}
result := make(map[uint]WorkspaceTagDTO)
if len(tagIDs) == 0 {
return result, nil
}
var tags []models.SaTag
if err := models.DBService.Where("project_id = ? AND id IN ?", projectID, tagIDs).Find(&tags).Error; err != nil {
return nil, err
}
byID := make(map[uint]WorkspaceTagDTO, len(tags))
for _, tag := range tags {
byID[tag.ID] = WorkspaceTagDTO{ID: tag.Identity, Name: tag.Name}
}
for _, task := range tasks {
if task.TagID != nil {
if tag, ok := byID[*task.TagID]; ok {
result[task.ID] = tag
}
}
}
return result, nil
}
func (s *Service) workspaceAISessions(projectID uint, projectIdentity string) ([]WorkspaceAISessionDTO, error) {
var sessions []models.SaAISession
if err := models.DBService.Where("project_id = ?", projectID).Order("updated_at desc, id desc").Limit(50).Find(&sessions).Error; err != nil {
return nil, err
}
items := make([]WorkspaceAISessionDTO, 0, len(sessions))
for _, session := range sessions {
items = append(items, WorkspaceAISessionDTO{
ID: session.Identity, ProjectID: projectIdentity, Title: session.Title, Summary: session.Context,
UpdatedAt: session.UpdatedAt.UTC(), References: []string{},
})
}
return items, nil
}
func (s *Service) workspaceDocuments(projectID uint, projectIdentity string) ([]WorkspaceDocumentDTO, error) {
var documents []models.SaDocument
if err := models.DBService.Where("project_id = ?", projectID).Order("updated_at desc, id desc").Limit(50).Find(&documents).Error; err != nil {
return nil, err
}
items := make([]WorkspaceDocumentDTO, 0, len(documents))
for _, document := range documents {
items = append(items, WorkspaceDocumentDTO{
ID: document.Identity, ProjectID: projectIdentity, Kind: document.Kind, Name: document.Name,
Extension: document.Extension, MimeType: document.MimeType, UpdatedAt: document.UpdatedAt.UTC(),
})
}
return items, nil
}
func (s *Service) workspaceCronPlans(projectID uint, projectIdentity string) ([]WorkspaceCronPlanDTO, error) {
var plans []models.SaCronPlan
if err := models.DBService.Where("project_id = ?", projectID).Order("enabled desc, updated_at desc, id desc").Limit(50).Find(&plans).Error; err != nil {
return nil, err
}
items := make([]WorkspaceCronPlanDTO, 0, len(plans))
for _, plan := range plans {
owner, err := userDisplayName(nil, plan.CreatedBy)
if err != nil {
return nil, err
}
items = append(items, WorkspaceCronPlanDTO{
ID: plan.Identity, ProjectID: projectIdentity, Title: plan.Title, Schedule: plan.Schedule,
NextRun: utcOptionalTime(plan.NextRunAt), Enabled: plan.Enabled,
LastResult: strings.TrimSpace(plan.LastResult), Owner: owner,
})
}
return items, nil
}
func utcOptionalTime(value *time.Time) *time.Time {
if value == nil {
return nil
}
utc := value.UTC()
return &utc
}
func userDisplayName(assigneeID *uint, createdBy uint) (string, error) {
userID := createdBy
if assigneeID != nil && *assigneeID != 0 {
userID = *assigneeID
}
var user models.SaUser
if err := models.DBService.Select("display_name").Where("id = ?", userID).First(&user).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return "未知用户", nil
}
return "", err
}
return user.DisplayName, nil
}
func defaultString(value string, fallback string) string {
if strings.TrimSpace(value) == "" {
return fallback
}
return value
}