feat: add per-message model and reasoning effort override

Allow users to select a different model and reasoning effort level
directly from the chat input toolbar, overriding the bot defaults
on a per-message basis. The backend accepts optional model_id and
reasoning_effort parameters via both WebSocket and HTTP APIs, with
request-level values taking priority over bot/session settings.

- Backend: extend wsClientMessage and LocalChannelMessageRequest with
  model_id/reasoning_effort fields; add ReasoningEffort to ChatRequest;
  update resolver to prioritize request-level reasoning effort
- Frontend: add ModelOptions and ReasoningEffortSelect shared components;
  refactor model-select to reuse ModelOptions; add model/reasoning
  selectors to chat input toolbar; initialize from bot settings
- Regenerate swagger spec and TypeScript SDK
This commit is contained in:
Acbox
2026-03-29 19:45:55 +08:00
parent 86d83108d9
commit 33f39c20ff
19 changed files with 593 additions and 150 deletions
+9 -2
View File
@@ -516,7 +516,7 @@ func (p *ChannelInboundProcessor) HandleInbound(ctx context.Context, cfg channel
return result
}
chunkCh, streamErrCh := p.runner.StreamChat(ctx, conversation.ChatRequest{
chatReq := conversation.ChatRequest{
BotID: identity.BotID,
ChatID: activeChatID,
SessionID: sessionID,
@@ -536,7 +536,14 @@ func (p *ChannelInboundProcessor) HandleInbound(ctx context.Context, cfg channel
UserMessagePersisted: false,
Attachments: attachments,
OutboundAssetCollector: assetCollector,
})
}
if mid, _ := msg.Metadata["model_id"].(string); strings.TrimSpace(mid) != "" {
chatReq.Model = strings.TrimSpace(mid)
}
if re, _ := msg.Metadata["reasoning_effort"].(string); strings.TrimSpace(re) != "" {
chatReq.ReasoningEffort = strings.TrimSpace(re)
}
chunkCh, streamErrCh := p.runner.StreamChat(ctx, chatReq)
var (
finalMessages []conversation.ModelMessage
+6 -2
View File
@@ -234,8 +234,12 @@ func (r *Resolver) resolve(ctx context.Context, req conversation.ChatRequest) (r
inlineImages := extractNativeImageParts(mergedAttachments)
reasoningEffort := ""
if chatModel.HasCompatibility(models.CompatReasoning) && botSettings.ReasoningEnabled {
reasoningEffort = botSettings.ReasoningEffort
if chatModel.HasCompatibility(models.CompatReasoning) {
if re := strings.TrimSpace(req.ReasoningEffort); re != "" {
reasoningEffort = re
} else if botSettings.ReasoningEnabled {
reasoningEffort = botSettings.ReasoningEffort
}
}
var reasoningConfig *models.ReasoningConfig
+8 -7
View File
@@ -241,13 +241,14 @@ type ChatRequest struct {
// Set by the inbound channel processor; called by the resolver at persist time.
OutboundAssetCollector func() []OutboundAssetRef `json:"-"`
Query string `json:"query"`
Model string `json:"model,omitempty"`
Provider string `json:"provider,omitempty"`
Channels []string `json:"channels,omitempty"`
CurrentChannel string `json:"current_channel,omitempty"`
Messages []ModelMessage `json:"messages,omitempty"`
Attachments []ChatAttachment `json:"attachments,omitempty"`
Query string `json:"query"`
Model string `json:"model,omitempty"`
Provider string `json:"provider,omitempty"`
ReasoningEffort string `json:"reasoning_effort,omitempty"`
Channels []string `json:"channels,omitempty"`
CurrentChannel string `json:"current_channel,omitempty"`
Messages []ModelMessage `json:"messages,omitempty"`
Attachments []ChatAttachment `json:"attachments,omitempty"`
}
// ChatResponse is the output of a non-streaming chat call.
+23 -5
View File
@@ -168,7 +168,9 @@ func formatLocalStreamEvent(event channel.StreamEvent) ([]byte, error) {
// LocalChannelMessageRequest is the request body for posting a local channel message.
type LocalChannelMessageRequest struct {
Message channel.Message `json:"message"`
Message channel.Message `json:"message"`
ModelID string `json:"model_id,omitempty"`
ReasoningEffort string `json:"reasoning_effort,omitempty"`
}
// PostMessage godoc
@@ -234,6 +236,18 @@ func (h *LocalChannelHandler) PostMessage(c echo.Context) error {
ReceivedAt: time.Now().UTC(),
Source: "local",
}
if mid := strings.TrimSpace(req.ModelID); mid != "" {
if msg.Metadata == nil {
msg.Metadata = make(map[string]any)
}
msg.Metadata["model_id"] = mid
}
if re := strings.TrimSpace(req.ReasoningEffort); re != "" {
if msg.Metadata == nil {
msg.Metadata = make(map[string]any)
}
msg.Metadata["reasoning_effort"] = re
}
if err := h.channelManager.HandleInbound(c.Request().Context(), cfg, msg); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
}
@@ -245,10 +259,12 @@ var wsUpgrader = websocket.Upgrader{
}
type wsClientMessage struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
SessionID string `json:"session_id,omitempty"`
Attachments []json.RawMessage `json:"attachments,omitempty"`
Type string `json:"type"`
Text string `json:"text,omitempty"`
SessionID string `json:"session_id,omitempty"`
Attachments []json.RawMessage `json:"attachments,omitempty"`
ModelID string `json:"model_id,omitempty"`
ReasoningEffort string `json:"reasoning_effort,omitempty"`
}
// wsWriter serialises all WebSocket writes through a single goroutine to
@@ -419,6 +435,8 @@ func (h *LocalChannelHandler) HandleWebSocket(c echo.Context) error {
CurrentChannel: h.channelType.String(),
Channels: []string{h.channelType.String()},
Attachments: chatAttachments,
Model: strings.TrimSpace(msg.ModelID),
ReasoningEffort: strings.TrimSpace(msg.ReasoningEffort),
}
if streamErr := h.resolver.StreamChatWS(streamCtx, req, eventCh, abortCh); streamErr != nil {
if ctx.Err() == nil {