872 lines
26 KiB
Go
872 lines
26 KiB
Go
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 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, CreateProjectRequest{Name: name, Description: description})
|
||
}
|
||
|
||
func (s *Service) CreateProjectWithInput(ownerID uint, input CreateProjectRequest) (*models.SenlinAgentProject, error) {
|
||
name := strings.TrimSpace(input.Name)
|
||
if name == "" {
|
||
return nil, ErrProjectNameRequired
|
||
}
|
||
identifier := strings.TrimSpace(input.Identifier)
|
||
if identifier == "" {
|
||
identifier = projectInitials(name)
|
||
}
|
||
var count int64
|
||
if err := models.DBService.Model(&models.SenlinAgentProject{}).
|
||
Where("owner_id = ? AND identifier = ?", ownerID, identifier).
|
||
Count(&count).Error; err != nil {
|
||
return nil, err
|
||
}
|
||
if count > 0 {
|
||
return nil, ErrProjectIdentifierConflict
|
||
}
|
||
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
|
||
}
|
||
|
||
// ListProjects 只返回当前所有者的公开 DTO,不让 handler 接触或序列化数据库模型。
|
||
func (s *Service) ListProjects(ownerID uint) ([]ProjectDTO, error) {
|
||
var projects []models.SenlinAgentProject
|
||
if err := models.DBService.Where("owner_id = ?", ownerID).Order("updated_at desc").Find(&projects).Error; err != nil {
|
||
return nil, err
|
||
}
|
||
result := make([]ProjectDTO, 0, len(projects))
|
||
for _, project := range projects {
|
||
result = append(result, projectDTO(project))
|
||
}
|
||
return result, nil
|
||
}
|
||
|
||
// GetProject 使用公开 identity 和所有权边界读取单个项目。
|
||
func (s *Service) GetProject(ownerID uint, identity string) (ProjectDTO, error) {
|
||
project, err := FindOwnedProject(ownerID, identity)
|
||
if err != nil {
|
||
return ProjectDTO{}, err
|
||
}
|
||
return projectDTO(*project), nil
|
||
}
|
||
|
||
var (
|
||
ErrProjectNameRequired = errors.New("project name is required")
|
||
ErrProjectIdentifierRequired = errors.New("project identifier is required")
|
||
ErrProjectIdentifierConflict = errors.New("project identifier already exists")
|
||
)
|
||
|
||
// UpdateProject 仅更新项目设置白名单字段,查询和冲突检查均限定在当前所有者内。
|
||
func (s *Service) UpdateProject(ownerID uint, identity string, input UpdateProjectRequest) error {
|
||
project, err := FindOwnedProject(ownerID, identity)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
updates := make(map[string]any, 5)
|
||
if input.Name != nil {
|
||
name := strings.TrimSpace(*input.Name)
|
||
if name == "" {
|
||
return ErrProjectNameRequired
|
||
}
|
||
updates["name"] = name
|
||
}
|
||
if input.Identifier != nil {
|
||
identifier := strings.TrimSpace(*input.Identifier)
|
||
if identifier == "" {
|
||
return ErrProjectIdentifierRequired
|
||
}
|
||
var count int64
|
||
if err := models.DBService.Model(&models.SenlinAgentProject{}).
|
||
Where("owner_id = ? AND identifier = ? AND id <> ?", ownerID, identifier, project.ID).
|
||
Count(&count).Error; err != nil {
|
||
return err
|
||
}
|
||
if count > 0 {
|
||
return ErrProjectIdentifierConflict
|
||
}
|
||
updates["identifier"] = identifier
|
||
}
|
||
if input.Icon != nil {
|
||
updates["icon"] = strings.TrimSpace(*input.Icon)
|
||
}
|
||
if input.Background != nil {
|
||
updates["background"] = strings.TrimSpace(*input.Background)
|
||
}
|
||
if input.Description != nil {
|
||
updates["description"] = strings.TrimSpace(*input.Description)
|
||
}
|
||
if len(updates) == 0 {
|
||
return nil
|
||
}
|
||
return models.DBService.Model(project).Updates(updates).Error
|
||
}
|
||
|
||
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
|
||
}
|