2026-07-18 15:59:34 +08:00
|
|
|
|
package inbox
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
|
"errors"
|
2026-07-21 18:24:15 +08:00
|
|
|
|
"strings"
|
2026-07-18 15:59:34 +08:00
|
|
|
|
|
2026-07-21 18:24:15 +08:00
|
|
|
|
"github.com/google/uuid"
|
2026-07-18 15:59:34 +08:00
|
|
|
|
"gorm.io/gorm"
|
2026-07-21 18:24:15 +08:00
|
|
|
|
"gorm.io/gorm/clause"
|
2026-07-18 21:58:15 +08:00
|
|
|
|
"senlinai-agent/backend/internal/models"
|
2026-07-18 15:59:34 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
2026-07-21 18:24:15 +08:00
|
|
|
|
var (
|
|
|
|
|
|
ErrProjectAndUserRequired = errors.New("project and user are required")
|
|
|
|
|
|
ErrSourceTypeRequired = errors.New("source type is required")
|
|
|
|
|
|
ErrInvalidSuggestion = errors.New("invalid inbox suggestion")
|
|
|
|
|
|
ErrSuggestionSelectionRequired = errors.New("suggestion selection is required")
|
|
|
|
|
|
ErrSuggestionNotFound = errors.New("selected suggestion was not saved for inbox item")
|
|
|
|
|
|
ErrInboxAlreadyConfirmed = errors.New("inbox item already confirmed")
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-07-18 15:59:34 +08:00
|
|
|
|
type CaptureInput struct {
|
2026-07-21 18:24:15 +08:00
|
|
|
|
ProjectIdentity string
|
|
|
|
|
|
UserID uint
|
|
|
|
|
|
SourceType string
|
|
|
|
|
|
Title string
|
|
|
|
|
|
Body string
|
2026-07-18 15:59:34 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-21 18:24:15 +08:00
|
|
|
|
// Suggestion 是分析接口返回的候选草稿;ID 由服务端保存后生成,确认接口不再接受 kind/title/body。
|
2026-07-18 15:59:34 +08:00
|
|
|
|
type Suggestion struct {
|
2026-07-21 18:24:15 +08:00
|
|
|
|
ID string `json:"id"`
|
2026-07-18 15:59:34 +08:00
|
|
|
|
Kind string `json:"kind"`
|
|
|
|
|
|
Title string `json:"title"`
|
|
|
|
|
|
Body string `json:"body"`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-21 18:24:15 +08:00
|
|
|
|
type ConfirmResult struct {
|
|
|
|
|
|
CreatedCount int `json:"createdCount"`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-18 15:59:34 +08:00
|
|
|
|
type Analyzer interface {
|
2026-07-18 21:58:15 +08:00
|
|
|
|
Analyze(item models.SenlinAgentInboxItem, userID uint) ([]Suggestion, error)
|
2026-07-18 15:59:34 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type StaticAnalyzer struct {
|
|
|
|
|
|
Suggestions []Suggestion
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-18 21:58:15 +08:00
|
|
|
|
func (a StaticAnalyzer) Analyze(item models.SenlinAgentInboxItem, userID uint) ([]Suggestion, error) {
|
2026-07-21 18:24:15 +08:00
|
|
|
|
if len(a.Suggestions) > 0 {
|
|
|
|
|
|
return a.Suggestions, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
subject := suggestionSubject(item)
|
|
|
|
|
|
body := strings.TrimSpace(item.Body)
|
|
|
|
|
|
if body == "" {
|
|
|
|
|
|
body = strings.TrimSpace(item.Title)
|
|
|
|
|
|
}
|
|
|
|
|
|
// 默认分析器只把用户主动收集的内容整理成可审阅草稿;它不调用 provider,也不创建正式对象。
|
|
|
|
|
|
return []Suggestion{
|
|
|
|
|
|
{Kind: "task", Title: "跟进:" + subject, Body: body},
|
|
|
|
|
|
{Kind: "note", Title: subject + "整理笔记", Body: body},
|
|
|
|
|
|
{Kind: "source", Title: subject + "背景资料", Body: body},
|
|
|
|
|
|
}, nil
|
2026-07-18 15:59:34 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type Service struct {
|
|
|
|
|
|
analyzer Analyzer
|
2026-07-21 18:24:15 +08:00
|
|
|
|
db *gorm.DB
|
2026-07-18 15:59:34 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-21 18:24:15 +08:00
|
|
|
|
func NewService(analyzer Analyzer, databases ...*gorm.DB) *Service {
|
|
|
|
|
|
var database *gorm.DB
|
|
|
|
|
|
if len(databases) > 0 {
|
|
|
|
|
|
database = databases[0]
|
|
|
|
|
|
}
|
|
|
|
|
|
return &Service{analyzer: analyzer, db: database}
|
2026-07-18 15:59:34 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-21 18:24:15 +08:00
|
|
|
|
func (s *Service) database() *gorm.DB {
|
|
|
|
|
|
if s.db != nil {
|
|
|
|
|
|
return s.db
|
2026-07-18 15:59:34 +08:00
|
|
|
|
}
|
2026-07-21 18:24:15 +08:00
|
|
|
|
return models.DBService
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Capture 先以 owner + project identity 校验项目边界,再创建归属于当前用户的原始 Inbox 条目。
|
|
|
|
|
|
func (s *Service) Capture(input CaptureInput) (*models.SenlinAgentInboxItem, error) {
|
|
|
|
|
|
if strings.TrimSpace(input.ProjectIdentity) == "" || input.UserID == 0 {
|
|
|
|
|
|
return nil, ErrProjectAndUserRequired
|
2026-07-18 15:59:34 +08:00
|
|
|
|
}
|
2026-07-21 18:24:15 +08:00
|
|
|
|
sourceType := strings.TrimSpace(input.SourceType)
|
|
|
|
|
|
if sourceType == "" {
|
|
|
|
|
|
return nil, ErrSourceTypeRequired
|
2026-07-18 15:59:34 +08:00
|
|
|
|
}
|
2026-07-21 18:24:15 +08:00
|
|
|
|
var item models.SenlinAgentInboxItem
|
|
|
|
|
|
err := s.database().Transaction(func(tx *gorm.DB) error {
|
|
|
|
|
|
var project models.SenlinAgentProject
|
|
|
|
|
|
if err := tx.Where("owner_id = ? AND identity = ?", input.UserID, input.ProjectIdentity).First(&project).Error; err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
item = models.SenlinAgentInboxItem{
|
|
|
|
|
|
ProjectID: project.ID, ProjectIdentity: project.Identity, CreatedBy: input.UserID,
|
|
|
|
|
|
SourceType: sourceType, Title: strings.TrimSpace(input.Title), Body: strings.TrimSpace(input.Body), Status: "open",
|
|
|
|
|
|
}
|
|
|
|
|
|
return tx.Create(&item).Error
|
|
|
|
|
|
})
|
|
|
|
|
|
return &item, err
|
2026-07-18 15:59:34 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-21 18:24:15 +08:00
|
|
|
|
// Analyze 只保存候选草稿,不创建任何任务、笔记或资料;重新分析会原子替换该条目的旧草稿。
|
|
|
|
|
|
func (s *Service) Analyze(itemIdentity string, userID uint) ([]Suggestion, error) {
|
|
|
|
|
|
item, err := findOwnedInboxItem(s.database(), itemIdentity, userID, false)
|
|
|
|
|
|
if err != nil {
|
2026-07-18 15:59:34 +08:00
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
2026-07-21 18:24:15 +08:00
|
|
|
|
if item.Status != "open" {
|
|
|
|
|
|
return nil, ErrInboxAlreadyConfirmed
|
|
|
|
|
|
}
|
|
|
|
|
|
generated := []Suggestion{}
|
|
|
|
|
|
if s.analyzer != nil {
|
|
|
|
|
|
generated, err = s.analyzer.Analyze(*item, userID)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
normalized, err := normalizeSuggestions(generated)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, err
|
2026-07-18 15:59:34 +08:00
|
|
|
|
}
|
2026-07-21 18:24:15 +08:00
|
|
|
|
|
|
|
|
|
|
saved := make([]Suggestion, 0, len(normalized))
|
|
|
|
|
|
err = s.database().Transaction(func(tx *gorm.DB) error {
|
|
|
|
|
|
locked, err := findOwnedInboxItem(tx, itemIdentity, userID, true)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
if locked.Status != "open" {
|
|
|
|
|
|
return ErrInboxAlreadyConfirmed
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := tx.Where("inbox_item_id = ?", locked.ID).Delete(&models.SenlinAgentInboxSuggestion{}).Error; err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
for _, suggestion := range normalized {
|
|
|
|
|
|
draft := models.SenlinAgentInboxSuggestion{
|
|
|
|
|
|
InboxItemID: locked.ID, InboxItemIdentity: locked.Identity, CreatedBy: userID,
|
|
|
|
|
|
Kind: suggestion.Kind, Title: suggestion.Title, Body: suggestion.Body,
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := tx.Create(&draft).Error; err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
saved = append(saved, Suggestion{ID: draft.Identity, Kind: draft.Kind, Title: draft.Title, Body: draft.Body})
|
|
|
|
|
|
}
|
|
|
|
|
|
return nil
|
|
|
|
|
|
})
|
|
|
|
|
|
return saved, err
|
2026-07-18 15:59:34 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-21 18:24:15 +08:00
|
|
|
|
// Confirm 只按服务端保存的建议 identity 创建对象;全部写入和 Inbox 状态变更位于同一事务中。
|
|
|
|
|
|
func (s *Service) Confirm(itemIdentity string, userID uint, selectedSuggestionIdentities []string) (ConfirmResult, error) {
|
|
|
|
|
|
result := ConfirmResult{}
|
2026-07-21 18:42:47 +08:00
|
|
|
|
err := s.database().Transaction(func(tx *gorm.DB) error {
|
2026-07-21 18:24:15 +08:00
|
|
|
|
item, err := findOwnedInboxItem(tx, itemIdentity, userID, true)
|
|
|
|
|
|
if err != nil {
|
2026-07-18 15:59:34 +08:00
|
|
|
|
return err
|
|
|
|
|
|
}
|
2026-07-21 18:24:15 +08:00
|
|
|
|
if item.Status != "open" {
|
2026-07-21 18:42:47 +08:00
|
|
|
|
result.CreatedCount, err = countConfirmedObjects(tx, item.ID, userID)
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
selected, err := normalizeSelectedIdentities(selectedSuggestionIdentities)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return err
|
2026-07-21 18:24:15 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var saved []models.SenlinAgentInboxSuggestion
|
|
|
|
|
|
if err := tx.Where("inbox_item_id = ? AND created_by = ? AND identity IN ?", item.ID, userID, selected).Find(&saved).Error; err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
if len(saved) != len(selected) {
|
|
|
|
|
|
return ErrSuggestionNotFound
|
|
|
|
|
|
}
|
|
|
|
|
|
byIdentity := make(map[string]models.SenlinAgentInboxSuggestion, len(saved))
|
|
|
|
|
|
for _, suggestion := range saved {
|
|
|
|
|
|
byIdentity[suggestion.Identity] = suggestion
|
|
|
|
|
|
}
|
|
|
|
|
|
for _, identity := range selected {
|
|
|
|
|
|
suggestion, ok := byIdentity[identity]
|
|
|
|
|
|
if !ok {
|
|
|
|
|
|
return ErrSuggestionNotFound
|
2026-07-18 15:59:34 +08:00
|
|
|
|
}
|
2026-07-21 18:24:15 +08:00
|
|
|
|
if err := createConfirmedObject(tx, *item, suggestion); err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
result.CreatedCount++
|
2026-07-18 15:59:34 +08:00
|
|
|
|
}
|
2026-07-21 18:24:15 +08:00
|
|
|
|
return tx.Model(item).Update("status", "processed").Error
|
2026-07-18 15:59:34 +08:00
|
|
|
|
})
|
2026-07-21 18:24:15 +08:00
|
|
|
|
return result, err
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-21 18:42:47 +08:00
|
|
|
|
func countConfirmedObjects(tx *gorm.DB, inboxItemID, userID uint) (int, error) {
|
|
|
|
|
|
total := int64(0)
|
|
|
|
|
|
for _, model := range []any{&models.SenlinAgentTask{}, &models.SenlinAgentNote{}, &models.SenlinAgentSource{}} {
|
|
|
|
|
|
var count int64
|
|
|
|
|
|
if err := tx.Model(model).Where("source_inbox_item_id = ? AND created_by = ?", inboxItemID, userID).Count(&count).Error; err != nil {
|
|
|
|
|
|
return 0, err
|
|
|
|
|
|
}
|
|
|
|
|
|
total += count
|
|
|
|
|
|
}
|
|
|
|
|
|
return int(total), nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-21 18:24:15 +08:00
|
|
|
|
func findOwnedInboxItem(tx *gorm.DB, identity string, userID uint, lock bool) (*models.SenlinAgentInboxItem, error) {
|
|
|
|
|
|
var item models.SenlinAgentInboxItem
|
|
|
|
|
|
query := tx.Model(&models.SenlinAgentInboxItem{}).
|
|
|
|
|
|
Joins("JOIN senlin_agent_projects ON senlin_agent_projects.id = senlin_agent_inbox_items.project_id").
|
|
|
|
|
|
Where("senlin_agent_inbox_items.identity = ? AND senlin_agent_inbox_items.created_by = ? AND senlin_agent_projects.owner_id = ?", identity, userID, userID)
|
|
|
|
|
|
if lock {
|
|
|
|
|
|
query = query.Clauses(clause.Locking{Strength: "UPDATE"})
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := query.First(&item).Error; err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
return &item, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func normalizeSuggestions(suggestions []Suggestion) ([]Suggestion, error) {
|
|
|
|
|
|
result := make([]Suggestion, 0, len(suggestions))
|
|
|
|
|
|
for _, suggestion := range suggestions {
|
|
|
|
|
|
kind := strings.ToLower(strings.TrimSpace(suggestion.Kind))
|
|
|
|
|
|
title := strings.TrimSpace(suggestion.Title)
|
|
|
|
|
|
if title == "" || (kind != "task" && kind != "note" && kind != "source") {
|
|
|
|
|
|
return nil, ErrInvalidSuggestion
|
|
|
|
|
|
}
|
|
|
|
|
|
result = append(result, Suggestion{Kind: kind, Title: title, Body: strings.TrimSpace(suggestion.Body)})
|
|
|
|
|
|
}
|
|
|
|
|
|
return result, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func suggestionSubject(item models.SenlinAgentInboxItem) string {
|
|
|
|
|
|
value := strings.TrimSpace(item.Title)
|
|
|
|
|
|
if value == "" {
|
|
|
|
|
|
value = strings.TrimSpace(item.Body)
|
|
|
|
|
|
}
|
|
|
|
|
|
if value == "" {
|
|
|
|
|
|
return "Inbox 内容"
|
|
|
|
|
|
}
|
|
|
|
|
|
runes := []rune(value)
|
|
|
|
|
|
if len(runes) > 40 {
|
|
|
|
|
|
return string(runes[:40]) + "…"
|
|
|
|
|
|
}
|
|
|
|
|
|
return value
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func normalizeSelectedIdentities(values []string) ([]string, error) {
|
|
|
|
|
|
if len(values) == 0 {
|
|
|
|
|
|
return nil, ErrSuggestionSelectionRequired
|
|
|
|
|
|
}
|
|
|
|
|
|
seen := make(map[string]struct{}, len(values))
|
|
|
|
|
|
result := make([]string, 0, len(values))
|
|
|
|
|
|
for _, value := range values {
|
|
|
|
|
|
identity, err := uuid.Parse(strings.TrimSpace(value))
|
|
|
|
|
|
if err != nil || identity.Version() != 7 || identity.Variant() != uuid.RFC4122 {
|
|
|
|
|
|
return nil, ErrSuggestionNotFound
|
|
|
|
|
|
}
|
|
|
|
|
|
normalized := identity.String()
|
|
|
|
|
|
if _, exists := seen[normalized]; exists {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
seen[normalized] = struct{}{}
|
|
|
|
|
|
result = append(result, normalized)
|
|
|
|
|
|
}
|
|
|
|
|
|
return result, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func createConfirmedObject(tx *gorm.DB, item models.SenlinAgentInboxItem, suggestion models.SenlinAgentInboxSuggestion) error {
|
|
|
|
|
|
sourceInboxItemID := item.ID
|
|
|
|
|
|
switch suggestion.Kind {
|
|
|
|
|
|
case "task":
|
|
|
|
|
|
return tx.Create(&models.SenlinAgentTask{
|
|
|
|
|
|
ProjectID: item.ProjectID, ProjectIdentity: item.ProjectIdentity, CreatedBy: item.CreatedBy,
|
|
|
|
|
|
SourceInboxItemID: &sourceInboxItemID, Title: suggestion.Title, Description: suggestion.Body, Status: "open",
|
|
|
|
|
|
}).Error
|
|
|
|
|
|
case "note":
|
|
|
|
|
|
return tx.Create(&models.SenlinAgentNote{
|
|
|
|
|
|
ProjectID: item.ProjectID, ProjectIdentity: item.ProjectIdentity, CreatedBy: item.CreatedBy,
|
|
|
|
|
|
SourceInboxItemID: &sourceInboxItemID, Title: suggestion.Title, Markdown: suggestion.Body,
|
|
|
|
|
|
}).Error
|
|
|
|
|
|
case "source":
|
|
|
|
|
|
// 文本型 Inbox 资料不是上传文件,不构造或伪造任何本地路径。
|
|
|
|
|
|
return tx.Create(&models.SenlinAgentSource{
|
|
|
|
|
|
ProjectID: item.ProjectID, ProjectIdentity: item.ProjectIdentity, CreatedBy: item.CreatedBy,
|
|
|
|
|
|
SourceInboxItemID: &sourceInboxItemID, Kind: "text", Title: suggestion.Title, ContentText: suggestion.Body,
|
|
|
|
|
|
}).Error
|
|
|
|
|
|
default:
|
|
|
|
|
|
return ErrInvalidSuggestion
|
|
|
|
|
|
}
|
2026-07-18 15:59:34 +08:00
|
|
|
|
}
|