feat(chat): Quick/Reasoning toggle + resolved model display (#3325)

This commit is contained in:
Steven Enamakel
2026-06-04 00:38:14 -04:00
committed by GitHub
parent 57dd868f29
commit 1a85d242e2
22 changed files with 470 additions and 305 deletions
+2
View File
@@ -4107,6 +4107,8 @@ const messages: TranslationMap = {
'chat.agentProfile.defaultAgentLabel': 'المنسق',
'chat.agentProfile.exists': 'ملف تعريف الوكيل "{name}" موجود بالفعل.',
'chat.agentProfile.label': 'ملف تعريف الوكيل',
'chat.agentProfile.quick': 'سريع',
'chat.agentProfile.reasoning': 'تفكير',
'chat.agentProfile.namePlaceholder': 'اسم ملف التعريف',
'chat.agentProfile.promptStylePlaceholder': 'نمط المطالبة',
'chat.agentProfile.allowedToolsPlaceholder': 'الأدوات المسموح بها',
+2
View File
@@ -4183,6 +4183,8 @@ const messages: TranslationMap = {
'chat.agentProfile.defaultAgentLabel': 'অর্কেস্ট্রেটর',
'chat.agentProfile.exists': 'এজেন্ট প্রোফাইল "{name}" ইতিমধ্যেই বিদ্যমান৷',
'chat.agentProfile.label': 'এজেন্ট প্রোফাইল',
'chat.agentProfile.quick': 'দ্রুত',
'chat.agentProfile.reasoning': 'যুক্তি',
'chat.agentProfile.namePlaceholder': 'প্রোফাইল নাম',
'chat.agentProfile.promptStylePlaceholder': 'প্রম্পট স্টাইল',
'chat.agentProfile.allowedToolsPlaceholder': 'অনুমোদিত টুল',
+2
View File
@@ -4296,6 +4296,8 @@ const messages: TranslationMap = {
'chat.agentProfile.defaultAgentLabel': 'Orchestrator',
'chat.agentProfile.exists': 'Agentenprofil „{name}“ ist bereits vorhanden.',
'chat.agentProfile.label': 'Agentenprofil',
'chat.agentProfile.quick': 'Schnell',
'chat.agentProfile.reasoning': 'Denkmodus',
'chat.agentProfile.namePlaceholder': 'Profilname',
'chat.agentProfile.promptStylePlaceholder': 'Eingabeaufforderungsstil',
'chat.agentProfile.allowedToolsPlaceholder': 'Zulässige Tools',
+2
View File
@@ -4579,6 +4579,8 @@ const en: TranslationMap = {
'chat.agentProfile.defaultAgentLabel': 'Orchestrator',
'chat.agentProfile.exists': 'Agent profile "{name}" already exists.',
'chat.agentProfile.label': 'Agent profile',
'chat.agentProfile.quick': 'Quick',
'chat.agentProfile.reasoning': 'Reasoning',
'chat.agentProfile.namePlaceholder': 'Profile name',
'chat.agentProfile.promptStylePlaceholder': 'Prompt style',
'chat.agentProfile.allowedToolsPlaceholder': 'Allowed tools',
+2
View File
@@ -4263,6 +4263,8 @@ const messages: TranslationMap = {
'chat.agentProfile.defaultAgentLabel': 'orquestador',
'chat.agentProfile.exists': 'El perfil del agente "{name}" ya existe.',
'chat.agentProfile.label': 'Perfil del agente',
'chat.agentProfile.quick': 'Rápido',
'chat.agentProfile.reasoning': 'Razonamiento',
'chat.agentProfile.namePlaceholder': 'Nombre del perfil',
'chat.agentProfile.promptStylePlaceholder': 'estilo rápido',
'chat.agentProfile.allowedToolsPlaceholder': 'Herramientas permitidas',
+2
View File
@@ -4278,6 +4278,8 @@ const messages: TranslationMap = {
'chat.agentProfile.defaultAgentLabel': 'Orchestrator',
'chat.agentProfile.exists': "Le profil d'agent « {name} » existe déjà.",
'chat.agentProfile.label': "Profil d'agent",
'chat.agentProfile.quick': 'Rapide',
'chat.agentProfile.reasoning': 'Raisonnement',
'chat.agentProfile.namePlaceholder': 'Nom du profil',
'chat.agentProfile.promptStylePlaceholder': 'Style de prompt',
'chat.agentProfile.allowedToolsPlaceholder': 'Outils autorisés',
+2
View File
@@ -4193,6 +4193,8 @@ const messages: TranslationMap = {
'chat.agentProfile.defaultAgentLabel': 'ऑर्केस्ट्रेटर',
'chat.agentProfile.exists': 'एजेंट प्रोफ़ाइल "{name}" पहले से मौजूद है।',
'chat.agentProfile.label': 'एजेंट प्रोफ़ाइल',
'chat.agentProfile.quick': 'त्वरित',
'chat.agentProfile.reasoning': 'तर्क',
'chat.agentProfile.namePlaceholder': 'प्रोफ़ाइल नाम',
'chat.agentProfile.promptStylePlaceholder': 'शीघ्र शैली',
'chat.agentProfile.allowedToolsPlaceholder': 'अनुमत उपकरण',
+2
View File
@@ -4202,6 +4202,8 @@ const messages: TranslationMap = {
'chat.agentProfile.defaultAgentLabel': 'Orchestrator',
'chat.agentProfile.exists': 'Profil agen "{name}" sudah ada.',
'chat.agentProfile.label': 'Profil agen',
'chat.agentProfile.quick': 'Cepat',
'chat.agentProfile.reasoning': 'Penalaran',
'chat.agentProfile.namePlaceholder': 'Nama profil',
'chat.agentProfile.promptStylePlaceholder': 'Gaya perintah',
'chat.agentProfile.allowedToolsPlaceholder': 'Alat yang diizinkan',
+2
View File
@@ -4252,6 +4252,8 @@ const messages: TranslationMap = {
'chat.agentProfile.defaultAgentLabel': 'Orchestrator',
'chat.agentProfile.exists': 'Il profilo agente "{name}" esiste già.',
'chat.agentProfile.label': 'Profilo agente',
'chat.agentProfile.quick': 'Veloce',
'chat.agentProfile.reasoning': 'Ragionamento',
'chat.agentProfile.namePlaceholder': 'Nome profilo',
'chat.agentProfile.promptStylePlaceholder': 'Stile prompt',
'chat.agentProfile.allowedToolsPlaceholder': 'Strumenti consentiti',
+2
View File
@@ -4147,6 +4147,8 @@ const messages: TranslationMap = {
'chat.agentProfile.defaultAgentLabel': 'Orchestrator',
'chat.agentProfile.exists': '에이전트 프로필 "{name}"이(가) 이미 존재합니다.',
'chat.agentProfile.label': '에이전트 프로필',
'chat.agentProfile.quick': '빠름',
'chat.agentProfile.reasoning': '추론',
'chat.agentProfile.namePlaceholder': '프로필 이름',
'chat.agentProfile.promptStylePlaceholder': '프롬프트 스타일',
'chat.agentProfile.allowedToolsPlaceholder': '허용된 도구',
+2
View File
@@ -4256,6 +4256,8 @@ const messages: TranslationMap = {
'chat.agentProfile.defaultAgentLabel': 'Orchestrator',
'chat.agentProfile.exists': 'Profil agenta „{name}” już istnieje.',
'chat.agentProfile.label': 'Profil agenta',
'chat.agentProfile.quick': 'Szybki',
'chat.agentProfile.reasoning': 'Rozumowanie',
'chat.agentProfile.namePlaceholder': 'Nazwa profilu',
'chat.agentProfile.promptStylePlaceholder': 'Styl promptu',
'chat.agentProfile.allowedToolsPlaceholder': 'Dozwolone narzędzia',
+2
View File
@@ -4255,6 +4255,8 @@ const messages: TranslationMap = {
'chat.agentProfile.defaultAgentLabel': 'Orquestrador',
'chat.agentProfile.exists': 'O perfil de agente "{name}" já existe.',
'chat.agentProfile.label': 'Perfil do agente',
'chat.agentProfile.quick': 'Rápido',
'chat.agentProfile.reasoning': 'Raciocínio',
'chat.agentProfile.namePlaceholder': 'Nome do perfil',
'chat.agentProfile.promptStylePlaceholder': 'Estilo de prompt',
'chat.agentProfile.allowedToolsPlaceholder': 'Ferramentas permitidas',
+2
View File
@@ -4218,6 +4218,8 @@ const messages: TranslationMap = {
'chat.agentProfile.defaultAgentLabel': 'Оркестратор',
'chat.agentProfile.exists': 'Профиль агента «{name}» уже существует.',
'chat.agentProfile.label': 'Профиль агента',
'chat.agentProfile.quick': 'Быстрый',
'chat.agentProfile.reasoning': 'Рассуждение',
'chat.agentProfile.namePlaceholder': 'Имя профиля',
'chat.agentProfile.promptStylePlaceholder': 'Стиль подсказки',
'chat.agentProfile.allowedToolsPlaceholder': 'Разрешенные инструменты',
+2
View File
@@ -3983,6 +3983,8 @@ const messages: TranslationMap = {
'chat.agentProfile.defaultAgentLabel': '协调者',
'chat.agentProfile.exists': '代理配置文件“{name}”已存在。',
'chat.agentProfile.label': '代理简介',
'chat.agentProfile.quick': '快速',
'chat.agentProfile.reasoning': '推理',
'chat.agentProfile.namePlaceholder': '个人资料名称',
'chat.agentProfile.promptStylePlaceholder': '提示风格',
'chat.agentProfile.allowedToolsPlaceholder': '允许使用的工具',
+1 -75
View File
@@ -2,7 +2,6 @@ import { useEffect, useMemo, useState } from 'react';
import AddAccountModal from '../components/accounts/AddAccountModal';
import { AgentIcon, ProviderIcon } from '../components/accounts/providerIcons';
import RespondQueuePanel from '../components/accounts/RespondQueuePanel';
import WebviewHost from '../components/accounts/WebviewHost';
import { usePrewarmMostRecentAccount } from '../hooks/usePrewarmMostRecentAccount';
import { useT } from '../lib/i18n/I18nContext';
@@ -20,7 +19,6 @@ import {
setLastActiveAccount,
} from '../store/accountsSlice';
import { useAppDispatch, useAppSelector } from '../store/hooks';
import { fetchRespondQueue } from '../store/providerSurfaceSlice';
import type { Account, AccountProvider, ProviderDescriptor } from '../types/accounts';
import { AGENT_ACCOUNT_ID as AGENT_ID } from '../utils/accountsFullscreen';
import { AgentChatPanel } from './Conversations';
@@ -108,18 +106,8 @@ const Accounts = () => {
const order = useAppSelector(state => state.accounts.order);
const activeAccountId = useAppSelector(state => state.accounts.activeAccountId);
const unreadByAccount = useAppSelector(state => state.accounts.unread);
// Respond-queue selectors enabled
const respondQueue = useAppSelector(state => state.providerSurfaces.queue);
const respondQueueCount = useAppSelector(state => state.providerSurfaces.count);
const respondQueueStatus = useAppSelector(state => state.providerSurfaces.status);
const respondQueueError = useAppSelector(state => state.providerSurfaces.error);
const [addOpen, setAddOpen] = useState(false);
const [ctxMenu, setCtxMenu] = useState<ContextMenuState | null>(null);
// Respond Queue is a power-user surface (pending replies from connected
// accounts). Hidden by default so the agent chat gets full width; users
// can toggle it open from the header. (#XXXX)
const [respondQueueOpen, setRespondQueueOpen] = useState(false);
useEffect(() => {
startWebviewAccountService();
@@ -135,14 +123,6 @@ const Accounts = () => {
);
usePrewarmMostRecentAccount({ accounts, accountsById, activeAccountId });
useEffect(() => {
void dispatch(fetchRespondQueue());
const id = window.setInterval(() => {
void dispatch(fetchRespondQueue({ silent: true }));
}, 10_000);
return () => window.clearInterval(id);
}, [dispatch]);
const connectedProviders = useMemo(
() => new Set<AccountProvider>(accounts.map(a => a.provider)),
[accounts]
@@ -305,61 +285,7 @@ const Accounts = () => {
{/* Main pane */}
<main className="flex min-w-0 flex-1 flex-col">
{isAgentSelected ? (
<div className="flex h-full min-w-0">
<div className="relative min-w-0 flex-1">
<AgentChatPanel />
{/* Floating toggle to reveal the Respond Queue side panel.
Pinned top-right inside the chat pane so it doesn't shift
layout when the panel opens/closes. */}
<button
type="button"
onClick={() => {
trackEvent('tauri_browser_click', {
surface: 'chat_right_sidebar',
action: respondQueueOpen ? 'hide_respond_queue' : 'show_respond_queue',
provider: 'agent',
});
setRespondQueueOpen(prev => !prev);
}}
data-analytics-id="chat-right-sidebar-respond-queue-toggle"
className="absolute right-4 top-4 z-10 inline-flex items-center gap-1 rounded-full border border-stone-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-3 py-1 text-[11px] font-medium text-stone-600 dark:text-neutral-300 shadow-soft hover:bg-stone-50 dark:hover:bg-neutral-800/60"
title={
respondQueueOpen
? t('accounts.respondQueue.hide')
: t('accounts.respondQueue.show')
}>
<svg className="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
/>
</svg>
<span>
{t('accounts.respondQueue.title')}
{respondQueueCount > 0 && ` (${respondQueueCount})`}
</span>
</button>
</div>
{/* Respond queue side panel — hidden by default; reveals via toggle */}
{respondQueueOpen && (
<RespondQueuePanel
items={respondQueue}
count={respondQueueCount}
status={respondQueueStatus}
error={respondQueueError}
onRefresh={() => {
trackEvent('tauri_browser_click', {
surface: 'chat_right_sidebar',
action: 'refresh_respond_queue',
provider: 'agent',
});
void dispatch(fetchRespondQueue());
}}
/>
)}
</div>
<AgentChatPanel />
) : active ? (
<div className="flex-1 py-3 pr-3">
<WebviewHost accountId={active.id} provider={active.provider} />
+55 -159
View File
@@ -9,7 +9,6 @@ import ApprovalRequestCard from '../components/chat/ApprovalRequestCard';
import ArtifactCard from '../components/chat/ArtifactCard';
import ChatComposer from '../components/chat/ChatComposer';
import ChatFilesChip from '../components/chat/ChatFilesChip';
import TokenUsagePill from '../components/chat/TokenUsagePill';
import { ConfirmationModal } from '../components/intelligence/ConfirmationModal';
import PillTabBar from '../components/PillTabBar';
import UpsellBanner from '../components/upsell/UpsellBanner';
@@ -31,13 +30,13 @@ import { trackEvent } from '../services/analytics';
import { applyOpenRouterFreeModels } from '../services/api/openrouterFreeModels';
import { threadApi } from '../services/api/threadApi';
import { chatCancel, chatSend, useRustChat } from '../services/chatService';
import { callCoreRpc } from '../services/coreRpcClient';
import { store } from '../store';
import {
loadAgentProfiles,
selectActiveAgentProfileId,
selectAgentProfile,
selectAgentProfiles,
upsertAgentProfile,
} from '../store/agentProfileSlice';
import {
beginInferenceTurn,
@@ -61,7 +60,6 @@ import {
THREAD_NOT_FOUND_MESSAGE,
updateThreadTitle,
} from '../store/threadSlice';
import type { AgentProfile } from '../types/agentProfile';
import type { ConfirmationModal as ConfirmationModalType } from '../types/intelligence';
import type { ThreadMessage } from '../types/thread';
import type { TaskBoardCard, TaskBoardCardStatus } from '../types/turnState';
@@ -117,12 +115,6 @@ type ReplyMode = 'text' | 'voice';
const AUTOCOMPLETE_POLL_DEBOUNCE_MS = 320;
const AUTOCOMPLETE_MIN_CONTEXT_CHARS = 3;
const debug = debugFactory('conversations');
const DEFAULT_PROFILE_DRAFT = {
name: '',
agentId: 'orchestrator',
systemPromptSuffix: '',
allowedTools: '',
};
interface ConversationsProps {
/**
@@ -185,14 +177,6 @@ export function formatThreadLoadError(err: unknown): string {
return String(err);
}
function formatAgentProfileAgentLabel(agentId: string): string {
return agentId
.split(/[_-]+/)
.filter(Boolean)
.map(part => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ');
}
const Conversations = ({
variant = 'page',
composer: composerProp = 'text',
@@ -226,8 +210,6 @@ const Conversations = ({
const [sendAdvisory, setSendAdvisory] = useState<string | null>(null);
const [openRouterStatus, setOpenRouterStatus] = useState<'idle' | 'saving' | 'error'>('idle');
const [pendingSendingThreadId, setPendingSendingThreadId] = useState<string | null>(null);
const [profileDraftOpen, setProfileDraftOpen] = useState(false);
const [profileDraft, setProfileDraft] = useState(DEFAULT_PROFILE_DRAFT);
const socketStatus = useAppSelector(selectSocketStatus);
const agentProfiles = useAppSelector(selectAgentProfiles);
const selectedAgentProfileId = useAppSelector(selectActiveAgentProfileId);
@@ -273,29 +255,27 @@ const Conversations = ({
onConfirm: () => {},
onCancel: () => {},
});
const agentProfileAgentOptions = useMemo(() => {
const seen = new Set<string>();
const options: Array<{ id: string; label: string }> = [];
for (const profile of agentProfiles) {
const id = profile.agentId.trim();
if (!id || seen.has(id)) continue;
seen.add(id);
options.push({
id,
label: profile.builtIn ? profile.name : formatAgentProfileAgentLabel(id),
});
}
if (profileDraft.agentId && !seen.has(profileDraft.agentId)) {
options.push({
id: profileDraft.agentId,
label: formatAgentProfileAgentLabel(profileDraft.agentId),
});
}
if (options.length === 0) {
options.push({ id: 'orchestrator', label: t('chat.agentProfile.defaultAgentLabel') });
}
return options;
}, [agentProfiles, profileDraft.agentId, t]);
const [resolvedModel, setResolvedModel] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const profile = agentProfiles.find(p => p.id === selectedAgentProfileId);
const hint = profile?.modelOverride ?? 'hint:chat';
const res = await callCoreRpc<{ model: string }>({
method: 'openhuman.inference_resolve_model',
params: { hint },
});
if (!cancelled) setResolvedModel(res.model);
} catch {
if (!cancelled) setResolvedModel(null);
}
})();
return () => {
cancelled = true;
};
}, [agentProfiles, selectedAgentProfileId]);
const textInputRef = useRef<HTMLTextAreaElement>(null);
const isComposingTextRef = useRef(false);
@@ -379,42 +359,6 @@ const Conversations = ({
}
};
const handleCreateAgentProfile = async () => {
const name = profileDraft.name.trim();
if (!name) return;
const duplicate = agentProfiles.some(
profile => profile.name.trim().toLowerCase() === name.toLowerCase()
);
if (duplicate) {
setSendAdvisory(t('chat.agentProfile.exists').replace('{name}', name));
return;
}
const id = `profile-${globalThis.crypto.randomUUID().slice(0, 8)}`;
const allowedTools = profileDraft.allowedTools
.split(',')
.map(tool => tool.trim())
.filter(Boolean);
const profile: AgentProfile = {
id,
name,
description: t('chat.agentProfile.customDescription'),
agentId: profileDraft.agentId,
systemPromptSuffix: profileDraft.systemPromptSuffix.trim() || null,
allowedTools: allowedTools.length > 0 ? allowedTools : null,
builtIn: false,
};
try {
await dispatch(upsertAgentProfile(profile)).unwrap();
await dispatch(selectAgentProfile(id)).unwrap();
setProfileDraftOpen(false);
setProfileDraft(DEFAULT_PROFILE_DRAFT);
setSendAdvisory(null);
} catch (error) {
debug('agent profile create failed: %o', error);
setSendAdvisory(t('chat.agentProfile.createFailed'));
}
};
useEffect(() => {
let cancelled = false;
@@ -1526,107 +1470,59 @@ const Conversations = ({
)}
</div>
)}
{resolvedModel && (
<span className="text-[10px] text-stone-400 dark:text-neutral-500 leading-none">
{resolvedModel}
</span>
)}
</div>
<>
<div className="flex items-center gap-1">
<select
aria-label={t('chat.agentProfile.label')}
value={selectedAgentProfileId}
onChange={event => void handleSelectAgentProfile(event.target.value)}
className="h-7 max-w-[120px] rounded-lg border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-2 text-xs text-stone-700 dark:text-neutral-200 outline-none transition-colors focus:border-primary-400">
{agentProfiles.map(profile => (
<option key={profile.id} value={profile.id}>
{profile.name}
</option>
))}
</select>
<div
className="flex items-center h-7 rounded-full border border-stone-200 dark:border-neutral-700 bg-stone-100 dark:bg-neutral-800 p-0.5"
role="radiogroup"
aria-label={t('chat.agentProfile.label')}>
<button
type="button"
data-analytics-id="chat-header-create-agent-profile-toggle"
onClick={() => setProfileDraftOpen(prev => !prev)}
className="h-7 w-7 rounded-lg text-xs font-medium text-stone-500 dark:text-neutral-400 transition-colors hover:bg-stone-100 dark:hover:bg-neutral-800 dark:bg-neutral-800 dark:hover:bg-neutral-800/60 hover:text-stone-700 dark:hover:text-neutral-200 dark:text-neutral-200 dark:hover:text-neutral-200"
title={t('chat.agentProfile.create')}
aria-label={t('chat.agentProfile.create')}>
+
role="radio"
aria-checked={selectedAgentProfileId === 'default'}
data-analytics-id="chat-header-mode-quick"
onClick={() => void handleSelectAgentProfile('default')}
className={`px-2.5 py-0.5 rounded-full text-xs font-medium transition-all ${
selectedAgentProfileId === 'default'
? 'bg-white dark:bg-neutral-600 text-stone-800 dark:text-neutral-100 shadow-sm'
: 'text-stone-500 dark:text-neutral-400 hover:text-stone-700 dark:hover:text-neutral-200'
}`}>
{t('chat.agentProfile.quick')}
</button>
<button
type="button"
role="radio"
aria-checked={selectedAgentProfileId === 'reasoning'}
data-analytics-id="chat-header-mode-reasoning"
onClick={() => void handleSelectAgentProfile('reasoning')}
className={`px-2.5 py-0.5 rounded-full text-xs font-medium transition-all ${
selectedAgentProfileId === 'reasoning'
? 'bg-white dark:bg-neutral-600 text-stone-800 dark:text-neutral-100 shadow-sm'
: 'text-stone-500 dark:text-neutral-400 hover:text-stone-700 dark:hover:text-neutral-200'
}`}>
{t('chat.agentProfile.reasoning')}
</button>
</div>
{(selectedThreadId ?? activeThreadId) && (
<ChatFilesChip threadId={(selectedThreadId ?? activeThreadId) as string} />
)}
<TokenUsagePill />
<button
type="button"
data-testid="new-thread-button"
data-analytics-id="chat-header-new-thread"
onClick={() => void handleCreateNewThread()}
className="px-2.5 py-1 rounded-lg text-xs font-medium text-primary-600 hover:bg-primary-50 transition-colors"
className="px-2.5 py-1 rounded-lg text-xs font-medium text-white bg-primary-500 hover:bg-primary-600 shadow-sm transition-colors"
title={t('chat.newThreadShortcut')}>
{t('chat.new')}
</button>
</>
</div>
)}
{!isSidebar && profileDraftOpen && (
<div className="border-b border-stone-100 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-4 py-3">
<div className="grid grid-cols-1 gap-2 sm:grid-cols-[1fr_140px]">
<input
value={profileDraft.name}
onChange={event => setProfileDraft(prev => ({ ...prev, name: event.target.value }))}
placeholder={t('chat.agentProfile.namePlaceholder')}
className="h-8 rounded-lg border border-stone-200 dark:border-neutral-800 dark:bg-neutral-900 dark:text-neutral-200 px-3 text-xs outline-none focus:border-primary-400"
/>
<select
value={profileDraft.agentId}
onChange={event =>
setProfileDraft(prev => ({ ...prev, agentId: event.target.value }))
}
className="h-8 rounded-lg border border-stone-200 dark:border-neutral-800 dark:bg-neutral-900 dark:text-neutral-200 px-2 text-xs outline-none focus:border-primary-400">
{agentProfileAgentOptions.map(agent => (
<option key={agent.id} value={agent.id}>
{agent.label}
</option>
))}
</select>
</div>
<textarea
value={profileDraft.systemPromptSuffix}
onChange={event =>
setProfileDraft(prev => ({ ...prev, systemPromptSuffix: event.target.value }))
}
placeholder={t('chat.agentProfile.promptStylePlaceholder')}
rows={2}
className="mt-2 w-full resize-none rounded-lg border border-stone-200 dark:border-neutral-800 dark:bg-neutral-900 dark:text-neutral-200 px-3 py-2 text-xs outline-none focus:border-primary-400"
/>
<div className="mt-2 flex items-center gap-2">
<input
value={profileDraft.allowedTools}
onChange={event =>
setProfileDraft(prev => ({ ...prev, allowedTools: event.target.value }))
}
placeholder={t('chat.agentProfile.allowedToolsPlaceholder')}
className="h-8 min-w-0 flex-1 rounded-lg border border-stone-200 dark:border-neutral-800 dark:bg-neutral-900 dark:text-neutral-200 px-3 text-xs outline-none focus:border-primary-400"
/>
<button
type="button"
data-analytics-id="chat-agent-profile-save"
onClick={() => void handleCreateAgentProfile()}
disabled={!profileDraft.name.trim()}
className="h-8 rounded-lg bg-primary-500 px-3 text-xs font-medium text-white transition-colors hover:bg-primary-600 disabled:opacity-40">
{t('common.save')}
</button>
<button
type="button"
data-analytics-id="chat-agent-profile-cancel"
onClick={() => {
setProfileDraft(DEFAULT_PROFILE_DRAFT);
setProfileDraftOpen(false);
}}
className="h-8 rounded-lg border border-stone-200 dark:border-neutral-800 px-3 text-xs font-medium text-stone-600 dark:text-neutral-300 transition-colors hover:bg-stone-50 dark:hover:bg-neutral-800/60">
{t('common.cancel')}
</button>
</div>
</div>
)}
<div
ref={messagesContainerRef}
className="flex-1 overflow-y-auto px-5 py-4 bg-[#f6f6f6] dark:bg-neutral-950">
@@ -13,7 +13,6 @@ import { Provider } from 'react-redux';
import { MemoryRouter } from 'react-router-dom';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { agentProfilesApi } from '../../services/api/agentProfilesApi';
import { threadApi } from '../../services/api/threadApi';
import { chatSend } from '../../services/chatService';
import { CoreRpcError } from '../../services/coreRpcClient';
@@ -1028,76 +1027,6 @@ describe('Conversations — smoke render (#1123 welcome-lock removal)', () => {
});
});
it('creates a custom agent profile from the header draft form', async () => {
const thread = makeThread({ id: 'profile-thread', title: 'Profile Thread' });
mockGetThreads.mockResolvedValue({ threads: [thread], count: 1 });
mockGetThreadMessages.mockResolvedValue({ messages: [], count: 0 });
vi.mocked(agentProfilesApi.upsert).mockResolvedValueOnce({
activeProfileId: 'custom',
profiles: [
{
id: 'custom',
name: 'Custom',
description: 'Custom agent profile',
agentId: 'orchestrator',
builtIn: false,
},
],
});
vi.mocked(agentProfilesApi.select).mockResolvedValueOnce({
activeProfileId: 'custom',
profiles: [
{
id: 'custom',
name: 'Custom',
description: 'Custom agent profile',
agentId: 'orchestrator',
builtIn: false,
},
],
});
await act(async () => {
await renderConversations({
thread: selectedThreadState(thread),
socket: socketState('connected'),
});
});
fireEvent.click(await screen.findByLabelText('Create agent profile'));
fireEvent.change(screen.getByPlaceholderText('Profile name'), { target: { value: 'Custom' } });
fireEvent.change(screen.getByPlaceholderText('Prompt style'), {
target: { value: 'Be concise' },
});
fireEvent.change(screen.getByPlaceholderText('Allowed tools'), {
target: { value: 'todowrite, spawn_parallel_agents' },
});
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
await waitFor(() => expect(agentProfilesApi.upsert).toHaveBeenCalledTimes(1));
expect(agentProfilesApi.upsert).toHaveBeenCalledWith(
expect.objectContaining({
name: 'Custom',
systemPromptSuffix: 'Be concise',
allowedTools: ['todowrite', 'spawn_parallel_agents'],
})
);
expect(agentProfilesApi.select).toHaveBeenCalled();
});
it('shows validation when creating a duplicate profile name', async () => {
await act(async () => {
await renderConversations({ thread: emptyThreadState, socket: socketState('connected') });
});
fireEvent.click(await screen.findByLabelText('Create agent profile'));
fireEvent.change(screen.getByPlaceholderText('Profile name'), { target: { value: 'Default' } });
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
expect(await screen.findByText('Agent profile "Default" already exists.')).toBeInTheDocument();
expect(agentProfilesApi.upsert).not.toHaveBeenCalled();
});
it('rolls back and shows feedback when task board move persistence fails', async () => {
const thread = makeThread({ id: 'board-thread', title: 'Board Thread' });
const board = {
+19
View File
@@ -363,6 +363,25 @@ pub fn load_profiles(workspace_dir: &Path) -> Result<AgentProfilesState, String>
pub fn built_in_profiles() -> Vec<AgentProfile> {
vec![
built_in_default_profile(),
AgentProfile {
id: "reasoning".to_string(),
name: "Reasoning".to_string(),
description: "Deep reasoning mode with extended thinking.".to_string(),
agent_id: "orchestrator".to_string(),
model_override: Some("hint:reasoning".to_string()),
temperature: None,
system_prompt_suffix: None,
allowed_tools: None,
built_in: true,
avatar_url: None,
voice_id: None,
soul_md: None,
soul_md_path: None,
composio_integrations: None,
memory_dir_suffix: None,
is_master: false,
sort_order: None,
},
AgentProfile {
id: "research".to_string(),
name: "Research".to_string(),
@@ -83,6 +83,63 @@ pub fn auth_key_for_slug(slug: &str) -> String {
format!("provider:{slug}")
}
/// Resolve a model hint (e.g. `"hint:reasoning"`) or tier name to the
/// concrete model string that the provider router would use — without
/// constructing the actual provider. Returns the provider-string prefix
/// (e.g. `"openai"`) concatenated with the model when a BYOK provider is
/// active, or the bare tier name for the managed OpenHuman backend.
pub fn resolve_model_for_hint(hint_or_tier: &str, config: &Config) -> String {
let hint_to_tier: &[(&str, &str)] = &[
("reasoning", crate::openhuman::config::MODEL_REASONING_V1),
("chat", crate::openhuman::config::MODEL_REASONING_QUICK_V1),
("agentic", crate::openhuman::config::MODEL_AGENTIC_V1),
("coding", crate::openhuman::config::MODEL_CODING_V1),
("summarization", "summarization-v1"),
];
let tier_to_role: &[(&str, &str)] = &[
(crate::openhuman::config::MODEL_REASONING_V1, "reasoning"),
(crate::openhuman::config::MODEL_REASONING_QUICK_V1, "chat"),
(crate::openhuman::config::MODEL_AGENTIC_V1, "agentic"),
(crate::openhuman::config::MODEL_CODING_V1, "coding"),
("chat-v1", "chat"),
("summarization-v1", "summarization"),
];
let (tier, role) = if let Some(hint_key) = hint_or_tier.strip_prefix("hint:") {
let tier = hint_to_tier
.iter()
.find(|(k, _)| *k == hint_key)
.map(|(_, v)| *v)
.unwrap_or(hint_or_tier);
let role = tier_to_role
.iter()
.find(|(k, _)| *k == tier)
.map(|(_, v)| *v)
.unwrap_or(hint_key);
(tier, role)
} else {
let role = tier_to_role
.iter()
.find(|(k, _)| *k == hint_or_tier)
.map(|(_, v)| *v)
.unwrap_or("chat");
(hint_or_tier, role)
};
let provider_string = provider_for_role(role, config);
let ps = provider_string.trim();
if ps.is_empty() || ps == "cloud" || ps == PROVIDER_OPENHUMAN || ps == BYOK_INCOMPLETE_SENTINEL
{
tier.to_string()
} else if let Some(idx) = ps.find(':') {
let model_with_temp = &ps[idx + 1..];
let (model, _) = split_model_and_temperature(model_with_temp);
model
} else {
ps.to_string()
}
}
/// Return whether `model` is a recognized OpenHuman backend tier name.
///
/// Used to guard against stale `default_model` values (e.g. set by older UI
@@ -1762,3 +1762,80 @@ fn local_provider_string_detection() {
assert!(!is_local_provider_string("openhuman"));
assert!(!is_local_provider_string("cloud"));
}
// ── resolve_model_for_hint ──────────────────────────────────────────────
#[test]
fn resolve_model_for_hint_maps_known_hints_to_tiers() {
let config = Config::default();
assert_eq!(
resolve_model_for_hint("hint:reasoning", &config),
"reasoning-v1"
);
assert_eq!(
resolve_model_for_hint("hint:chat", &config),
"reasoning-quick-v1"
);
assert_eq!(
resolve_model_for_hint("hint:agentic", &config),
"agentic-v1"
);
assert_eq!(resolve_model_for_hint("hint:coding", &config), "coding-v1");
assert_eq!(
resolve_model_for_hint("hint:summarization", &config),
"summarization-v1"
);
}
#[test]
fn resolve_model_for_hint_passes_through_tier_names() {
let config = Config::default();
assert_eq!(
resolve_model_for_hint("reasoning-v1", &config),
"reasoning-v1"
);
assert_eq!(resolve_model_for_hint("agentic-v1", &config), "agentic-v1");
assert_eq!(resolve_model_for_hint("coding-v1", &config), "coding-v1");
}
#[test]
fn resolve_model_for_hint_extracts_model_from_byok_provider() {
let mut config = Config::default();
config.reasoning_provider = Some("openai:gpt-4o".to_string());
assert_eq!(resolve_model_for_hint("hint:reasoning", &config), "gpt-4o");
config.chat_provider = Some("anthropic:claude-sonnet-4-20250514".to_string());
assert_eq!(
resolve_model_for_hint("hint:chat", &config),
"claude-sonnet-4-20250514"
);
}
#[test]
fn resolve_model_for_hint_falls_through_openhuman_and_cloud_sentinels() {
let mut config = Config::default();
config.reasoning_provider = Some("openhuman".to_string());
assert_eq!(
resolve_model_for_hint("hint:reasoning", &config),
"reasoning-v1"
);
config.reasoning_provider = Some("cloud".to_string());
assert_eq!(
resolve_model_for_hint("hint:reasoning", &config),
"reasoning-v1"
);
config.reasoning_provider = Some("".to_string());
assert_eq!(
resolve_model_for_hint("hint:reasoning", &config),
"reasoning-v1"
);
}
#[test]
fn resolve_model_for_hint_handles_unknown_hint_passthrough() {
let config = Config::default();
let result = resolve_model_for_hint("hint:unknown_tier", &config);
assert_eq!(result, "hint:unknown_tier");
}
+31
View File
@@ -27,6 +27,11 @@ struct InferenceVisionPromptParams {
max_tokens: Option<u32>,
}
#[derive(Debug, Deserialize)]
struct InferenceResolveModelParams {
hint: String,
}
#[derive(Debug, Deserialize)]
struct InferenceTestProviderModelParams {
workload: String,
@@ -120,6 +125,7 @@ struct InferenceOpenAiOAuthCompleteParams {
pub fn all_controller_schemas() -> Vec<ControllerSchema> {
vec![
schemas("resolve_model"),
schemas("status"),
schemas("get_client_config"),
schemas("update_model_settings"),
@@ -147,6 +153,10 @@ pub fn all_controller_schemas() -> Vec<ControllerSchema> {
pub fn all_registered_controllers() -> Vec<RegisteredController> {
vec![
RegisteredController {
schema: schemas("resolve_model"),
handler: handle_inference_resolve_model,
},
RegisteredController {
schema: schemas("status"),
handler: handle_inference_status,
@@ -240,6 +250,13 @@ pub fn all_registered_controllers() -> Vec<RegisteredController> {
pub fn schemas(function: &str) -> ControllerSchema {
match function {
"resolve_model" => ControllerSchema {
namespace: "inference",
function: "resolve_model",
description: "Resolve a model hint or tier name to the concrete model the provider router would use.",
inputs: vec![required_string("hint", "Model hint (e.g. hint:reasoning) or tier name (e.g. reasoning-v1).")],
outputs: vec![json_output("model", "Resolved concrete model id.")],
},
"status" => ControllerSchema {
namespace: "inference",
function: "status",
@@ -533,6 +550,20 @@ fn json_output(name: &'static str, comment: &'static str) -> FieldSchema {
}
}
fn handle_inference_resolve_model(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let p = deserialize_params::<InferenceResolveModelParams>(params)?;
let config = config_rpc::load_config_with_timeout().await?;
let resolved = crate::openhuman::inference::provider::factory::resolve_model_for_hint(
&p.hint, &config,
);
to_json(RpcOutcome::new(
serde_json::json!({ "model": resolved }),
vec![],
))
})
}
fn handle_inference_status(_params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let config = config_rpc::load_config_with_timeout().await?;
+202
View File
@@ -9852,3 +9852,205 @@ async fn json_rpc_workflows_lifecycle_round_trip() {
api_join.abort();
rpc_join.abort();
}
// ── Model resolution + agent profile switching ──────────────────────────
#[tokio::test]
async fn json_rpc_inference_resolve_model_returns_tier_for_hints() {
let _env_lock = json_rpc_e2e_env_lock();
let tmp = tempdir().expect("tempdir");
let home = tmp.path();
let openhuman_dir = home.join(".openhuman");
let _home_guard = EnvVarGuard::set_to_path("HOME", home);
let _workspace_guard = EnvVarGuard::set_to_path("OPENHUMAN_WORKSPACE", &openhuman_dir);
let (api_addr, api_join) = serve_on_ephemeral(mock_upstream_router()).await;
let api_origin = format!("http://{api_addr}");
write_min_config(&openhuman_dir, &api_origin);
let (rpc_addr, rpc_join) = serve_on_ephemeral(build_core_http_router(false)).await;
let rpc_base = format!("http://{rpc_addr}");
let res = post_json_rpc(
&rpc_base,
9900_1,
"openhuman.inference_resolve_model",
json!({ "hint": "hint:reasoning" }),
)
.await;
let result = assert_no_jsonrpc_error(&res, "resolve_model hint:reasoning");
let model = result
.get("model")
.and_then(Value::as_str)
.expect("model field");
assert_eq!(model, "reasoning-v1");
let res = post_json_rpc(
&rpc_base,
9900_2,
"openhuman.inference_resolve_model",
json!({ "hint": "hint:chat" }),
)
.await;
let result = assert_no_jsonrpc_error(&res, "resolve_model hint:chat");
let model = result
.get("model")
.and_then(Value::as_str)
.expect("model field");
assert_eq!(model, "reasoning-quick-v1");
let res = post_json_rpc(
&rpc_base,
9900_3,
"openhuman.inference_resolve_model",
json!({ "hint": "hint:coding" }),
)
.await;
let result = assert_no_jsonrpc_error(&res, "resolve_model hint:coding");
let model = result
.get("model")
.and_then(Value::as_str)
.expect("model field");
assert_eq!(model, "coding-v1");
let res = post_json_rpc(
&rpc_base,
9900_4,
"openhuman.inference_resolve_model",
json!({ "hint": "reasoning-v1" }),
)
.await;
let result = assert_no_jsonrpc_error(&res, "resolve_model tier passthrough");
let model = result
.get("model")
.and_then(Value::as_str)
.expect("model field");
assert_eq!(model, "reasoning-v1");
api_join.abort();
rpc_join.abort();
}
#[tokio::test]
async fn json_rpc_agent_profile_select_and_resolve_model_integration() {
let _env_lock = json_rpc_e2e_env_lock();
let tmp = tempdir().expect("tempdir");
let home = tmp.path();
let openhuman_dir = home.join(".openhuman");
let _home_guard = EnvVarGuard::set_to_path("HOME", home);
let _workspace_guard = EnvVarGuard::set_to_path("OPENHUMAN_WORKSPACE", &openhuman_dir);
let (api_addr, api_join) = serve_on_ephemeral(mock_upstream_router()).await;
let api_origin = format!("http://{api_addr}");
write_min_config(&openhuman_dir, &api_origin);
let (rpc_addr, rpc_join) = serve_on_ephemeral(build_core_http_router(false)).await;
let rpc_base = format!("http://{rpc_addr}");
// List profiles — should include built-in 'default' and 'reasoning'
let res = post_json_rpc(
&rpc_base,
9901_1,
"openhuman.agent_profiles_list",
json!({}),
)
.await;
let result = assert_no_jsonrpc_error(&res, "agent_profiles_list");
let profiles = result
.get("profiles")
.and_then(Value::as_array)
.expect("profiles array");
let profile_ids: Vec<&str> = profiles
.iter()
.filter_map(|p| p.get("id").and_then(Value::as_str))
.collect();
assert!(
profile_ids.contains(&"default"),
"should contain default profile"
);
assert!(
profile_ids.contains(&"reasoning"),
"should contain reasoning profile"
);
// Select reasoning profile
let res = post_json_rpc(
&rpc_base,
9901_2,
"openhuman.agent_profile_select",
json!({ "profile_id": "reasoning" }),
)
.await;
let result = assert_no_jsonrpc_error(&res, "agent_profile_select reasoning");
let active = result
.get("activeProfileId")
.and_then(Value::as_str)
.expect("activeProfileId");
assert_eq!(active, "reasoning");
// Verify the reasoning profile has hint:reasoning model override
let reasoning_profile = result
.get("profiles")
.and_then(Value::as_array)
.expect("profiles")
.iter()
.find(|p| p.get("id").and_then(Value::as_str) == Some("reasoning"))
.expect("reasoning profile in response");
let model_override = reasoning_profile
.get("modelOverride")
.and_then(Value::as_str)
.expect("modelOverride");
assert_eq!(model_override, "hint:reasoning");
// Resolve the model for this profile's override
let res = post_json_rpc(
&rpc_base,
9901_3,
"openhuman.inference_resolve_model",
json!({ "hint": model_override }),
)
.await;
let result = assert_no_jsonrpc_error(&res, "resolve_model for reasoning profile");
let resolved = result
.get("model")
.and_then(Value::as_str)
.expect("resolved model");
assert_eq!(resolved, "reasoning-v1");
// Switch back to default and resolve
let res = post_json_rpc(
&rpc_base,
9901_4,
"openhuman.agent_profile_select",
json!({ "profile_id": "default" }),
)
.await;
let result = assert_no_jsonrpc_error(&res, "agent_profile_select default");
assert_eq!(
result
.get("activeProfileId")
.and_then(Value::as_str)
.unwrap(),
"default"
);
// Default profile has no model_override — resolve with hint:chat
let res = post_json_rpc(
&rpc_base,
9901_5,
"openhuman.inference_resolve_model",
json!({ "hint": "hint:chat" }),
)
.await;
let result = assert_no_jsonrpc_error(&res, "resolve_model for default profile");
let resolved = result
.get("model")
.and_then(Value::as_str)
.expect("resolved model");
assert_eq!(resolved, "reasoning-quick-v1");
api_join.abort();
rpc_join.abort();
}