Files
2026-06-20 19:16:44 +08:00

415 lines
12 KiB
Go

package chat
import (
"context"
"errors"
"log/slog"
"sync"
"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"
"github.com/yan1h/agent-coding-workflow/internal/infra/notify"
)
// clientGetter is a testability seam around *llm.Registry.
// *llm.Registry satisfies this interface implicitly.
type clientGetter interface {
GetClient(ctx context.Context, endpointID uuid.UUID) (llm.LLMClient, error)
}
// notifyDispatcher is a testability seam around *notify.Dispatcher.
// *notify.Dispatcher satisfies this interface implicitly.
type notifyDispatcher interface {
Dispatch(ctx context.Context, msg notify.Message) error
}
// streamer holds the ring buffer and state for one in-flight assistant message.
type streamer struct {
mu sync.Mutex
cond *sync.Cond
events []SSEEvent
closed bool
cancel context.CancelFunc
messageID int64
}
func newStreamer() *streamer {
s := &streamer{}
s.cond = sync.NewCond(&s.mu)
return s
}
// append adds an event to the ring buffer and broadcasts to all waiters.
// The event ID is assigned as the current length before append.
func (s *streamer) append(ev SSEEvent) {
s.mu.Lock()
ev.ID = int64(len(s.events))
s.events = append(s.events, ev)
s.cond.Broadcast()
s.mu.Unlock()
}
// close marks the streamer as done and wakes all waiters.
func (s *streamer) close() {
s.mu.Lock()
s.closed = true
s.cond.Broadcast()
s.mu.Unlock()
}
// subscription is the handle returned to SSE HTTP handlers.
type subscription struct {
st *streamer
}
// Next blocks until at least one event with ID >= nextEventID is available,
// the stream closes, or ctx is cancelled.
// It returns all events from nextEventID to the current tail.
func (sub *subscription) Next(ctx context.Context, nextEventID int64) ([]SSEEvent, bool, error) {
st := sub.st
// Launch a single watcher that observes ctx cancellation and wakes any
// waiter. It broadcasts under the lock so the signal cannot be missed by a
// waiter that has not yet parked on cond.Wait(); the predicate loop below
// re-checks ctx.Err() after every wake, so a broadcast delivered before a
// waiter parks is still observed on the next predicate check.
//
// done is closed (under the lock) when Next returns, which makes the watcher
// exit even if ctx never cancels — so neither goroutine can leak.
done := make(chan struct{})
go func() {
select {
case <-ctx.Done():
st.mu.Lock()
st.cond.Broadcast()
st.mu.Unlock()
case <-done:
}
}()
st.mu.Lock()
defer func() {
// Signal the watcher to exit, then release the lock. Closing done under
// the lock pairs with the watcher's Broadcast-under-lock so there is no
// window in which the watcher broadcasts to an unlocked, already-returned
// Next.
close(done)
st.mu.Unlock()
}()
// Wait until there is data at/after nextEventID, the stream closes, or ctx
// is cancelled. Each predicate component is re-checked after every wake so a
// missed/early broadcast cannot wedge the wait.
for int64(len(st.events)) <= nextEventID && !st.closed && ctx.Err() == nil {
st.cond.Wait()
}
if int64(len(st.events)) <= nextEventID && !st.closed && ctx.Err() != nil {
return nil, false, ctx.Err()
}
out := append([]SSEEvent(nil), st.events[nextEventID:]...)
return out, st.closed, nil
}
// streamerHub manages all in-flight streamers.
type streamerHub struct {
mu sync.Mutex
byMsgID map[int64]*streamer
repo Repository
registry clientGetter
dispatch notifyDispatcher
auditor audit.Recorder
convSvc *conversationService
log *slog.Logger
retention time.Duration
safety float64 // reserved for T15 token-truncation; unused in T14
}
// NewStreamerHub constructs a StreamerHub.
// convSvc must be the concrete *conversationService produced by NewConversationService;
// the type assertion lets callers in other packages pass the interface without needing
// to import the unexported concrete type. Pass nil to skip title generation.
func NewStreamerHub(
repo Repository,
registry clientGetter,
dispatch notifyDispatcher,
auditor audit.Recorder,
convSvc ConversationService,
log *slog.Logger,
retention time.Duration,
safetyPct float64,
) StreamerHub {
var concrete *conversationService
if convSvc != nil {
if c, ok := convSvc.(*conversationService); ok {
concrete = c
}
}
return &streamerHub{
byMsgID: make(map[int64]*streamer),
repo: repo,
registry: registry,
dispatch: dispatch,
auditor: auditor,
convSvc: concrete,
log: log,
retention: retention,
safety: safetyPct,
}
}
// Start launches a goroutine to stream LLM output for the given params.
// streamCtx is intentionally detached from the caller so the stream survives
// after the initiating HTTP request completes.
func (h *streamerHub) Start(ctx context.Context, p StreamParams) error {
st := newStreamer()
st.messageID = p.AssistantMessageID
streamCtx, cancel := context.WithCancel(context.Background())
st.cancel = cancel
h.mu.Lock()
h.byMsgID[p.AssistantMessageID] = st
h.mu.Unlock()
go h.run(streamCtx, st, p)
return nil
}
// Cancel signals the streaming goroutine to stop.
// Returns true if the streamer was found, false otherwise.
func (h *streamerHub) Cancel(messageID int64) bool {
h.mu.Lock()
st, ok := h.byMsgID[messageID]
h.mu.Unlock()
if !ok {
return false
}
st.cancel()
return true
}
// Subscribe returns a Subscription for the given messageID.
// Returns false if no active or recently-completed streamer is found.
func (h *streamerHub) Subscribe(messageID int64) (Subscription, bool) {
h.mu.Lock()
st, ok := h.byMsgID[messageID]
h.mu.Unlock()
if !ok {
return nil, false
}
return &subscription{st: st}, true
}
// run is the goroutine body for one stream. It calls the LLM, populates
// the ring buffer, then persists the result in a transaction.
func (h *streamerHub) run(ctx context.Context, st *streamer, p StreamParams) {
defer h.scheduleRemoval(p.AssistantMessageID)
defer st.close()
client, err := h.registry.GetClient(ctx, p.EndpointID)
if err != nil {
h.handleError(ctx, st, p, err)
return
}
st.append(SSEEvent{Event: "message_meta", Data: map[string]any{
"id": p.AssistantMessageID,
"role": "assistant",
"status": "pending",
}})
req := h.prepareRequest(ctx, p)
ch, err := client.Stream(ctx, p.ModelMeta.ModelID, req)
if err != nil {
h.handleError(ctx, st, p, err)
return
}
var content, thinking string
var usage *llm.Usage
var finishReason string
for ev := range ch {
switch ev.Type {
case llm.EventTextDelta:
content += ev.Text
st.append(SSEEvent{Event: "text_delta", Data: map[string]string{"text": ev.Text}})
case llm.EventThinkingDelta:
thinking += ev.Text
st.append(SSEEvent{Event: "thinking_delta", Data: map[string]string{"text": ev.Text}})
case llm.EventUsage:
usage = ev.Usage
st.append(SSEEvent{Event: "usage", Data: ev.Usage})
case llm.EventDone:
finishReason = ev.Reason
case llm.EventError:
h.handleError(ctx, st, p, ev.Err)
return
}
}
// Cancel path: ctx.Err() != nil after channel drains.
if errors.Is(ctx.Err(), context.Canceled) {
_ = h.repo.UpdateMessageCancelled(context.Background(), p.AssistantMessageID)
h.tryAudit(context.Background(), audit.Entry{
UserID: &p.UserID,
Action: "chat.message.cancelled",
TargetType: "message",
TargetID: int64ToString(p.AssistantMessageID),
})
st.append(SSEEvent{Event: "error", Data: map[string]string{"code": "cancelled"}})
return
}
// Done path: persist in a single transaction.
bgCtx := context.Background()
txRepo, tx, err := h.repo.WithTx(bgCtx)
if err != nil {
h.handleError(bgCtx, st, p, err)
return
}
committed := false
defer func() {
if !committed {
_ = tx.Rollback(bgCtx)
}
}()
var thinkingPtr *string
if thinking != "" {
thinkingPtr = &thinking
}
var ptok, ctok, ttok *int32
if usage != nil {
ptok = intToInt32Ptr(usage.PromptTokens)
ctok = intToInt32Ptr(usage.CompletionTokens)
ttok = intToInt32Ptr(usage.ThinkingTokens)
}
if err := txRepo.UpdateMessageOK(bgCtx, UpdateMessageOKParams{
ID: p.AssistantMessageID,
Content: content,
Thinking: thinkingPtr,
PromptTokens: ptok,
CompletionTokens: ctok,
ThinkingTokens: ttok,
}); err != nil {
h.handleError(bgCtx, st, p, err)
return
}
if usage != nil {
costUSD := computeCost(p.ModelMeta,
usage.PromptTokens, usage.CompletionTokens, usage.ThinkingTokens)
if err := txRepo.InsertUsage(bgCtx, UsageRecord{
ConversationID: p.ConversationID,
MessageID: p.AssistantMessageID,
UserID: p.UserID,
EndpointID: p.EndpointID,
ModelID: p.ModelID,
PromptTokens: usage.PromptTokens,
CompletionTokens: usage.CompletionTokens,
ThinkingTokens: usage.ThinkingTokens,
CostUSD: costUSD,
}); err != nil {
h.handleError(bgCtx, st, p, err)
return
}
}
if err := tx.Commit(bgCtx); err != nil {
h.handleError(bgCtx, st, p, err)
return
}
committed = true
h.tryAudit(bgCtx, audit.Entry{
UserID: &p.UserID,
Action: "chat.message.completed",
TargetType: "message",
TargetID: int64ToString(p.AssistantMessageID),
Metadata: map[string]any{
"finish_reason": finishReason,
},
})
st.append(SSEEvent{Event: "done", Data: map[string]string{"finish_reason": finishReason}})
go h.maybeGenerateTitle(p.ConversationID)
}
// handleError persists the error state, dispatches a user notification,
// and appends an "error" SSE event to the stream.
func (h *streamerHub) handleError(ctx context.Context, st *streamer, p StreamParams, err error) {
bgCtx := context.Background()
e := err.Error()
_ = h.repo.UpdateMessageError(bgCtx, p.AssistantMessageID, &e)
h.tryAudit(bgCtx, audit.Entry{
UserID: &p.UserID,
Action: "chat.message.error",
TargetType: "message",
TargetID: int64ToString(p.AssistantMessageID),
Metadata: map[string]any{"error": e},
})
_ = h.dispatch.Dispatch(bgCtx, notify.Message{
UserID: p.UserID,
Topic: "chat.message_failed",
Severity: notify.SeverityError,
Title: "对话回复失败",
Body: e,
Link: "/chat/" + p.ConversationID.String(),
})
st.append(SSEEvent{Event: "error", Data: map[string]string{
"code": string(errs.CodeLLMUpstream),
"message": e,
}})
}
// scheduleRemoval removes the streamer from the hub after h.retention so
// late-arriving subscribers can still catch the tail for a short window.
func (h *streamerHub) scheduleRemoval(messageID int64) {
time.AfterFunc(h.retention, func() {
h.mu.Lock()
delete(h.byMsgID, messageID)
h.mu.Unlock()
})
}
// maybeGenerateTitle triggers title generation if the conversation has none yet.
func (h *streamerHub) maybeGenerateTitle(conversationID uuid.UUID) {
if h.convSvc == nil {
return
}
c, err := h.repo.GetConversation(context.Background(), conversationID)
if err != nil || c.Title != nil {
return
}
h.convSvc.GenerateTitle(context.Background(), conversationID)
}
// prepareRequest is a stub for T14; T15 will add context-window truncation.
func (h *streamerHub) prepareRequest(_ context.Context, p StreamParams) llm.Request {
return p.Request
}
// tryAudit swallows audit errors, logging them at Warn level.
func (h *streamerHub) tryAudit(ctx context.Context, e audit.Entry) {
if err := h.auditor.Record(ctx, e); err != nil {
h.log.Warn("audit record failed", "action", e.Action, "err", err)
}
}
// intToInt32Ptr converts an int to a heap-allocated int32 pointer.
func intToInt32Ptr(v int) *int32 {
x := int32(v)
return &x
}