package chat import ( "context" "log/slog" "strings" "time" "github.com/google/uuid" "github.com/yan1h/agent-coding-workflow/internal/audit" "github.com/yan1h/agent-coding-workflow/internal/infra/errs" "github.com/yan1h/agent-coding-workflow/internal/infra/llm" ) // compile-time assertion var _ ConversationService = (*conversationService)(nil) type conversationService struct { repo Repository registry *llm.Registry auditor audit.Recorder log *slog.Logger } // NewConversationService constructs a ConversationService. func NewConversationService(repo Repository, reg *llm.Registry, a audit.Recorder, log *slog.Logger) ConversationService { return &conversationService{repo: repo, registry: reg, auditor: a, log: log} } // Create validates the model and optional template, then inserts a new conversation. func (s *conversationService) Create(ctx context.Context, userID uuid.UUID, in CreateConversationInput) (*Conversation, error) { // Validate model exists and is enabled. model, err := s.repo.GetLLMModel(ctx, in.ModelID) if err != nil { return nil, err } if !model.Enabled { return nil, errs.New(errs.CodeChatModelNotFound, "model is disabled") } // Resolve system prompt: custom override > template content > empty string. var systemPrompt string if in.SystemPrompt != nil { systemPrompt = *in.SystemPrompt } else if in.TemplateID != nil { tpl, err := s.repo.GetPromptTemplate(ctx, *in.TemplateID) if err != nil { return nil, err } // Check user-scoped template ownership. if tpl.Scope == TemplateScopeUser && (tpl.OwnerID == nil || *tpl.OwnerID != userID) { return nil, errs.New(errs.CodeForbidden, "template not accessible") } systemPrompt = tpl.Content } id, err := uuid.NewV7() if err != nil { return nil, errs.Wrap(err, errs.CodeInternal, "generate conversation id") } c, err := s.repo.InsertConversation(ctx, InsertConversationParams{ ID: id, UserID: userID, ProjectID: in.ProjectID, WorkspaceID: in.WorkspaceID, IssueID: in.IssueID, ModelID: in.ModelID, SystemPrompt: systemPrompt, Title: nil, }) if err != nil { return nil, err } s.tryAudit(ctx, audit.Entry{ UserID: &userID, Action: "chat.conversation.created", TargetType: "conversation", TargetID: c.ID.String(), }) return c, nil } // Get returns a conversation and its most recent messages, enforcing ownership. // limit caps the number of returned messages (defaults to 50 when non-positive). func (s *conversationService) Get(ctx context.Context, userID, id uuid.UUID, limit int) (*Conversation, []Message, error) { c, err := s.repo.GetConversation(ctx, id) if err != nil { return nil, nil, err } if c.UserID != userID { return nil, nil, errs.New(errs.CodeForbidden, "not conversation owner") } if limit <= 0 { limit = 50 } msgs, err := s.repo.ListMessagesByConversation(ctx, id, nil, limit) if err != nil { return nil, nil, err } // Populate each message's attachments for the user-facing read path. for i := range msgs { atts, err := s.repo.ListAttachmentsForMessage(ctx, msgs[i].ID) if err != nil { return nil, nil, err } msgs[i].Attachments = atts } return c, msgs, nil } // List returns conversations for the given user, defaulting limit to 50. func (s *conversationService) List(ctx context.Context, userID uuid.UUID, f ConversationFilter) ([]Conversation, error) { if f.Limit == 0 { f.Limit = 50 } return s.repo.ListConversationsByUser(ctx, userID, f) } // UpdateTitle manually sets a conversation title, enforcing ownership. func (s *conversationService) UpdateTitle(ctx context.Context, userID, id uuid.UUID, title string) error { c, err := s.repo.GetConversation(ctx, id) if err != nil { return err } if c.UserID != userID { return errs.New(errs.CodeForbidden, "not conversation owner") } return s.repo.SetConversationTitleManual(ctx, id, &title) } // SoftDelete marks a conversation as deleted, enforcing ownership. func (s *conversationService) SoftDelete(ctx context.Context, userID, id uuid.UUID) error { c, err := s.repo.GetConversation(ctx, id) if err != nil { return err } if c.UserID != userID { return errs.New(errs.CodeForbidden, "not conversation owner") } if err := s.repo.SoftDeleteConversation(ctx, id); err != nil { return err } s.tryAudit(ctx, audit.Entry{ UserID: &userID, Action: "chat.conversation.deleted", TargetType: "conversation", TargetID: id.String(), }) return nil } // GenerateTitle is NOT on the ConversationService interface. // It is called by the streamer (T14) via type assertion on the concrete type. // It runs in a fire-and-forget goroutine; errors are silently swallowed. func (s *conversationService) GenerateTitle(ctx context.Context, conversationID uuid.UUID) { tCtx, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() titleModel, err := s.repo.GetTitleGeneratorModel(tCtx) if err != nil { s.log.Warn("GenerateTitle: no title model", "err", err) return } first, err := s.repo.GetFirstUserMessage(tCtx, conversationID) if err != nil { s.log.Warn("GenerateTitle: no first user message", "conversation_id", conversationID, "err", err) return } client, err := s.registry.GetClient(tCtx, titleModel.EndpointID) if err != nil { s.log.Warn("GenerateTitle: get client failed", "endpoint_id", titleModel.EndpointID, "err", err) return } req := llm.Request{ SystemPrompt: "用 ≤20 字概括下面对话主题,仅输出标题:", Messages: []llm.Message{{ Role: llm.RoleUser, Blocks: []llm.ContentBlock{{Type: "text", Text: first.Content}}, }}, MaxOutputTokens: 64, } ch, err := client.Stream(tCtx, titleModel.ModelID, req) if err != nil { s.log.Warn("GenerateTitle: stream failed", "err", err) return } var title string for ev := range ch { if ev.Type == llm.EventTextDelta { title += ev.Text } } title = strings.TrimSpace(title) if title == "" { return } if err := s.repo.UpdateConversationTitle(tCtx, conversationID, &title); err != nil { s.log.Warn("GenerateTitle: update title failed", "conversation_id", conversationID, "err", err) } } func (s *conversationService) tryAudit(ctx context.Context, e audit.Entry) { if err := s.auditor.Record(ctx, e); err != nil { s.log.Warn("audit record failed", "action", e.Action, "err", err) } }