You've already forked agentic-coding-workflow
217 lines
6.3 KiB
Go
217 lines
6.3 KiB
Go
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,
|
|
RequirementID: in.RequirementID,
|
|
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)
|
|
}
|
|
}
|