mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat(chat): Quick/Reasoning toggle + resolved model display (#3325)
This commit is contained in:
@@ -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': 'الأدوات المسموح بها',
|
||||
|
||||
@@ -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': 'অনুমোদিত টুল',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'अनुमत उपकरण',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': '허용된 도구',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'Разрешенные инструменты',
|
||||
|
||||
@@ -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': '允许使用的工具',
|
||||
|
||||
@@ -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
@@ -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 = {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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?;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user