Files
2026-06-22 08:55:57 +08:00

602 lines
16 KiB
Go

package chat
import (
"context"
"io"
"log/slog"
"strings"
"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"
"github.com/yan1h/agent-coding-workflow/internal/infra/storage"
)
// compile-time assertion
var _ MessageService = (*messageService)(nil)
// MessageServiceConfig carries tunable limits for messageService.
type MessageServiceConfig struct {
MimeWhitelist []string
MaxFileBytes int64
MaxMsgBytes int64
SafetyPct float64
}
type messageService struct {
repo Repository
storage storage.Storage
hub StreamerHub
auditor audit.Recorder
log *slog.Logger
mimeWhite map[string]bool
maxFile int64
maxMsg int64
safetyPct float64
briefReader ContextReader // 可为 nil;非 nil 时 composeHistory 注入 FK 上下文
}
// NewMessageService constructs a MessageService. briefReader 可为 nil:nil 时
// composeHistory 完全保留旧行为(仅用 conv.SystemPrompt),不做 FK 上下文注入。
func NewMessageService(
repo Repository,
st storage.Storage,
hub StreamerHub,
auditor audit.Recorder,
log *slog.Logger,
cfg MessageServiceConfig,
briefReader ContextReader,
) MessageService {
mimeWhite := make(map[string]bool, len(cfg.MimeWhitelist))
for _, m := range cfg.MimeWhitelist {
mimeWhite[m] = true
}
return &messageService{
repo: repo,
storage: st,
hub: hub,
auditor: auditor,
log: log,
mimeWhite: mimeWhite,
maxFile: cfg.MaxFileBytes,
maxMsg: cfg.MaxMsgBytes,
safetyPct: cfg.SafetyPct,
briefReader: briefReader,
}
}
// Send persists a user message and a pending assistant message in one tx,
// then kicks off the streamer hub. Returns both message IDs on success.
func (s *messageService) Send(
ctx context.Context,
userID, conversationID uuid.UUID,
content string,
attachmentIDs []uuid.UUID,
) (userMsgID, assistantMsgID int64, err error) {
conv, err := s.repo.GetConversation(ctx, conversationID)
if err != nil {
return 0, 0, err
}
if conv.UserID != userID {
return 0, 0, errs.New(errs.CodeForbidden, "not conversation owner")
}
model, err := s.repo.GetLLMModel(ctx, conv.ModelID)
if err != nil {
return 0, 0, err
}
endpoint, err := s.repo.GetLLMEndpoint(ctx, model.EndpointID)
if err != nil {
return 0, 0, err
}
// Resolve attachments and validate ownership/size/mime/capability.
var atts []Attachment
if len(attachmentIDs) > 0 {
atts, err = s.repo.ListOrphanAttachmentsForUser(ctx, userID, attachmentIDs)
if err != nil {
return 0, 0, err
}
if len(atts) != len(attachmentIDs) {
return 0, 0, errs.New(errs.CodeChatAttachmentTooLarge, "attachment ownership mismatch")
}
var totalSize int64
for _, a := range atts {
if s.maxFile > 0 && a.SizeBytes > s.maxFile {
return 0, 0, errs.New(errs.CodeChatAttachmentTooLarge, "file exceeds per-file size limit")
}
totalSize += a.SizeBytes
if !s.mimeAllowed(a.MimeType) {
return 0, 0, errs.New(errs.CodeChatAttachmentUnsupportedMime, "unsupported mime type: "+a.MimeType)
}
if !capabilityAllows(model.Capabilities, a.MimeType) {
return 0, 0, errs.New(errs.CodeChatModelCapabilityMismatch, "model does not support mime type: "+a.MimeType)
}
}
if s.maxMsg > 0 && totalSize > s.maxMsg {
return 0, 0, errs.New(errs.CodeChatAttachmentTooLarge, "total attachment size exceeds limit")
}
}
// Open transaction: persist user message + pending assistant message atomically.
repo, tx, err := s.repo.WithTx(ctx)
if err != nil {
return 0, 0, err
}
committed := false
defer func() {
if !committed {
_ = tx.Rollback(ctx)
}
}()
userMsg, err := repo.InsertMessage(ctx, conversationID, RoleUser, content, StatusOK)
if err != nil {
return 0, 0, err
}
if len(atts) > 0 {
ids := make([]uuid.UUID, len(atts))
for i, a := range atts {
ids[i] = a.ID
}
if err := repo.AttachToMessage(ctx, ids, userMsg.ID); err != nil {
return 0, 0, err
}
}
asstMsg, err := repo.InsertMessage(ctx, conversationID, RoleAssistant, "", StatusPending)
if err != nil {
return 0, 0, err
}
if err := tx.Commit(ctx); err != nil {
return 0, 0, err
}
committed = true
// Audit after commit so it never rolls back with the tx.
s.tryAudit(ctx, audit.Entry{
UserID: &userID,
Action: "chat.message.sent",
TargetType: "message",
TargetID: int64ToString(userMsg.ID),
Metadata: map[string]any{
"conversation_id": conversationID.String(),
"attachment_count": len(atts),
},
})
req, err := s.composeHistory(ctx, conv, model)
if err != nil {
e := err.Error()
_ = s.repo.UpdateMessageError(ctx, asstMsg.ID, &e)
return userMsg.ID, asstMsg.ID, err
}
params := StreamParams{
UserID: userID,
ConversationID: conversationID,
EndpointID: endpoint.ID,
ModelID: model.ID,
AssistantMessageID: asstMsg.ID,
Request: req,
ModelMeta: *model,
}
if err := s.hub.Start(ctx, params); err != nil {
e := err.Error()
_ = s.repo.UpdateMessageError(ctx, asstMsg.ID, &e)
return userMsg.ID, asstMsg.ID, err
}
return userMsg.ID, asstMsg.ID, nil
}
// Stream replays message history for the conversation and tails any in-flight
// pending message via hub.Subscribe.
func (s *messageService) Stream(
ctx context.Context,
userID, conversationID uuid.UUID,
sinceMessageID int64,
) (<-chan SSEEvent, error) {
conv, err := s.repo.GetConversation(ctx, conversationID)
if err != nil {
return nil, err
}
if conv.UserID != userID {
return nil, errs.New(errs.CodeForbidden, "not conversation owner")
}
out := make(chan SSEEvent, 16)
go func() {
defer close(out)
all, err := s.repo.ListMessagesAscending(ctx, conversationID)
if err != nil {
out <- SSEEvent{Event: "error", Data: map[string]string{"code": string(errs.CodeInternal), "message": err.Error()}}
return
}
for _, m := range all {
if m.ID <= sinceMessageID {
continue
}
// Emit message_meta for every qualifying message.
out <- SSEEvent{
Event: "message_meta",
Data: map[string]any{
"id": m.ID,
"role": string(m.Role),
"status": string(m.Status),
},
}
switch m.Status {
case StatusOK:
if m.Content != "" {
out <- SSEEvent{Event: "text_delta", Data: map[string]string{"text": m.Content}}
}
if m.Thinking != nil && *m.Thinking != "" {
out <- SSEEvent{Event: "thinking_delta", Data: map[string]string{"text": *m.Thinking}}
}
out <- SSEEvent{Event: "done", Data: map[string]string{"finish_reason": "stop"}}
case StatusError:
out <- SSEEvent{Event: "error", Data: map[string]string{
"code": string(errs.CodeLLMUpstream),
"message": derefString(m.ErrorMessage),
}}
case StatusCancelled:
out <- SSEEvent{Event: "error", Data: map[string]string{"code": "cancelled"}}
case StatusPending:
sub, ok := s.hub.Subscribe(m.ID)
if !ok {
e := "server restart"
_ = s.repo.UpdateMessageError(ctx, m.ID, &e)
out <- SSEEvent{Event: "error", Data: map[string]string{"code": "server_restart", "message": e}}
return
}
nextEventID := int64(0)
for {
events, closed, err := sub.Next(ctx, nextEventID)
if err != nil {
return
}
for _, ev := range events {
out <- ev
nextEventID = ev.ID + 1
}
if closed {
return
}
}
}
}
}()
return out, nil
}
// Cancel signals the streamer hub to stop generating for the given pending message.
func (s *messageService) Cancel(ctx context.Context, userID uuid.UUID, messageID int64) error {
m, err := s.repo.GetMessage(ctx, messageID)
if err != nil {
return err
}
if m.Status != StatusPending {
return errs.New(errs.CodeChatMessageNotPending, "message is not pending")
}
conv, err := s.repo.GetConversation(ctx, m.ConversationID)
if err != nil {
return err
}
if conv.UserID != userID {
return errs.New(errs.CodeForbidden, "not conversation owner")
}
s.hub.Cancel(messageID)
return nil
}
// Retry deletes the failed/cancelled assistant message, inserts a fresh pending
// one, and restarts the streamer.
func (s *messageService) Retry(ctx context.Context, userID uuid.UUID, messageID int64) (int64, uuid.UUID, error) {
old, err := s.repo.GetMessage(ctx, messageID)
if err != nil {
return 0, uuid.Nil, err
}
if old.Status != StatusError && old.Status != StatusCancelled {
return 0, uuid.Nil, errs.New(errs.CodeChatMessageNotPending, "message is not retriable")
}
conv, err := s.repo.GetConversation(ctx, old.ConversationID)
if err != nil {
return 0, uuid.Nil, err
}
if conv.UserID != userID {
return 0, uuid.Nil, errs.New(errs.CodeForbidden, "not conversation owner")
}
repo, tx, err := s.repo.WithTx(ctx)
if err != nil {
return 0, uuid.Nil, err
}
committed := false
defer func() {
if !committed {
_ = tx.Rollback(ctx)
}
}()
if err := repo.DeleteMessage(ctx, messageID); err != nil {
return 0, uuid.Nil, err
}
asstMsg, err := repo.InsertMessage(ctx, conv.ID, RoleAssistant, "", StatusPending)
if err != nil {
return 0, uuid.Nil, err
}
if err := tx.Commit(ctx); err != nil {
return 0, uuid.Nil, err
}
committed = true
model, err := s.repo.GetLLMModel(ctx, conv.ModelID)
if err != nil {
e := err.Error()
_ = s.repo.UpdateMessageError(ctx, asstMsg.ID, &e)
return asstMsg.ID, conv.ID, err
}
endpoint, err := s.repo.GetLLMEndpoint(ctx, model.EndpointID)
if err != nil {
e := err.Error()
_ = s.repo.UpdateMessageError(ctx, asstMsg.ID, &e)
return asstMsg.ID, conv.ID, err
}
req, err := s.composeHistory(ctx, conv, model)
if err != nil {
e := err.Error()
_ = s.repo.UpdateMessageError(ctx, asstMsg.ID, &e)
return asstMsg.ID, conv.ID, err
}
params := StreamParams{
UserID: userID,
ConversationID: conv.ID,
EndpointID: endpoint.ID,
ModelID: model.ID,
AssistantMessageID: asstMsg.ID,
Request: req,
ModelMeta: *model,
}
if err := s.hub.Start(ctx, params); err != nil {
e := err.Error()
_ = s.repo.UpdateMessageError(ctx, asstMsg.ID, &e)
return asstMsg.ID, conv.ID, err
}
// Audit after successful hub start.
s.tryAudit(ctx, audit.Entry{
UserID: &userID,
Action: "chat.message.retried",
TargetType: "message",
TargetID: int64ToString(asstMsg.ID),
Metadata: map[string]any{
"conversation_id": conv.ID.String(),
"old_message_id": messageID,
},
})
return asstMsg.ID, conv.ID, nil
}
// ListMessages returns paginated messages for a conversation, enforcing ownership.
func (s *messageService) ListMessages(
ctx context.Context,
userID, conversationID uuid.UUID,
beforeID *int64,
limit int,
) ([]Message, error) {
conv, err := s.repo.GetConversation(ctx, conversationID)
if err != nil {
return nil, err
}
if conv.UserID != userID {
return nil, errs.New(errs.CodeForbidden, "not conversation owner")
}
if limit == 0 || limit > 200 {
limit = 50
}
msgs, err := s.repo.ListMessagesByConversation(ctx, conversationID, beforeID, limit)
if err != nil {
return 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, err
}
msgs[i].Attachments = atts
}
return msgs, nil
}
// composeHistory loads all OK/Pending messages for the conversation, fetches
// attachment blobs from storage, truncates by token budget, and returns an
// llm.Request ready to send.
func (s *messageService) composeHistory(ctx context.Context, conv *Conversation, model *LLMModel) (llm.Request, error) {
all, err := s.repo.ListMessagesAscending(ctx, conv.ID)
if err != nil {
return llm.Request{}, err
}
var msgs []llm.Message
for _, m := range all {
if m.Status != StatusOK && m.Status != StatusPending {
continue
}
msg := llm.Message{Role: llm.Role(m.Role)}
if m.Content != "" {
msg.Blocks = append(msg.Blocks, llm.ContentBlock{Type: "text", Text: m.Content})
}
// Load attachments from storage.
atts, err := s.repo.ListAttachmentsForMessage(ctx, m.ID)
if err != nil {
return llm.Request{}, err
}
for _, a := range atts {
var blockType string
switch {
case strings.HasPrefix(a.MimeType, "image/"):
blockType = "image"
case a.MimeType == "application/pdf":
blockType = "document"
default:
continue // skip unsupported types (capability check gates at Send time)
}
rc, err := s.storage.Get(ctx, a.StoragePath)
if err != nil {
return llm.Request{}, err
}
data, readErr := io.ReadAll(rc)
rc.Close()
if readErr != nil {
return llm.Request{}, readErr
}
msg.Blocks = append(msg.Blocks, llm.ContentBlock{
Type: blockType,
MimeType: a.MimeType,
Data: data,
})
}
// Only append if there's content or attachments to send.
if len(msg.Blocks) > 0 {
msgs = append(msgs, msg)
}
}
// Resolve the effective system prompt by merging conv.SystemPrompt with the
// FK-derived context brief (requirement/issue/project mounts). The brief is
// pre-bounded by the composer, so it cannot starve message history to zero
// (truncateByTokens still reserves the remaining budget for messages).
effectiveSystemPrompt := s.effectiveSystemPrompt(ctx, conv)
// Compute token budget: context window minus max output tokens, then apply safety margin.
budget := model.ContextWindow - model.MaxOutputTokens
budget = int(float64(budget) * (1 - s.safetyPct))
msgs = truncateByTokens(msgs, effectiveSystemPrompt, budget)
return llm.Request{
SystemPrompt: effectiveSystemPrompt,
Messages: msgs,
MaxOutputTokens: model.MaxOutputTokens,
Reasoning: model.Capabilities.Reasoning,
}, nil
}
// effectiveSystemPrompt 把会话静态 system prompt 与 FK 派生上下文简报合并。
// briefReader 为 nil、会话无任何 FK 挂载、或简报为空时,直接返回 conv.SystemPrompt
// (与旧行为完全一致)。合并是追加式的(FK 简报置于静态 prompt 之后),便于
// 让旧会话(曾把角色 prompt 烘焙进 SystemPrompt)与新会话都能正常工作。
func (s *messageService) effectiveSystemPrompt(ctx context.Context, conv *Conversation) string {
if s.briefReader == nil || !conv.HasFKMount() {
return conv.SystemPrompt
}
fkBrief, err := s.briefReader.BriefForConversation(ctx, conv)
if err != nil {
if s.log != nil {
s.log.Warn("chat.compose_history.brief_failed",
"conversation_id", conv.ID, "err", err.Error())
}
return conv.SystemPrompt
}
fkBrief = strings.TrimSpace(fkBrief)
if fkBrief == "" {
return conv.SystemPrompt
}
if strings.TrimSpace(conv.SystemPrompt) == "" {
return fkBrief
}
return conv.SystemPrompt + "\n\n" + fkBrief
}
// truncateByTokens walks messages backwards and drops oldest messages until the
// estimated token count (system prompt + messages) fits within budget.
func truncateByTokens(msgs []llm.Message, systemPrompt string, budget int) []llm.Message {
systemTokens := llm.EstimateTokens(systemPrompt)
remaining := budget - systemTokens
if remaining <= 0 {
return nil
}
// Walk backwards to find how many messages fit.
tokens := 0
cutAt := len(msgs)
for i := len(msgs) - 1; i >= 0; i-- {
msgTokens := 0
for _, b := range msgs[i].Blocks {
if b.Type == "text" {
msgTokens += llm.EstimateTokens(b.Text)
}
}
if tokens+msgTokens > remaining {
cutAt = i + 1
break
}
tokens += msgTokens
cutAt = i
}
return msgs[cutAt:]
}
// ===== Helpers =====
func (s *messageService) mimeAllowed(mt string) bool {
if s.mimeWhite[mt] {
return true
}
if s.mimeWhite["text/*"] && strings.HasPrefix(mt, "text/") {
return true
}
return false
}
func capabilityAllows(c ModelCapabilities, mt string) bool {
switch {
case strings.HasPrefix(mt, "image/"):
return c.Image
case mt == "application/pdf":
return c.PDF
case strings.HasPrefix(mt, "text/"):
return true
}
return false
}
func derefString(p *string) string {
if p == nil {
return ""
}
return *p
}
func (s *messageService) 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)
}
}