Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged.

## Unreleased

**Before upgrading.** Nine things change for an existing deployment:
**Before upgrading.** Ten things change for an existing deployment:
- A hosted computer's browser is now headed by default. It uses a little more memory than headless
did; set `COMPUTER_BROWSER_MODE=headless` (Helm: `computers.browserMode: headless`) to keep the old
behaviour.
Expand Down Expand Up @@ -43,6 +43,17 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged.
`computers/<Bot id>` on the workspace volume (below). Files saved before the upgrade stay at the
root of the volume, where no Bot sees them; move them into a Bot's folder to give them back.

- Migration `0066_lazy_dragon_man` adds a nullable `model_selection` column to
`intelligence_channel_mappings`; existing conversations keep the configured default.

### Choose a model for a conversation

Admin → Enterprise controls → Models → Allow model selection enables a small model shortlist for
API-backed built-in Bots. It is off by default. When enabled, the selector sits beside Send, including
before the first message, and remembers the choice for that person, Bot, and conversation. Turning
the setting off uses the configured default again without deleting saved choices. Account-backed
ChatGPT and Claude harnesses, external Bots, and group chats keep their existing model behavior.

### The audit log reads like a log

Admin → Audit fills the window and runs the table across the whole pane, with the title, the views
Expand Down
25 changes: 18 additions & 7 deletions app/src/components/channels/channel-chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,19 @@ import {
useAgent,
useCopilotKit,
} from "@copilotkit/react-core/v2";
import { observeApprovalAgent } from "@/lib/copilot/approval-context";
import { useMutation, useQuery } from "@tanstack/react-query";
import { useCallback, useEffect, useRef, useState } from "react";
import { HandoffResumeNotice } from "@/components/computer/handoff-resume-notice";
import { ChannelWorkRows } from "@/components/channels/channel-work";
import { attachmentModality } from "@/components/channels/chat-messages";
import { toAgentOptions } from "@/components/channels/composer";
import { ChannelWorkRows } from "@/components/channels/channel-work";
import { ConversationView } from "@/components/channels/conversation-view";
import { VoiceCallWidget } from "@/components/channels/voice-call-widget";
import {
seedMessage,
takeFirstMessage,
transcriptMessages,
} from "@/components/channels/transcript-messages";
import { VoiceCallWidget } from "@/components/channels/voice-call-widget";
import { HandoffResumeNotice } from "@/components/computer/handoff-resume-notice";
import { agentListQueryOptions } from "@/lib/agents/queries";
import { attachmentUrl } from "@/lib/channels/attachments";
import {
Expand All @@ -32,6 +31,7 @@ import {
channelKeys,
} from "@/lib/channels/queries";
import { useActiveBot } from "@/lib/copilot/active-bot";
import { observeApprovalAgent } from "@/lib/copilot/approval-context";
import { ConversationProvider } from "@/lib/copilot/conversation";
import { afterMs, joinWithin } from "@/lib/copilot/join-thread";
import { repairUnansweredToolCalls } from "@/lib/copilot/repair-history";
Expand All @@ -44,16 +44,17 @@ import {
voiceContext,
withVoiceContext,
} from "@/lib/voice/agent-bridge";
import { useVoiceCall } from "@/lib/voice/use-voice-call";
import {
loadVoiceArchive,
cachedVoiceArchive,
loadVoiceArchive,
useVoiceArchive,
voiceArchiveContext,
withVoiceChats,
} from "@/lib/voice/archive";
import { useVoiceCall } from "@/lib/voice/use-voice-call";
import { queryClient } from "@/query-client";
import { newId } from "../../lib/new-id";
import { ModelSelector } from "./model-selector";

/**
* How long a stalled thread join is worth waiting for before it is ended.
Expand Down Expand Up @@ -536,6 +537,7 @@ export function ChannelChat({
}, [channel.id, channel.threadId, joinGatePromise, runtimeAgentId]);

// Tool calls from this conversation act on this coworker's own computer.
const [modelBlocked, setModelBlocked] = useState(true);
useActiveBot(runtimeAgentId);

const skillCommands = useSkillCommands(runtimeAgentId);
Expand Down Expand Up @@ -1056,7 +1058,16 @@ export function ChannelChat({
* middle of an answer: a second turn racing the first on one thread, with a fabricated
* result stitched over a tool call that is still executing.
*/
pending={agent.isRunning || turnsInFlight > 0}
modelSelector={
<ModelSelector
channelId={channel.id}
agentId={runtimeAgentId}
disabled={agent.isRunning || turnsInFlight > 0 || restoring}
onBlockingChange={setModelBlocked}
/>
}
submissionBlocked={modelBlocked}
pending={agent.isRunning || turnsInFlight > 0 || modelBlocked}
/*
* A channel outlives its turns, so it is the screen where waiting is worth offering. A
* correction typed mid-answer is held here, in this tab, and runs as one follow-up turn the
Expand Down
40 changes: 25 additions & 15 deletions app/src/components/channels/composer/composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
type ChangeEvent,
type DragEvent,
type FormEvent,
type ReactNode,
useCallback,
useEffect,
useMemo,
Expand Down Expand Up @@ -121,6 +122,7 @@ const COMPACT_MIN_HEIGHT_PX = 19;
const COMPACT_MAX_HEIGHT_PX = 96;

export type ComposerProps = {
modelSelector?: ReactNode;
className?: string;
/**
* Classes for the editor itself rather than the frame. `className` styles the box — border,
Expand Down Expand Up @@ -310,6 +312,7 @@ const UNACCEPTED_DROP_REASON = {
} as const;

export function Composer({
modelSelector,
className,
editorClassName,
compact = false,
Expand Down Expand Up @@ -1564,6 +1567,11 @@ export function Composer({
dictation={dictation}
disabled={disabled || voiceCall?.active === true}
/>
{modelSelector ? (
<div className="min-w-0 max-w-[45%] self-end">
{modelSelector}
</div>
) : null}
{primaryAction}
</div>
</DictationSurface>
Expand Down Expand Up @@ -1620,26 +1628,28 @@ export function Composer({
</div>

<div className="flex items-center justify-between">
{attachmentsEnabled ? (
<Button
aria-label="Attach a file"
className="size-7 rounded-full p-0"
disabled={disabled}
onClick={() => fileInputRef.current?.click()}
type="button"
variant="ghost"
>
<IconPlus className="size-4" />
</Button>
) : (
<div />
)}

<div className="flex min-w-0 items-center gap-1">
{attachmentsEnabled ? (
<Button
aria-label="Attach a file"
className="size-7 rounded-full p-0"
disabled={disabled}
onClick={() => fileInputRef.current?.click()}
type="button"
variant="ghost"
>
<IconPlus className="size-4" />
</Button>
) : (
<div />
)}
</div>
<div className="flex items-center gap-1">
<DictationButton
dictation={dictation}
disabled={disabled || voiceCall?.active === true}
/>
{modelSelector}
{primaryAction}
</div>
</div>
Expand Down
7 changes: 6 additions & 1 deletion app/src/components/channels/conversation-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ export function ConversationView({
agents = [],
commands,
channelId,
modelSelector,
submissionBlocked = false,
disabled = false,
pending = false,
autoFocus = false,
Expand Down Expand Up @@ -55,6 +57,8 @@ export function ConversationView({
* first send — which leaves the composer exactly as it behaved before attachments existed.
*/
channelId?: string;
modelSelector?: ReactNode;
submissionBlocked?: boolean;
disabled?: boolean;
/**
* A turn is in flight: the Bot has been asked something and has not come back yet.
Expand Down Expand Up @@ -441,6 +445,7 @@ export function ConversationView({
<div className="max-w-2xl mx-auto w-full px-0 pb-4 shrink-0">
{notice}
<Composer
modelSelector={modelSelector}
agents={agents}
autoFocus={autoFocus}
{...(channelId ? { channelId } : {})}
Expand All @@ -451,7 +456,7 @@ export function ConversationView({
disabled={disabled}
droppedAttachments={droppedAttachments}
onQueue={
queueWhileBusy
queueWhileBusy && !submissionBlocked
? (draft) => {
submit(draft, true);
}
Expand Down
154 changes: 154 additions & 0 deletions app/src/components/channels/model-selector.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import type { ConversationModel } from "@openbot/shared/conversation-model";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect } from "react";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
} from "@/components/ui/select";
import { client } from "@/lib/client";
export function ModelSelector({
channelId,
agentId,
disabled,
onBlockingChange,
draftModel = null,
onDraftChange,
}: {
channelId?: string;
draftModel?: string | null;
onDraftChange?: (model: string | null) => void;
agentId: string;
disabled: boolean;
onBlockingChange: (blocked: boolean) => void;
}) {
const cache = useQueryClient();
const queryKey = ["conversation-model", channelId, agentId];
const path = channelId
? `/api/channels/${encodeURIComponent(channelId)}/agents/${encodeURIComponent(agentId)}/model`
: `/api/channels/model-options/${encodeURIComponent(agentId)}`;
const query = useQuery({
queryKey,
queryFn: ({ signal }) =>
client<ConversationModel>(path, "model", { signal }),
retry: false,
refetchOnWindowFocus: "always",
});
const mutation = useMutation({
mutationFn: (model: string | null) =>
client<ConversationModel>(path, "model", {
method: "PUT",
body: { model },
}),
onMutate: async () => {
await cache.cancelQueries({ queryKey });
},
onSuccess: async (model) => {
await cache.cancelQueries({ queryKey });
cache.setQueryData(queryKey, model);
onBlockingChange(model.selectionUnavailable);
},
onError: () =>
onBlockingChange(!query.data || query.data.selectionUnavailable),
});
const data =
query.data && !channelId && query.data.selectable
? {
...query.data,
selectedModel: draftModel,
selectionUnavailable:
draftModel !== null &&
!query.data.options.some((option) => option.id === draftModel),
}
: query.data;
useEffect(() => {
if (!channelId && query.data && !query.data.selectable)
onDraftChange?.(null);
}, [channelId, query.data, onDraftChange]);
const blocked = !data || mutation.isPending || !!data.selectionUnavailable;
useEffect(() => {
onBlockingChange(blocked);
}, [blocked, onBlockingChange]);
useEffect(() => () => onBlockingChange(false), [onBlockingChange]);
if (!data)
return query.isError ? (
<span className="text-xs text-muted-foreground" role="status">
Couldn't load models.{" "}
<button
type="button"
className="underline"
onClick={() => void query.refetch()}
>
Retry
</button>
</span>
) : (
<span className="text-xs text-muted-foreground" role="status">
Loading models…
</span>
);
if (!data.selectable && !data.selectionUnavailable) return null;
const effective = data.selectedModel ?? data.defaultModel;
const label =
data.options.find((m) => m.id === effective)?.label ??
effective ??
"Use Bot default";
return (
<div className="flex min-w-0 flex-col gap-1">
<Select
value={data.selectedModel ?? "__default"}
disabled={disabled || mutation.isPending}
onOpenChange={(open) => {
if (open) void query.refetch();
}}
onValueChange={(value) => {
if (value === null) return;
const selected = value === "__default" ? null : value;
if (!channelId) {
onDraftChange?.(selected);
return;
}
onBlockingChange(true);
mutation.mutate(selected);
}}
>
<SelectTrigger
size="sm"
aria-label="Model for this conversation"
title="Model for this conversation"
className="max-w-48 border-0 text-xs text-muted-foreground"
>
<span className="truncate">{label}</span>
</SelectTrigger>
<SelectContent
className="w-max max-w-[calc(100vw-2rem)]"
align="start"
alignItemWithTrigger={false}
>
<SelectItem value="__default">
Use Bot default
{data.defaultModel
? ` (${data.options.find((m) => m.id === data.defaultModel)?.label ?? data.defaultModel})`
: ""}
</SelectItem>
{data.selectable &&
data.options.map((model) => (
<SelectItem key={model.id} value={model.id}>
{model.label}
</SelectItem>
))}
</SelectContent>
</Select>
{mutation.isError ? (
<span role="alert" className="text-xs text-destructive">
Couldn't change the model. Try again.
</span>
) : data.selectionUnavailable ? (
<span role="alert" className="text-xs text-destructive">
This model isn't available. Choose another model.
</span>
) : null}
</div>
);
}
Loading
Loading