feat: refine Hermes settings UX

This commit is contained in:
Simos Mikelatos
2026-06-30 21:32:37 +00:00
parent 84fadad662
commit 7c6d00ee93
7 changed files with 143 additions and 164 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -29,7 +29,7 @@ export class HermesProviderAuth implements IProviderAuth {
authenticated: false, authenticated: false,
email: null, email: null,
method: null, method: null,
error: 'Hermes ACP is not installed', error: 'Hermes is not installed',
}; };
} }
@@ -39,8 +39,8 @@ export class HermesProviderAuth implements IProviderAuth {
installed, installed,
authenticated: credentials.authenticated, authenticated: credentials.authenticated,
email: credentials.email, email: credentials.email,
method: credentials.method, method: credentials.method ?? 'managed_by_hermes',
error: credentials.authenticated ? undefined : 'Hermes credentials were not found', error: undefined,
}; };
} }

View File

@@ -17,8 +17,8 @@ export const HERMES_FALLBACK_MODELS: ProviderModelsDefinition = {
OPTIONS: [ OPTIONS: [
{ {
value: HERMES_CONFIGURED_MODEL, value: HERMES_CONFIGURED_MODEL,
label: 'Configured in Hermes', label: 'Use Hermes default',
description: 'Uses the provider and model selected with `hermes model`.', description: 'Uses the provider and model selected in Hermes.',
}, },
], ],
DEFAULT: HERMES_CONFIGURED_MODEL, DEFAULT: HERMES_CONFIGURED_MODEL,
@@ -105,8 +105,8 @@ export class HermesProviderModels implements IProviderModels {
OPTIONS: [ OPTIONS: [
{ {
value: HERMES_CONFIGURED_MODEL, value: HERMES_CONFIGURED_MODEL,
label: 'Configured in Hermes', label: 'Use Hermes default',
description: `Current Hermes model: ${activeModel}`, description: `Uses the provider and model selected in Hermes. Current config: ${activeModel}`,
}, },
], ],
DEFAULT: HERMES_CONFIGURED_MODEL, DEFAULT: HERMES_CONFIGURED_MODEL,

View File

@@ -4,13 +4,11 @@ type HermesLogoProps = {
export default function HermesLogo({ className = 'w-5 h-5' }: HermesLogoProps) { export default function HermesLogo({ className = 'w-5 h-5' }: HermesLogoProps) {
return ( return (
<svg className={className} viewBox="0 0 24 24" role="img" aria-label="Hermes"> <img
<rect width="24" height="24" rx="6" fill="#047857" /> className={`${className} block object-contain`}
<path src="/icons/hermes-agent.png"
d="M6.2 6.5h2.4v4.3h6.8V6.5h2.4v11h-2.4v-4.6H8.6v4.6H6.2v-11Z" alt="Hermes"
fill="white" loading="lazy"
/> />
<path d="M9.3 4.7h5.4l-1.2 1.2h-3L9.3 4.7Z" fill="#A7F3D0" />
</svg>
); );
} }

View File

@@ -72,7 +72,7 @@ export default function AgentListItem({
}: AgentListItemProps) { }: AgentListItemProps) {
const config = agentConfig[agentId]; const config = agentConfig[agentId];
const colors = colorClasses[config.color]; const colors = colorClasses[config.color];
const isReady = agentId === 'hermes' ? authStatus.installed : authStatus.authenticated; const isReady = agentId !== 'hermes' && authStatus.authenticated;
if (isMobile) { if (isMobile) {
return ( return (

View File

@@ -1,5 +1,4 @@
import { import {
CheckCircle2,
KeyRound, KeyRound,
Layers3, Layers3,
LogIn, LogIn,
@@ -88,37 +87,20 @@ type HermesAction = {
icon: typeof Layers3; icon: typeof Layers3;
}; };
type HermesActionGroup = { const hermesActions: HermesAction[] = [
title: string;
actions: HermesAction[];
};
const hermesActionGroups: HermesActionGroup[] = [
{ {
title: 'Setup', label: 'Configure model',
actions: [ description: 'Choose the provider and model Hermes should use.',
{ command: 'hermes model',
label: 'Provider setup', title: 'Configure Hermes Model',
description: 'Configure provider credentials and the active model.', icon: Layers3,
command: 'hermes model', },
title: 'Hermes Provider Setup', {
icon: Layers3, label: 'Manage credentials',
}, description: 'Update credential pools and API keys.',
{ command: 'hermes auth',
label: 'Credential pools', title: 'Hermes Credentials',
description: 'Manage API keys and OAuth credentials.', icon: KeyRound,
command: 'hermes auth',
title: 'Hermes Credential Pools',
icon: KeyRound,
},
{
label: 'ACP check',
description: 'Validate the Hermes ACP adapter.',
command: 'hermes acp --check',
title: 'Hermes ACP Check',
icon: CheckCircle2,
},
],
}, },
]; ];
@@ -126,7 +108,6 @@ export default function AccountContent({ agent, authStatus, onLogin }: AccountCo
const { t } = useTranslation('settings'); const { t } = useTranslation('settings');
const config = agentConfig[agent]; const config = agentConfig[agent];
const isHermes = agent === 'hermes'; const isHermes = agent === 'hermes';
const hermesReady = authStatus.installed;
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -143,130 +124,118 @@ export default function AccountContent({ agent, authStatus, onLogin }: AccountCo
</div> </div>
<div className={`${config.bgClass} border ${config.borderClass} rounded-lg p-4`}> <div className={`${config.bgClass} border ${config.borderClass} rounded-lg p-4`}>
<div className="space-y-4"> {isHermes ? (
<div className="flex items-center gap-3"> <div className="space-y-4">
<div className="flex-1"> <div>
<div className={`font-medium ${config.textClass}`}> <div className={`font-medium ${config.textClass}`}>
{isHermes {t('agents.hermes.configuration.title', { defaultValue: 'Hermes configuration' })}
? t('agents.hermes.setupStatus.title', { defaultValue: 'Setup status' })
: t('agents.connectionStatus')}
</div> </div>
<div className={`text-sm ${config.subtextClass}`}> <div className={`text-sm ${config.subtextClass}`}>
{t('agents.hermes.configuration.description', {
defaultValue: 'Models and credentials are managed by Hermes.',
})}
</div>
</div>
<div className="grid gap-2 md:grid-cols-2">
{hermesActions.map((action, index) => {
const Icon = action.icon;
const isPrimary = index === 0;
return (
<Button
key={action.command}
type="button"
variant={isPrimary ? 'default' : 'outline'}
className={
isPrimary
? `${config.buttonClass} h-auto justify-start gap-3 px-3 py-2 text-left text-white`
: 'h-auto justify-start gap-3 border-border/70 bg-background/70 px-3 py-2 text-left'
}
onClick={() => onLogin(action.command, action.title)}
>
<Icon className="h-4 w-4 flex-shrink-0" />
<span className="min-w-0">
<span className={isPrimary ? 'block text-sm font-medium text-white' : 'block text-sm font-medium text-foreground'}>
{action.label}
</span>
<span className={isPrimary ? 'block text-xs text-white/80' : 'block text-xs text-muted-foreground'}>
{action.description}
</span>
</span>
</Button>
);
})}
</div>
</div>
) : (
<div className="space-y-4">
<div className="flex items-center gap-3">
<div className="flex-1">
<div className={`font-medium ${config.textClass}`}>
{t('agents.connectionStatus')}
</div>
<div className={`text-sm ${config.subtextClass}`}>
{authStatus.loading ? (
t('agents.authStatus.checkingAuth')
) : authStatus.authenticated ? (
t('agents.authStatus.loggedInAs', {
email: authStatus.email || t('agents.authStatus.authenticatedUser'),
})
) : (
t('agents.authStatus.notConnected')
)}
</div>
</div>
<div>
{authStatus.loading ? ( {authStatus.loading ? (
t('agents.authStatus.checkingAuth') <Badge variant="secondary" className="bg-muted">
) : isHermes ? ( {t('agents.authStatus.checking')}
hermesReady </Badge>
? t('agents.hermes.setupStatus.readyDescription', { defaultValue: 'Hermes ACP is installed. Credentials and models are managed by Hermes.' })
: t('agents.hermes.setupStatus.needsSetupDescription', { defaultValue: 'Install Hermes or run the ACP check to validate the adapter.' })
) : authStatus.authenticated ? ( ) : authStatus.authenticated ? (
t('agents.authStatus.loggedInAs', { <Badge variant="secondary" className="bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300">
email: authStatus.email || t('agents.authStatus.authenticatedUser'), {t('agents.authStatus.connected')}
}) </Badge>
) : ( ) : (
t('agents.authStatus.notConnected') <Badge variant="secondary" className="bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300">
{t('agents.authStatus.disconnected')}
</Badge>
)} )}
</div> </div>
</div> </div>
<div>
{authStatus.loading ? (
<Badge variant="secondary" className="bg-muted">
{t('agents.authStatus.checking')}
</Badge>
) : isHermes ? (
<Badge
variant="secondary"
className={
hermesReady
? 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-300'
: 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300'
}
>
{hermesReady
? t('agents.hermes.setupStatus.ready', { defaultValue: 'ACP ready' })
: t('agents.hermes.setupStatus.needsSetup', { defaultValue: 'Needs setup' })}
</Badge>
) : authStatus.authenticated ? (
<Badge variant="secondary" className="bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300">
{t('agents.authStatus.connected')}
</Badge>
) : (
<Badge variant="secondary" className="bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300">
{t('agents.authStatus.disconnected')}
</Badge>
)}
</div>
</div>
{!isHermes && authStatus.method !== 'api_key' && ( {authStatus.method !== 'api_key' && (
<div className="border-t border-border/50 pt-4"> <div className="border-t border-border/50 pt-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<div className={`font-medium ${config.textClass}`}> <div className={`font-medium ${config.textClass}`}>
{authStatus.authenticated ? t('agents.login.reAuthenticate') : t('agents.login.title')} {authStatus.authenticated ? t('agents.login.reAuthenticate') : t('agents.login.title')}
</div> </div>
<div className={`text-sm ${config.subtextClass}`}> <div className={`text-sm ${config.subtextClass}`}>
{authStatus.authenticated {authStatus.authenticated
? t('agents.login.reAuthDescription') ? t('agents.login.reAuthDescription')
: t('agents.login.description', { agent: config.name })} : t('agents.login.description', { agent: config.name })}
</div>
</div> </div>
<Button
onClick={() => onLogin()}
className={`${config.buttonClass} text-white`}
size="sm"
>
<LogIn className="mr-2 h-4 w-4" />
{authStatus.authenticated ? t('agents.login.reLoginButton') : t('agents.login.button')}
</Button>
</div> </div>
<Button
onClick={() => onLogin()}
className={`${config.buttonClass} text-white`}
size="sm"
>
<LogIn className="mr-2 h-4 w-4" />
{authStatus.authenticated ? t('agents.login.reLoginButton') : t('agents.login.button')}
</Button>
</div> </div>
</div> )}
)}
{isHermes && ( {authStatus.error && (
<div className="border-t border-border/50 pt-4"> <div className="border-t border-border/50 pt-4">
<div className={`mb-3 font-medium ${config.textClass}`}> <div className="text-sm text-red-600 dark:text-red-400">
{t('agents.hermes.actions.title', { defaultValue: 'Hermes tools' })} {t('agents.error', { error: authStatus.error })}
</div>
</div> </div>
<div className="space-y-4"> )}
{hermesActionGroups.map((group) => ( </div>
<div key={group.title}> )}
<div className={`mb-2 text-xs font-semibold uppercase ${config.subtextClass}`}>
{group.title}
</div>
<div className="grid gap-2 md:grid-cols-2">
{group.actions.map((action) => {
const Icon = action.icon;
return (
<Button
key={action.command}
type="button"
variant="outline"
className="h-auto justify-start gap-3 border-border/70 bg-background/70 px-3 py-2 text-left"
onClick={() => onLogin(action.command, action.title)}
>
<Icon className="h-4 w-4 flex-shrink-0" />
<span className="min-w-0">
<span className="block text-sm font-medium text-foreground">{action.label}</span>
<span className="block text-xs text-muted-foreground">{action.description}</span>
</span>
</Button>
);
})}
</div>
</div>
))}
</div>
</div>
)}
{authStatus.error && (
<div className="border-t border-border/50 pt-4">
<div className="text-sm text-red-600 dark:text-red-400">
{t('agents.error', { error: authStatus.error })}
</div>
</div>
)}
</div>
</div> </div>
</div> </div>
); );

View File

@@ -166,6 +166,14 @@ function hasServerEchoForLocalUser(
}); });
} }
function assistantEchoFingerprint(message: NormalizedMessage): string | null {
const content = (message.content || '').trim();
if (!content) {
return null;
}
return content.replace(/\s+/g, '');
}
function compareMessagesChronologically(a: NormalizedMessage, b: NormalizedMessage): number { function compareMessagesChronologically(a: NormalizedMessage, b: NormalizedMessage): number {
const timeA = readMessageTime(a) ?? 0; const timeA = readMessageTime(a) ?? 0;
const timeB = readMessageTime(b) ?? 0; const timeB = readMessageTime(b) ?? 0;
@@ -248,7 +256,7 @@ function isAssistantTextEchoedInSameTurnOnServer(
serverMessages: NormalizedMessage[], serverMessages: NormalizedMessage[],
realtimeMessages: NormalizedMessage[], realtimeMessages: NormalizedMessage[],
): boolean { ): boolean {
const assistantText = (message.content || '').trim(); const assistantText = assistantEchoFingerprint(message);
if (!assistantText) { if (!assistantText) {
return false; return false;
} }
@@ -264,7 +272,7 @@ function isAssistantTextEchoedInSameTurnOnServer(
.some((serverMessage) => .some((serverMessage) =>
serverMessage.kind === 'text' serverMessage.kind === 'text'
&& serverMessage.role === 'assistant' && serverMessage.role === 'assistant'
&& (serverMessage.content || '').trim() === assistantText, && assistantEchoFingerprint(serverMessage) === assistantText,
); );
} }
@@ -281,9 +289,9 @@ function dedupeAdjacentAssistantEchoes(merged: NormalizedMessage[]): NormalizedM
const prev = out[out.length - 1]; const prev = out[out.length - 1];
if (prev) { if (prev) {
if (prev.kind === 'stream_delta' && m.kind === 'text' && m.role === 'assistant') { if (prev.kind === 'stream_delta' && m.kind === 'text' && m.role === 'assistant') {
const ps = (prev.content || '').trim(); const ps = assistantEchoFingerprint(prev);
const ms = (m.content || '').trim(); const ms = assistantEchoFingerprint(m);
if (ps.length > 0 && ps === ms) { if (ps && ps === ms) {
out[out.length - 1] = m; out[out.length - 1] = m;
continue; continue;
} }
@@ -294,8 +302,12 @@ function dedupeAdjacentAssistantEchoes(merged: NormalizedMessage[]): NormalizedM
&& prev.role === 'assistant' && prev.role === 'assistant'
&& m.role === 'assistant' && m.role === 'assistant'
) { ) {
const ms = (m.content || '').trim(); const ps = assistantEchoFingerprint(prev);
if (ms.length > 0 && ms === (prev.content || '').trim()) { const ms = assistantEchoFingerprint(m);
if (ms && ms === ps) {
if ((m.content || '').length > (prev.content || '').length) {
out[out.length - 1] = m;
}
continue; continue;
} }
} }