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

491 lines
16 KiB
Go

package projects
import (
"errors"
"fmt"
"strings"
"time"
"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"`
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"`
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"`
}
func NewService() *Service {
return &Service{}
}
func (s *Service) CreateProject(ownerID uint, name string, description string) (*models.SenlinAgentProject, error) {
name = strings.TrimSpace(name)
if name == "" {
return nil, errors.New("project name is required")
}
project := &models.SenlinAgentProject{OwnerID: ownerID, Name: name, Description: 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) CreateTag(projectID uint, name string) (*models.SenlinAgentTag, error) {
name = strings.TrimSpace(name)
if name == "" {
return nil, errors.New("tag name is required")
}
tag := &models.SenlinAgentTag{ProjectID: projectID, Name: name}
return tag, models.DBService.Create(tag).Error
}
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) Dashboard(ownerID uint, projectID uint) (Dashboard, error) {
var project models.SenlinAgentProject
if err := models.DBService.Where("id = ? AND owner_id = ?", projectID, ownerID).First(&project).Error; 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,
Description: project.Description,
Initials: projectInitials(project.Name),
UnreadCount: counts.inbox,
},
Channels: channels,
Tags: tags,
RecentSessions: aiSessions,
Inbox: inboxItems,
Tasks: tasks,
AISessions: aiSessions,
NotesSources: notesSources,
CronPlans: cronPlans,
}, 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(&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) ([]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
}
items := make([]WorkTask, 0, len(tasks))
for _, task := range tasks {
owner, err := userDisplayName(task.AssigneeID, task.CreatedBy)
if err != nil {
return nil, err
}
items = append(items, WorkTask{
ID: fmt.Sprint(task.ID),
Title: task.Title,
Summary: task.Description,
Completed: task.Status == "done",
Owner: owner,
Due: displayOptionalTime(task.DueAt),
Tag: "",
})
}
return items, 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(&notes).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 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
}