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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,4 @@
import {
CheckCircle2,
KeyRound,
Layers3,
LogIn,
@@ -88,37 +87,20 @@ type HermesAction = {
icon: typeof Layers3;
};
type HermesActionGroup = {
title: string;
actions: HermesAction[];
};
const hermesActionGroups: HermesActionGroup[] = [
const hermesActions: HermesAction[] = [
{
title: 'Setup',
actions: [
{
label: 'Provider setup',
description: 'Configure provider credentials and the active model.',
command: 'hermes model',
title: 'Hermes Provider Setup',
icon: Layers3,
},
{
label: 'Credential pools',
description: 'Manage API keys and OAuth credentials.',
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,
},
],
label: 'Configure model',
description: 'Choose the provider and model Hermes should use.',
command: 'hermes model',
title: 'Configure Hermes Model',
icon: Layers3,
},
{
label: 'Manage credentials',
description: 'Update credential pools and API keys.',
command: 'hermes auth',
title: 'Hermes Credentials',
icon: KeyRound,
},
];
@@ -126,7 +108,6 @@ export default function AccountContent({ agent, authStatus, onLogin }: AccountCo
const { t } = useTranslation('settings');
const config = agentConfig[agent];
const isHermes = agent === 'hermes';
const hermesReady = authStatus.installed;
return (
<div className="space-y-6">
@@ -143,130 +124,118 @@ export default function AccountContent({ agent, authStatus, onLogin }: AccountCo
</div>
<div className={`${config.bgClass} border ${config.borderClass} rounded-lg p-4`}>
<div className="space-y-4">
<div className="flex items-center gap-3">
<div className="flex-1">
{isHermes ? (
<div className="space-y-4">
<div>
<div className={`font-medium ${config.textClass}`}>
{isHermes
? t('agents.hermes.setupStatus.title', { defaultValue: 'Setup status' })
: t('agents.connectionStatus')}
{t('agents.hermes.configuration.title', { defaultValue: 'Hermes configuration' })}
</div>
<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 ? (
t('agents.authStatus.checkingAuth')
) : isHermes ? (
hermesReady
? 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.' })
<Badge variant="secondary" className="bg-muted">
{t('agents.authStatus.checking')}
</Badge>
) : authStatus.authenticated ? (
t('agents.authStatus.loggedInAs', {
email: authStatus.email || t('agents.authStatus.authenticatedUser'),
})
<Badge variant="secondary" className="bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300">
{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>
{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' && (
<div className="border-t border-border/50 pt-4">
<div className="flex items-center justify-between">
<div>
<div className={`font-medium ${config.textClass}`}>
{authStatus.authenticated ? t('agents.login.reAuthenticate') : t('agents.login.title')}
</div>
<div className={`text-sm ${config.subtextClass}`}>
{authStatus.authenticated
? t('agents.login.reAuthDescription')
: t('agents.login.description', { agent: config.name })}
{authStatus.method !== 'api_key' && (
<div className="border-t border-border/50 pt-4">
<div className="flex items-center justify-between">
<div>
<div className={`font-medium ${config.textClass}`}>
{authStatus.authenticated ? t('agents.login.reAuthenticate') : t('agents.login.title')}
</div>
<div className={`text-sm ${config.subtextClass}`}>
{authStatus.authenticated
? t('agents.login.reAuthDescription')
: t('agents.login.description', { agent: config.name })}
</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>
<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>
)}
)}
{isHermes && (
<div className="border-t border-border/50 pt-4">
<div className={`mb-3 font-medium ${config.textClass}`}>
{t('agents.hermes.actions.title', { defaultValue: 'Hermes tools' })}
{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 className="space-y-4">
{hermesActionGroups.map((group) => (
<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>
);

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 {
const timeA = readMessageTime(a) ?? 0;
const timeB = readMessageTime(b) ?? 0;
@@ -248,7 +256,7 @@ function isAssistantTextEchoedInSameTurnOnServer(
serverMessages: NormalizedMessage[],
realtimeMessages: NormalizedMessage[],
): boolean {
const assistantText = (message.content || '').trim();
const assistantText = assistantEchoFingerprint(message);
if (!assistantText) {
return false;
}
@@ -264,7 +272,7 @@ function isAssistantTextEchoedInSameTurnOnServer(
.some((serverMessage) =>
serverMessage.kind === 'text'
&& 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];
if (prev) {
if (prev.kind === 'stream_delta' && m.kind === 'text' && m.role === 'assistant') {
const ps = (prev.content || '').trim();
const ms = (m.content || '').trim();
if (ps.length > 0 && ps === ms) {
const ps = assistantEchoFingerprint(prev);
const ms = assistantEchoFingerprint(m);
if (ps && ps === ms) {
out[out.length - 1] = m;
continue;
}
@@ -294,8 +302,12 @@ function dedupeAdjacentAssistantEchoes(merged: NormalizedMessage[]): NormalizedM
&& prev.role === 'assistant'
&& m.role === 'assistant'
) {
const ms = (m.content || '').trim();
if (ms.length > 0 && ms === (prev.content || '').trim()) {
const ps = assistantEchoFingerprint(prev);
const ms = assistantEchoFingerprint(m);
if (ms && ms === ps) {
if ((m.content || '').length > (prev.content || '').length) {
out[out.length - 1] = m;
}
continue;
}
}