Changeset 0.20.0 (#85)

This commit is contained in:
2026-03-01 12:40:15 +00:00
parent eb74180611
commit 817062e5fc
57 changed files with 5435 additions and 72 deletions

View File

@@ -18,6 +18,7 @@ import (
"git.gobha.me/xcaliber/chat-switchboard/database"
"git.gobha.me/xcaliber/chat-switchboard/events"
"git.gobha.me/xcaliber/chat-switchboard/knowledge"
"git.gobha.me/xcaliber/chat-switchboard/mentions"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/providers"
"git.gobha.me/xcaliber/chat-switchboard/storage"
@@ -218,6 +219,19 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
}
}
// ── Multi-model @mention routing (v0.20.0) ──────────
// Check if the message @mentions specific channel models.
// If multiple models are targeted, fan out sequentially.
roster, _ := h.stores.Channels.GetModels(c.Request.Context(), channelID)
if len(roster) > 1 {
parsed := mentions.Parse(req.Content, roster)
targets := mentions.ResolvedModels(parsed)
if len(targets) > 1 {
h.multiModelStream(c, targets, messages, channelID, userID, personaID, presetSystemPrompt, req)
return
}
}
// Build provider request
provReq := providers.CompletionRequest{
Model: model,
@@ -257,6 +271,122 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
}
}
// ── Multi-Model Sequential Stream (v0.20.0) ──
// multiModelStream handles the case where a message @mentions multiple
// channel models. It streams each model's response sequentially within
// a single SSE response, sending model_start/model_end delimiters.
func (h *CompletionHandler) multiModelStream(
c *gin.Context,
targets []models.ChannelModel,
messages []providers.Message,
channelID, userID, personaID, presetSystemPrompt string,
req completionRequest,
) {
// Set SSE headers once for the entire multi-model stream
c.Header("Content-Type", "text/event-stream")
c.Header("Cache-Control", "no-cache")
c.Header("Connection", "keep-alive")
c.Header("X-Accel-Buffering", "no")
c.Status(http.StatusOK)
flusher, _ := c.Writer.(http.Flusher)
flush := func() {
if flusher != nil {
flusher.Flush()
}
}
sendSSE := func(data string) {
fmt.Fprintf(c.Writer, "data: %s\n\n", data)
flush()
}
sendEvent := func(event, data string) {
fmt.Fprintf(c.Writer, "event: %s\ndata: %s\n\n", event, data)
flush()
}
for _, target := range targets {
// Notify client which model is about to respond
startJSON, _ := json.Marshal(map[string]string{
"model_id": target.ModelID,
"display_name": target.DisplayName,
})
sendEvent("model_start", string(startJSON))
// Resolve config for this specific model
targetReq := req
targetReq.Model = target.ModelID
if target.ProviderConfigID != "" {
targetReq.APIConfigID = target.ProviderConfigID
}
providerCfg, providerID, model, configID, providerScope, err := h.resolveConfig(userID, channelID, targetReq)
if err != nil {
sendSSE(fmt.Sprintf(`{"error":"failed to resolve config for %s: %s"}`, target.DisplayName, escapeJSON(err.Error())))
sendEvent("model_end", string(startJSON))
continue
}
provider, err := providers.Get(providerID)
if err != nil {
sendSSE(fmt.Sprintf(`{"error":"provider unavailable for %s"}`, target.DisplayName))
sendEvent("model_end", string(startJSON))
continue
}
// Build per-model messages (inject model-specific system prompt if set)
modelMessages := make([]providers.Message, len(messages))
copy(modelMessages, messages)
if target.SystemPrompt != "" {
// Prepend model-specific system prompt
modelMessages = append([]providers.Message{{
Role: "system",
Content: target.SystemPrompt,
}}, modelMessages...)
}
caps := h.getModelCapabilities(c, model, configID)
provReq := providers.CompletionRequest{
Model: model,
Messages: modelMessages,
}
if targetReq.MaxTokens > 0 {
provReq.MaxTokens = targetReq.MaxTokens
} else {
provReq.MaxTokens = capspkg.ResolveMaxOutput(model, caps)
}
if targetReq.Temperature != nil {
provReq.Temperature = targetReq.Temperature
}
if targetReq.TopP != nil {
provReq.TopP = targetReq.TopP
}
hasBrowserTools := h.hub != nil && h.hub.IsConnected(userID)
if caps.ToolCalling && (tools.HasTools() || hasBrowserTools) {
provReq.Tools = h.buildToolDefs(c.Request.Context(), userID, hasBrowserTools, req.DisabledTools)
}
// Stream this model's response using the shared streaming core
result := streamModelResponse(c, provider, providerCfg, &provReq, model, target.DisplayName, userID, channelID, personaID, h.hub)
// Persist assistant message with model attribution
if result.Content != "" {
if _, err := h.persistMessage(channelID, userID, "assistant", result.Content, model, result.InputTokens, result.OutputTokens, nil, toolActivityJSON(result.ToolActivity)); err != nil {
log.Printf("Failed to persist multi-model assistant message: %v", err)
}
}
// Log usage
h.logUsage(c, channelID, userID, configID, providerScope, model,
result.InputTokens, result.OutputTokens,
result.CacheCreationTokens, result.CacheReadTokens)
sendEvent("model_end", string(startJSON))
}
sendSSE("[DONE]")
}
// buildToolDefs converts registered tools to provider-format tool definitions.
// If includeBrowser is true, also fetches tool schemas from enabled browser extensions.
// Tools whose names appear in disabledTools are excluded.