2026-07-18 21:58:15 +08:00
package projects
import (
"errors"
2026-07-19 21:46:43 +08:00
"fmt"
2026-07-18 21:58:15 +08:00
"strings"
2026-07-19 21:46:43 +08:00
"time"
2026-07-18 21:58:15 +08:00
"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" `
}
2026-07-19 21:46:43 +08:00
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" `
2026-07-20 18:25:04 +08:00
CreatedAt string ` json:"createdAt" `
2026-07-19 21:46:43 +08:00
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" `
}
2026-07-18 21:58:15 +08:00
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
}
2026-07-19 21:46:43 +08:00
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 ) ,
2026-07-20 18:25:04 +08:00
CreatedAt : displayTime ( task . CreatedAt ) ,
2026-07-19 21:46:43 +08:00
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
}