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

343 lines
12 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
notesSources int64
cronPlans int64
}
// Dashboard 保留旧概览查询给尚未迁移的 handler所有统计仍严格限定项目所有者。
func (s *Service) Dashboard(ownerID uint, projectID uint) (Dashboard, error) {
if err := ensureProjectOwner(ownerID, projectID); err != nil {
return Dashboard{}, err
}
dashboard := Dashboard{ProjectID: projectID}
if err := models.DBService.Model(&models.SenlinAgentInboxItem{}).Where("project_id = ? AND status = ?", projectID, "open").Count(&dashboard.PendingInboxCount).Error; err != nil {
return dashboard, err
}
if err := models.DBService.Model(&models.SenlinAgentTask{}).Where("project_id = ? AND status <> ?", projectID, "done").Count(&dashboard.OpenTaskCount).Error; err != nil {
return dashboard, err
}
if err := models.DBService.Model(&models.SenlinAgentNote{}).Where("project_id = ?", projectID).Count(&dashboard.RecentNoteCount).Error; err != nil {
return dashboard, err
}
if err := models.DBService.Model(&models.SenlinAgentAISession{}).Where("project_id = ?", projectID).Count(&dashboard.RecentSessionCount).Error; err != nil {
return dashboard, err
}
return dashboard, nil
}
// 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
}
notesSources, err := s.workspaceNotesSources(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,
NotesSources: notesSources,
CronPlans: cronPlans,
}, nil
}
func (s *Service) workspaceCounts(projectID uint) (workspaceCounts, error) {
var counts workspaceCounts
if err := models.DBService.Model(&models.SenlinAgentInboxItem{}).Where("project_id = ? AND status = ?", projectID, "open").Count(&counts.inbox).Error; err != nil {
return counts, err
}
if err := models.DBService.Model(&models.SenlinAgentTask{}).Where("project_id = ? AND status <> ?", projectID, "done").Count(&counts.tasks).Error; err != nil {
return counts, err
}
if err := models.DBService.Model(&models.SenlinAgentAISession{}).Where("project_id = ?", projectID).Count(&counts.aiSessions).Error; err != nil {
return counts, err
}
var noteCount int64
if err := models.DBService.Model(&models.SenlinAgentNote{}).Where("project_id = ?", projectID).Count(&noteCount).Error; err != nil {
return counts, err
}
var sourceCount int64
if err := models.DBService.Model(&models.SenlinAgentSource{}).Where("project_id = ?", projectID).Count(&sourceCount).Error; err != nil {
return counts, err
}
counts.notesSources = noteCount + sourceCount
if err := models.DBService.Model(&models.SenlinAgentCronPlan{}).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.SenlinAgentTag
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.notesSources + counts.cronPlans
channels := []WorkspaceChannelDTO{
{ID: projectIdentity + ":overview", ProjectID: projectIdentity, Type: "overview", Title: "Overview", Icon: "home", Count: &total, SortOrder: 1},
{ID: projectIdentity + ":inbox", ProjectID: projectIdentity, Type: "inbox", Title: "Message Flow", Icon: "mail", Count: &counts.inbox, SortOrder: 2},
{ID: projectIdentity + ":tasks", ProjectID: projectIdentity, Type: "tasks", Title: "Work Plan", Icon: "list", Count: &counts.tasks, SortOrder: 3},
{ID: projectIdentity + ":ai", ProjectID: projectIdentity, Type: "ai_sessions", Title: "AI Sessions", Icon: "sparkles", Count: &counts.aiSessions, SortOrder: 4},
{ID: projectIdentity + ":notes", ProjectID: projectIdentity, Type: "notes_sources", Title: "Notes & Sources", Icon: "file", Count: &counts.notesSources, SortOrder: 5},
{ID: projectIdentity + ":cron", ProjectID: projectIdentity, Type: "cron", Title: "Cron Plans", Icon: "clock", Count: &counts.cronPlans, SortOrder: 6},
}
var customChannels []models.SenlinAgentProjectChannel
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, URL: channel.URL, SortOrder: channel.SortOrder,
})
}
return channels, nil
}
func (s *Service) workspaceInbox(projectID uint, projectIdentity string) ([]WorkspaceInboxDTO, error) {
var records []models.SenlinAgentInboxItem
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.SenlinAgentTask
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
}
completed := task.Status == "done"
var completedAt *time.Time
if completed {
completedAt = utcOptionalTime(&task.UpdatedAt)
}
item := WorkspaceTaskDTO{
ID: task.Identity, ProjectID: projectIdentity, Title: task.Title, Summary: task.Description,
Completed: completed, Owner: owner, Due: utcOptionalTime(task.DueAt), CreatedAt: task.CreatedAt.UTC(), CompletedAt: completedAt,
}
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.SenlinAgentTask) (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.SenlinAgentTag
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.SenlinAgentAISession
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) workspaceNotesSources(projectID uint, projectIdentity string) ([]WorkspaceNoteSourceDTO, error) {
var notes []models.SenlinAgentNote
if err := models.DBService.Where("project_id = ?", projectID).Order("updated_at desc, id desc").Limit(50).Find(&notes).Error; err != nil {
return nil, err
}
items := make([]WorkspaceNoteSourceDTO, 0, len(notes))
for _, note := range notes {
items = append(items, WorkspaceNoteSourceDTO{
ID: note.Identity, ProjectID: projectIdentity, Kind: "note", Title: note.Title,
UpdatedAt: note.UpdatedAt.UTC(), Source: "Markdown",
})
}
var sources []models.SenlinAgentSource
if err := models.DBService.Where("project_id = ?", projectID).Order("updated_at desc, id desc").Limit(50).Find(&sources).Error; err != nil {
return nil, err
}
for _, source := range sources {
items = append(items, WorkspaceNoteSourceDTO{
ID: source.Identity, ProjectID: projectIdentity, Kind: source.Kind, Title: source.Title,
UpdatedAt: source.UpdatedAt.UTC(), Source: sourceDisplayName(source),
})
}
return items, nil
}
func (s *Service) workspaceCronPlans(projectID uint, projectIdentity string) ([]WorkspaceCronPlanDTO, error) {
var plans []models.SenlinAgentCronPlan
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: defaultString(plan.LastResult, "Not run yet"), Owner: owner,
})
}
return items, nil
}
func utcOptionalTime(value *time.Time) *time.Time {
if value == nil {
return nil
}
utc := value.UTC()
return &utc
}
func sourceDisplayName(source models.SenlinAgentSource) string {
switch source.Kind {
case "file":
return "Attachment"
case "link":
return "URL"
default:
return source.Kind
}
}
func userDisplayName(assigneeID *uint, createdBy uint) (string, error) {
userID := createdBy
if assigneeID != nil && *assigneeID != 0 {
userID = *assigneeID
}
var user models.SenlinAgentUser
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
}