package projects import ( "errors" "fmt" "strings" "time" "gorm.io/gorm" "senlinai-agent/backend/internal/models" ) type Service struct { } type Dashboard struct { ProjectID uint `json:"project_id"` PendingInboxCount int64 `json:"pending_inbox_count"` OpenTaskCount int64 `json:"open_task_count"` RecentNoteCount int64 `json:"recent_note_count"` RecentSessionCount int64 `json:"recent_session_count"` } type WorkbenchProject struct { ID uint `json:"id"` Name string `json:"name"` Identifier string `json:"identifier"` Icon string `json:"icon"` Background string `json:"background"` Description string `json:"description"` Initials string `json:"initials"` UnreadCount int64 `json:"unreadCount"` } type WorkbenchChannel struct { ID string `json:"id"` ProjectID uint `json:"projectID"` Type string `json:"type"` Title string `json:"title"` Icon string `json:"icon"` Count *int64 `json:"count,omitempty"` URL string `json:"url,omitempty"` SortOrder int `json:"sortOrder"` } type InboxMessage struct { ID string `json:"id"` Source string `json:"source"` Title string `json:"title"` Summary string `json:"summary"` Status string `json:"status"` Tag string `json:"tag"` Time string `json:"time"` } type WorkTask struct { ID string `json:"id"` Title string `json:"title"` Summary string `json:"summary"` Completed bool `json:"completed"` Owner string `json:"owner"` Due string `json:"due"` CreatedAt string `json:"createdAt"` CompletedAt string `json:"completedAt"` Duration string `json:"duration"` Tag string `json:"tag"` } type AISessionItem struct { ID string `json:"id"` Title string `json:"title"` Summary string `json:"summary"` UpdatedAt string `json:"updatedAt"` References []string `json:"references"` } type NoteSourceItem struct { ID string `json:"id"` Kind string `json:"kind"` Title string `json:"title"` UpdatedAt string `json:"updatedAt"` Tag string `json:"tag"` Source string `json:"source"` } type CronPlan struct { ID string `json:"id"` Title string `json:"title"` Schedule string `json:"schedule"` NextRun string `json:"nextRun"` Enabled bool `json:"enabled"` LastResult string `json:"lastResult"` Owner string `json:"owner"` } type ProjectWorkspace struct { Project WorkbenchProject `json:"project"` Channels []WorkbenchChannel `json:"channels"` Tags []string `json:"tags"` RecentSessions []AISessionItem `json:"recentSessions"` Inbox []InboxMessage `json:"inbox"` Tasks []WorkTask `json:"tasks"` AISessions []AISessionItem `json:"aiSessions"` NotesSources []NoteSourceItem `json:"notesSources"` CronPlans []CronPlan `json:"cronPlans"` } type CreateProjectInput struct { Name string Identifier string Icon string Background string Description string } type CreateTaskInput struct { Title string Description string Status string DueAt *time.Time Tag string } type UpdateTaskInput struct { Title string Description string Status string Completed bool NextProjectID uint Tag string } type CreateFileSourceInput struct { Title string FilePath string } type CreateCronPlanInput struct { Title string Schedule string Enabled bool NextRunAt *time.Time } func NewService() *Service { return &Service{} } func (s *Service) CreateProject(ownerID uint, name string, description string) (*models.SenlinAgentProject, error) { return s.CreateProjectWithInput(ownerID, CreateProjectInput{Name: name, Description: description}) } func (s *Service) CreateProjectWithInput(ownerID uint, input CreateProjectInput) (*models.SenlinAgentProject, error) { name := strings.TrimSpace(input.Name) if name == "" { return nil, errors.New("project name is required") } identifier := strings.TrimSpace(input.Identifier) if identifier == "" { identifier = projectInitials(name) } project := &models.SenlinAgentProject{ OwnerID: ownerID, Name: name, Identifier: identifier, Icon: strings.TrimSpace(input.Icon), Background: strings.TrimSpace(input.Background), Description: strings.TrimSpace(input.Description), } return project, models.DBService.Create(project).Error } func (s *Service) ListProjects(ownerID uint) ([]models.SenlinAgentProject, error) { var projects []models.SenlinAgentProject err := models.DBService.Where("owner_id = ?", ownerID).Order("updated_at desc").Find(&projects).Error return projects, err } func (s *Service) CreateTask(ownerID uint, projectID uint, input CreateTaskInput) (*models.SenlinAgentTask, error) { if err := ensureProjectOwner(ownerID, projectID); err != nil { return nil, err } title := strings.TrimSpace(input.Title) if title == "" { return nil, errors.New("task title is required") } status := strings.TrimSpace(input.Status) if status == "" { status = "open" } tagID, err := s.findOrCreateTagID(projectID, input.Tag) if err != nil { return nil, err } task := &models.SenlinAgentTask{ ProjectID: projectID, CreatedBy: ownerID, TagID: tagID, Title: title, Description: strings.TrimSpace(input.Description), Status: status, DueAt: input.DueAt, } return task, models.DBService.Create(task).Error } func (s *Service) UpdateTask(ownerID uint, projectID uint, taskID uint, input UpdateTaskInput) (*models.SenlinAgentTask, error) { if err := ensureProjectOwner(ownerID, projectID); err != nil { return nil, err } var task models.SenlinAgentTask if err := models.DBService.Where("id = ? AND project_id = ?", taskID, projectID).First(&task).Error; err != nil { return nil, err } nextProjectID := input.NextProjectID if nextProjectID == 0 { nextProjectID = task.ProjectID } if err := ensureProjectOwner(ownerID, nextProjectID); err != nil { return nil, err } title := strings.TrimSpace(input.Title) if title == "" { return nil, errors.New("task title is required") } tagID, err := s.findOrCreateTagID(nextProjectID, input.Tag) if err != nil { return nil, err } projectIdentity, err := projectIdentity(nextProjectID) if err != nil { return nil, err } tagIdentity, err := optionalTagIdentity(tagID) if err != nil { return nil, err } status := strings.TrimSpace(input.Status) if status == "" { if input.Completed { status = "done" } else { status = "open" } } task.ProjectID = nextProjectID task.ProjectIdentity = projectIdentity task.Title = title task.Description = strings.TrimSpace(input.Description) task.Status = status task.TagID = tagID task.TagIdentity = tagIdentity if err := models.DBService.Save(&task).Error; err != nil { return nil, err } return &task, nil } func (s *Service) CreateFileSource(ownerID uint, projectID uint, input CreateFileSourceInput) (*models.SenlinAgentSource, error) { if err := ensureProjectOwner(ownerID, projectID); err != nil { return nil, err } title := strings.TrimSpace(input.Title) if title == "" { return nil, errors.New("source title is required") } filePath := strings.TrimSpace(input.FilePath) if filePath == "" { return nil, errors.New("source file path is required") } source := &models.SenlinAgentSource{ ProjectID: projectID, CreatedBy: ownerID, Kind: "file", Title: title, FilePath: filePath, } return source, models.DBService.Create(source).Error } func (s *Service) CreateCronPlan(ownerID uint, projectID uint, input CreateCronPlanInput) (*models.SenlinAgentCronPlan, error) { if err := ensureProjectOwner(ownerID, projectID); err != nil { return nil, err } title := strings.TrimSpace(input.Title) if title == "" { return nil, errors.New("cron plan title is required") } schedule := strings.TrimSpace(input.Schedule) if schedule == "" { return nil, errors.New("cron schedule is required") } plan := &models.SenlinAgentCronPlan{ ProjectID: projectID, CreatedBy: ownerID, Title: title, Schedule: schedule, Enabled: input.Enabled, NextRunAt: input.NextRunAt, LastResult: "Not run yet", } return plan, models.DBService.Create(plan).Error } func (s *Service) CreateProjectTag(ownerID uint, projectID uint, name string) (*models.SenlinAgentTag, error) { if err := ensureProjectOwner(ownerID, projectID); err != nil { return nil, err } return s.CreateTag(projectID, name) } func (s *Service) CreateTag(projectID uint, name string) (*models.SenlinAgentTag, error) { name = strings.TrimSpace(name) if name == "" { return nil, errors.New("tag name is required") } var existing models.SenlinAgentTag err := models.DBService.Where("project_id = ? AND name = ?", projectID, name).First(&existing).Error if err == nil { return &existing, nil } if err != gorm.ErrRecordNotFound { return nil, err } tag := &models.SenlinAgentTag{ProjectID: projectID, Name: name} return tag, models.DBService.Create(tag).Error } func (s *Service) ListProjectTags(ownerID uint, projectID uint) ([]models.SenlinAgentTag, error) { if err := ensureProjectOwner(ownerID, projectID); err != nil { return nil, err } return s.ListTags(projectID) } func (s *Service) ListTags(projectID uint) ([]models.SenlinAgentTag, error) { var tags []models.SenlinAgentTag err := models.DBService.Where("project_id = ?", projectID).Order("name asc").Find(&tags).Error return tags, err } func (s *Service) findOrCreateTagID(projectID uint, name string) (*uint, error) { name = strings.TrimSpace(name) if name == "" { return nil, nil } tag, err := s.CreateTag(projectID, name) if err != nil { return nil, err } return &tag.ID, nil } 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 } func (s *Service) Workspace(ownerID uint, projectID uint) (ProjectWorkspace, error) { var project models.SenlinAgentProject if err := models.DBService.Where("id = ? AND owner_id = ?", projectID, ownerID).First(&project).Error; err != nil { return ProjectWorkspace{}, err } counts, err := s.workspaceCounts(projectID) if err != nil { return ProjectWorkspace{}, err } tags, err := s.workspaceTags(projectID) if err != nil { return ProjectWorkspace{}, err } channels, err := s.workspaceChannels(projectID, counts) if err != nil { return ProjectWorkspace{}, err } inboxItems, err := s.workspaceInbox(projectID) if err != nil { return ProjectWorkspace{}, err } tasks, err := s.workspaceTasks(projectID) if err != nil { return ProjectWorkspace{}, err } aiSessions, err := s.workspaceAISessions(projectID) if err != nil { return ProjectWorkspace{}, err } notesSources, err := s.workspaceNotesSources(projectID) if err != nil { return ProjectWorkspace{}, err } cronPlans, err := s.workspaceCronPlans(projectID) if err != nil { return ProjectWorkspace{}, err } return ProjectWorkspace{ Project: WorkbenchProject{ ID: project.ID, 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 ensureProjectOwner(ownerID uint, projectID uint) error { var count int64 if err := models.DBService.Model(&models.SenlinAgentProject{}).Where("id = ? AND owner_id = ?", projectID, ownerID).Count(&count).Error; err != nil { return err } if count == 0 { return errors.New("project not found") } return nil } type workspaceCounts struct { inbox int64 tasks int64 aiSessions int64 notesSources int64 cronPlans int64 } 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(¬eCount).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) ([]string, error) { var tags []models.SenlinAgentTag if err := models.DBService.Where("project_id = ?", projectID).Order("name asc").Find(&tags).Error; err != nil { return nil, err } names := []string{"all"} for _, tag := range tags { names = append(names, tag.Name) } return names, nil } func (s *Service) workspaceChannels(projectID uint, counts workspaceCounts) ([]WorkbenchChannel, error) { total := counts.inbox + counts.tasks + counts.aiSessions + counts.notesSources + counts.cronPlans channels := []WorkbenchChannel{ {ID: fmt.Sprintf("%d-overview", projectID), ProjectID: projectID, Type: "overview", Title: "Overview", Icon: "home", Count: &total, SortOrder: 1}, {ID: fmt.Sprintf("%d-inbox", projectID), ProjectID: projectID, Type: "inbox", Title: "Message Flow", Icon: "mail", Count: &counts.inbox, SortOrder: 2}, {ID: fmt.Sprintf("%d-tasks", projectID), ProjectID: projectID, Type: "tasks", Title: "Work Plan", Icon: "list", Count: &counts.tasks, SortOrder: 3}, {ID: fmt.Sprintf("%d-ai", projectID), ProjectID: projectID, Type: "ai_sessions", Title: "AI Sessions", Icon: "sparkles", Count: &counts.aiSessions, SortOrder: 4}, {ID: fmt.Sprintf("%d-notes", projectID), ProjectID: projectID, Type: "notes_sources", Title: "Notes & Sources", Icon: "file", Count: &counts.notesSources, SortOrder: 5}, {ID: fmt.Sprintf("%d-cron", projectID), ProjectID: projectID, 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, WorkbenchChannel{ ID: fmt.Sprintf("%d-custom-%d", projectID, channel.ID), ProjectID: projectID, Type: "custom_link", Title: channel.Title, Icon: channel.Icon, URL: channel.URL, SortOrder: channel.SortOrder, }) } return channels, nil } func (s *Service) workspaceInbox(projectID uint) ([]InboxMessage, error) { var items []models.SenlinAgentInboxItem if err := models.DBService.Where("project_id = ?", projectID).Order("updated_at desc, id desc").Limit(50).Find(&items).Error; err != nil { return nil, err } messages := make([]InboxMessage, 0, len(items)) for _, item := range items { messages = append(messages, InboxMessage{ ID: fmt.Sprint(item.ID), Source: item.SourceType, Title: item.Title, Summary: item.Body, Status: item.Status, Tag: "", Time: displayTime(item.UpdatedAt), }) } return messages, nil } func (s *Service) workspaceTasks(projectID uint) ([]WorkTask, 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 } tagNames, err := taskTagNames(tasks) if err != nil { return nil, err } items := make([]WorkTask, 0, len(tasks)) for _, task := range tasks { owner, err := userDisplayName(task.AssigneeID, task.CreatedBy) if err != nil { return nil, err } completed := task.Status == "done" completedAt := "" duration := "" if completed { completedAt = displayTime(task.UpdatedAt) duration = displayDuration(task.CreatedAt, task.UpdatedAt) } items = append(items, WorkTask{ ID: fmt.Sprint(task.ID), Title: task.Title, Summary: task.Description, Completed: completed, Owner: owner, Due: displayOptionalTime(task.DueAt), CreatedAt: displayTime(task.CreatedAt), CompletedAt: completedAt, Duration: duration, Tag: tagNames[task.ID], }) } return items, nil } func taskTagNames(tasks []models.SenlinAgentTask) (map[uint]string, error) { names := make(map[uint]string) tagIDs := make([]uint, 0) for _, task := range tasks { if task.TagID != nil { tagIDs = append(tagIDs, *task.TagID) } } if len(tagIDs) == 0 { return names, nil } var tags []models.SenlinAgentTag if err := models.DBService.Where("id IN ?", tagIDs).Find(&tags).Error; err != nil { return nil, err } tagByID := make(map[uint]string, len(tags)) for _, tag := range tags { tagByID[tag.ID] = tag.Name } for _, task := range tasks { if task.TagID != nil { names[task.ID] = tagByID[*task.TagID] } } return names, nil } func projectIdentity(projectID uint) (string, error) { var project models.SenlinAgentProject if err := models.DBService.Select("identity").First(&project, projectID).Error; err != nil { return "", err } return project.Identity, nil } func optionalTagIdentity(tagID *uint) (*string, error) { if tagID == nil { return nil, nil } var tag models.SenlinAgentTag if err := models.DBService.Select("identity").First(&tag, *tagID).Error; err != nil { return nil, err } return &tag.Identity, nil } func (s *Service) workspaceAISessions(projectID uint) ([]AISessionItem, 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([]AISessionItem, 0, len(sessions)) for _, session := range sessions { items = append(items, AISessionItem{ ID: fmt.Sprint(session.ID), Title: session.Title, Summary: session.Context, UpdatedAt: displayTime(session.UpdatedAt), References: []string{}, }) } return items, nil } func (s *Service) workspaceNotesSources(projectID uint) ([]NoteSourceItem, error) { var notes []models.SenlinAgentNote if err := models.DBService.Where("project_id = ?", projectID).Order("updated_at desc, id desc").Limit(50).Find(¬es).Error; err != nil { return nil, err } items := make([]NoteSourceItem, 0, len(notes)) for _, note := range notes { items = append(items, NoteSourceItem{ ID: fmt.Sprintf("note-%d", note.ID), Kind: "note", Title: note.Title, UpdatedAt: displayTime(note.UpdatedAt), Tag: "", 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, NoteSourceItem{ ID: fmt.Sprintf("%s-%d", source.Kind, source.ID), Kind: source.Kind, Title: source.Title, UpdatedAt: displayTime(source.UpdatedAt), Tag: "", Source: sourceDisplayName(source), }) } return items, nil } func (s *Service) workspaceCronPlans(projectID uint) ([]CronPlan, 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([]CronPlan, 0, len(plans)) for _, plan := range plans { owner, err := userDisplayName(nil, plan.CreatedBy) if err != nil { return nil, err } items = append(items, CronPlan{ ID: fmt.Sprint(plan.ID), Title: plan.Title, Schedule: plan.Schedule, NextRun: displayOptionalTime(plan.NextRunAt), Enabled: plan.Enabled, LastResult: defaultString(plan.LastResult, "Not run yet"), Owner: owner, }) } return items, nil } func projectInitials(name string) string { words := strings.Fields(name) if len(words) == 0 { return "P" } for i := len(words) - 1; i >= 0; i-- { word := strings.Trim(words[i], "#_-") if isShortCode(word) { return strings.ToUpper(word) } } if len(words) == 1 { runes := []rune(words[0]) if len(runes) == 1 { return strings.ToUpper(string(runes[0])) } return strings.ToUpper(string(runes[:2])) } return strings.ToUpper(string([]rune(words[0])[0]) + string([]rune(words[len(words)-1])[0])) } func isShortCode(value string) bool { runes := []rune(value) if len(runes) == 0 || len(runes) > 3 { return false } hasDigit := false for _, r := range runes { if r >= '0' && r <= '9' { hasDigit = true continue } if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') { continue } return false } return hasDigit } func displayTime(value time.Time) string { if value.IsZero() { return "" } return value.UTC().Format("2006-01-02 15:04") } func displayOptionalTime(value *time.Time) string { if value == nil { return "" } return displayTime(*value) } func displayDuration(start time.Time, end time.Time) string { if start.IsZero() || end.IsZero() || end.Before(start) { return "" } duration := end.Sub(start) days := int(duration.Hours()) / 24 if days > 0 { return fmt.Sprintf("%d 天", days) } hours := int(duration.Hours()) if hours > 0 { return fmt.Sprintf("%d 小时", hours) } minutes := int(duration.Minutes()) if minutes > 0 { return fmt.Sprintf("%d 分钟", minutes) } return "1 分钟内" } func sourceDisplayName(source models.SenlinAgentSource) string { switch source.Kind { case "file": return "Attachment" case "link": return "URL" default: return strings.Title(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 { return fmt.Sprintf("User %d", userID), nil } return user.DisplayName, nil } func defaultString(value string, fallback string) string { if strings.TrimSpace(value) == "" { return fallback } return value }