hermes(voice): workspace nav home, character orb, conversation rename, voice-lang fix
Final conversation-mode polish from mobile testing: - The Workspace toggle now sits with the chat/Telegram nav at every width: nav.rail on desktop, the top app titlebar on mobile. The floating pill that pushed the mobile composer's control row (and the conversation-mode button) off screen is gone - a fallback exists only for headless DOMs and is pinned to a top corner, never over the composer. - The conversation orb watermark is now the Hermes character avatar (static/hermes-agent-192.png) instead of the caduceus staff. - User-facing 'hands-free' copy renamed to 'Conversation mode'. - Wrong-voice fix: strongReplyLanguage flagged Spanish on a single accented char, so an English reply naming European cities (Zürich, Málaga) overrode the correct English STT detection and was spoken by the Spanish voice. Detection now requires density (Cyrillic >=4 at >=50%, or inverted punctuation / >=2 accents corroborated by Spanish stopwords); plain English always speaks English, forced language wins, accent-free Spanish still routes via trusted STT. 294 voice tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
This commit is contained in:
parent
4caf8942fd
commit
4d93ef5a0e
@ -433,24 +433,23 @@
|
||||
wave animations always read over it. Monochrome via currentColor at low
|
||||
opacity so it stays legible against every state tint without competing with
|
||||
the accent colour. Scales with the orb because it is inset-positioned. */
|
||||
/* FIX 2: the Hermes CHARACTER avatar (static/hermes-agent-192.png — the same
|
||||
mark the top app bar shows next to the conversation title), painted as a
|
||||
centred, low-opacity watermark that scales with the orb. It is a raster mark
|
||||
already served under /static, so it is referenced by URL rather than inlined.
|
||||
It carries no animation of its own (reduced-motion safe) and stays subtle
|
||||
enough to read across every state tint without masking the energy layers. */
|
||||
.voice-conversation-orb-mark {
|
||||
position: absolute;
|
||||
inset: 24%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
inset: 22%;
|
||||
pointer-events: none;
|
||||
color: rgba(233, 244, 255, 0.9);
|
||||
background-image: url(hermes-agent-192.png);
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
background-size: contain;
|
||||
opacity: 0.16;
|
||||
mix-blend-mode: screen;
|
||||
transition: opacity 200ms ease-out;
|
||||
}
|
||||
|
||||
.voice-conversation-orb-mark svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
filter: drop-shadow(0 0 6px rgba(var(--voice-accent), 0.3));
|
||||
transition: opacity 200ms ease-out;
|
||||
}
|
||||
|
||||
/* Speaking/thinking lift the watermark a touch so it feels alive with the turn,
|
||||
|
||||
@ -67,12 +67,11 @@
|
||||
}catch(_){return '';}
|
||||
})();
|
||||
const reducedMotion=window.matchMedia?window.matchMedia('(prefers-reduced-motion: reduce)'):{matches:false};
|
||||
// FIX 2: the app's own Hermes caduceus mark (static/favicon.svg), embedded
|
||||
// as a centred watermark inside the conversation orb. Rendered monochrome via
|
||||
// currentColor at low opacity so it reads across every state tint
|
||||
// (idle/listening/transcribing/thinking/speaking) without ever obscuring the
|
||||
// energy animation, and it carries no animation of its own (reduced-motion safe).
|
||||
const HERMES_MARK_SVG='<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg" focusable="false" aria-hidden="true"><g transform="translate(-12.326 -15.707) scale(0.07072)"><path fill="currentColor" fill-rule="evenodd" d="M630.5 961.9 C634.9 960.7 638.5 957.9 640.5 953.9 C642.5 950.1 643.3 865.1 641.4 864.3 C640 863.8 623.9 872.5 618.2 876.8 C616.4 878.2 613.8 881.2 612.5 883.5 L610 887.7 610 918.4 C610 951.8 610.2 953.1 615.7 958.3 C618.3 960.8 622.4 962.7 625.5 962.9 C626 963 628.3 962.5 630.5 961.9 Z M596 913 C596.8 911.5 596.6 909.4 595.4 904.8 C592.1 892.1 595.4 881.4 605.5 872.1 C612.9 865.4 621.2 860.6 641.1 851.4 C681.3 832.9 691.1 827.1 704.5 813.6 C724.9 793.1 730 768.6 718.9 745.5 C714.9 737.4 705.5 727.4 696.7 722.1 L691.1 718.8 678.3 722.6 C671.3 724.6 664.9 726.8 664.2 727.3 C663.5 727.9 663 730.6 663 734.1 C663 739.9 663.1 740 666.8 741.9 C672.9 745 680.6 752.6 683.4 758.2 C688.9 769.3 686 781.3 675.3 791 C666.4 799.1 662.1 801.7 631.3 817.2 C598.7 833.5 587.2 840.5 578.9 849 C565.9 862.3 561.8 880.1 568.3 894.4 C574.4 907.7 592.4 919.8 596 913 Z M579.8 832.2 C582.7 830.2 586.8 827.3 589 825.8 C592.9 823.1 593 822.9 593 817.2 L593 811.4 586.6 807.2 C578.4 801.8 572.5 795.2 568.7 787.2 C566 781.5 565.8 780.1 566.2 773.5 C566.8 764.5 569.4 759.6 577.8 751.8 C589.1 741.4 603.5 735.3 666 714.8 C687.7 707.6 710.2 699.7 715.9 697.2 C741.9 685.8 757.8 670 764.5 648.7 C765.9 644.4 767 639.2 767 637.1 C767 631.5 768.1 631 777.9 631.6 C801.3 633.2 819.4 623.2 829 603.6 C831.2 599.2 833.5 593.4 834.2 590.7 C835.3 586.4 835.2 585.8 833.4 584 C831.5 582.1 830.8 582 814 583 C804.4 583.5 789.8 584.1 781.5 584.2 L766.5 584.5 766.2 577.9 C766 573.4 766.3 571 767.2 570.2 C767.9 569.6 778.4 568.4 790.4 567.6 C835.7 564.3 849.7 561.9 862.2 555.3 C878.5 546.7 889.5 529.3 893 506.4 C894.1 499.3 894 498.6 892.3 496.8 C890.4 494.9 890 495 865.4 500.4 C838.7 506.3 789.4 516.1 776.8 518.1 C766.3 519.7 766 519.5 766 511.2 C766 507.2 766.5 503.7 767.2 502.8 C767.9 502 771.2 500.7 774.5 500.1 C788.7 497.1 852.9 480.7 868.5 476 C877.9 473.2 889.8 468.8 895 466.3 C903 462.5 905.8 460.4 913.1 453.1 C922.9 443.2 928.3 433.9 932.5 419.5 C935.4 409.5 937.6 393.5 936.6 389.7 C935.3 384.3 933.5 384.5 914.3 392.1 C879.6 406 825.4 423.1 754.6 442.4 C728.5 449.6 719.1 452.5 717.2 454.2 C711.9 459.2 712 457.7 712 516.7 C712 551.6 711.6 572.9 710.9 575.2 C709.6 580 703.8 585.7 699.1 587 C697.1 587.5 689.9 588.2 683 588.6 C674 589.1 670 589.7 668.8 590.8 C667.2 592.1 667 594.3 667 608 C667 621.6 667.2 624.1 668.8 625.8 C670.4 627.8 671.8 627.9 694.9 628.2 C710.4 628.4 719.8 628.9 720.8 629.6 C723.1 631.2 720.6 639.8 715.9 646.9 C706.4 661.1 694.2 667.1 631 689 C581.4 706.1 563.9 713.8 550.3 724.7 C512.3 755 518.4 806 563.1 831.3 C567.7 833.9 572.2 836 573.1 836 C573.9 836 577 834.3 579.8 832.2 Z M626.3 807.4 C633.5 803.8 640.2 800.1 641.1 799.4 C642.5 798.1 642.7 794.2 642.5 766.5 C642.5 749.2 642.1 734.7 641.7 734.4 C641 733.7 613.6 743.6 611.2 745.3 C610.3 746 610 754.1 610 779.5 C610 797.7 610.3 813 610.7 813.3 C611.8 814.5 612.7 814.1 626.3 807.4 Z M571.1 699.2 L584.5 693.4 584.8 685.5 C585 681.2 584.7 677.3 584.1 676.7 C583.6 676.2 579.9 674.7 575.8 673.3 C567.5 670.5 554.7 664.2 548.4 660 C538.6 653.2 530.5 640.3 531.2 632.6 L531.5 629.5 541 628.9 C546.2 628.5 557.7 628.2 566.5 628.1 C577.6 628 583 627.6 583.8 626.8 C585.5 625.1 585.5 591.6 583.8 590.3 C583.1 589.7 576.5 589 569.1 588.6 C555.2 587.9 550.4 586.7 546.1 582.7 C541 578 541 578.1 541 518.8 C541 466.4 540.9 463.3 539 459.3 C536.5 453.7 533.3 451.9 518.7 448.1 C442.4 428 363.2 402.9 330.3 388.3 C322.4 384.9 322 384.8 319.5 386.4 C317.3 387.9 317 388.7 317 393.9 C317 397.1 317.7 403.5 318.5 408.1 C324.3 439.3 338.4 458 364.5 469.2 C374.6 473.5 396.4 479.7 441.3 491 C464.8 496.9 484.7 502.3 485.5 503 C486.6 503.9 487 506.3 487 511.2 C487 519.5 486.6 519.8 476.7 518.1 C461.5 515.5 412.5 505.6 391.5 500.9 C379.4 498.2 368.3 495.7 366.7 495.3 C364.8 494.9 363.3 495.3 361.9 496.6 C360 498.3 359.9 499.2 360.5 505.4 C362.5 526 373.6 544.9 388.9 554 C401.6 561.5 417.9 564.4 468 568.1 C477.1 568.7 485.1 569.7 485.8 570.3 C487.5 571.7 487.4 582.4 485.6 583.9 C484.2 585.1 473.4 584.7 432.5 582.4 C422.2 581.8 421.4 581.9 419.7 583.8 C417.9 585.8 417.9 586.1 419.5 591.6 C424.1 607.4 434.2 620.5 446.4 626.5 C455.1 630.8 461.3 632 474.1 632 C479.5 632 484.1 632.4 484.4 632.9 C484.8 633.4 485.6 637.4 486.4 641.7 C489.7 660.4 500.4 676.9 516.5 688 C526.3 694.7 549.1 704.8 555.1 704.9 C556.5 705 563.7 702.4 571.1 699.2 Z M628.9 678.9 C635.3 676.6 641 674.2 641.5 673.5 C643.1 671.6 642.5 576.3 640.9 574.4 C639.4 572.5 613.1 572.3 611.2 574.2 C610.3 575.1 610 588.7 610 629 C610 658.5 610.3 683 610.7 683.4 C611.8 684.5 616 683.5 628.9 678.9 Z M636.1 556 C654.1 552.6 669.6 536.9 673.6 517.8 C677.9 497.7 668.2 476.3 650 465.8 C636.2 457.8 615.8 458 601.7 466.3 C595.3 470.1 586.1 480.5 582.7 487.8 C570 514.9 585.4 547.4 614.7 555.5 C620.6 557.1 629 557.3 636.1 556 Z"/></g></svg>';
|
||||
// FIX 2: the conversation orb's centred watermark is the Hermes CHARACTER
|
||||
// avatar (static/hermes-agent-192.png) — the very mark the top app bar shows
|
||||
// next to the conversation title — painted by .voice-conversation-orb-mark in
|
||||
// atlas-voice.css as a low-opacity, non-animating (reduced-motion safe) layer
|
||||
// that scales with the orb and reads across every state tint.
|
||||
const ERROR_VISIBLE_MS=3200;
|
||||
const STREAMING_CAPABILITY_URL='/api/voice/streaming/capability';
|
||||
const TTS_STREAM_URL='/api/tts/stream';
|
||||
@ -188,26 +187,47 @@
|
||||
return language;
|
||||
}
|
||||
|
||||
const SPANISH_ORTHOGRAPHY=/[áéíóúñü¡¿]/i;
|
||||
// Inverted punctuation is exclusive to Spanish and decisive on its own.
|
||||
const SPANISH_UNIQUE=/[¡¿]/;
|
||||
// Accented vowels and ñ also occur in English loanwords and European place
|
||||
// names (Zürich, Málaga, Genève, jalapeño), so they signal Spanish only
|
||||
// alongside real Spanish stopword density — never on their own.
|
||||
const SPANISH_ACCENTS=/[áéíóúüñ]/gi;
|
||||
const SPANISH_STOPWORDS=/\b(?:el|la|los|las|un|una|es|está|qué|para|por|con|pero|como|más|sí|gracias|hola|puedo|también|muy|este|esta|todo|bien)\b/g;
|
||||
const CYRILLIC_CHARS=/[Ѐ-ӿ]/g;
|
||||
const WORD_LETTERS=/[A-Za-zÀ-ÿЀ-ӿ]/g;
|
||||
|
||||
function strongReplyLanguage(text){
|
||||
// Script-level certainty only: Cyrillic text is Russian; Spanish
|
||||
// orthography (accents, ñ, inverted punctuation) is Spanish. Plain-ASCII
|
||||
// text yields no signal, so an English reply never flips a trusted
|
||||
// STT-detected voice.
|
||||
// Decisive script/orthography evidence in the REPLY text only. This is
|
||||
// ranked ABOVE the trusted STT detection, so it must never fire on
|
||||
// incidental foreign glyphs: a lone accented European place name, or a
|
||||
// single stray Cyrillic letter inside otherwise-English prose, keeps the
|
||||
// English (Amy) voice. Russian needs Cyrillic-letter density; Spanish needs
|
||||
// inverted punctuation, or accent/ñ density corroborated by Spanish
|
||||
// stopword density. (Root cause of the "English reply about Europe spoken in
|
||||
// a foreign voice" bug: the old rule returned 'es' for a single accented
|
||||
// char and 'ru' for a single Cyrillic char, overriding a correct 'en' STT.)
|
||||
const sample=String(text||'').slice(0,400);
|
||||
if(/[Ѐ-ӿ]/.test(sample)) return 'ru';
|
||||
if(SPANISH_ORTHOGRAPHY.test(sample)) return 'es';
|
||||
const letters=(sample.match(WORD_LETTERS)||[]).length;
|
||||
const cyrillic=(sample.match(CYRILLIC_CHARS)||[]).length;
|
||||
if(cyrillic>=4&&letters>0&&cyrillic/letters>=0.5) return 'ru';
|
||||
if(SPANISH_UNIQUE.test(sample)) return 'es';
|
||||
const accents=(sample.match(SPANISH_ACCENTS)||[]).length;
|
||||
if(accents>=2){
|
||||
const lower=sample.toLowerCase();
|
||||
const words=lower.split(/\s+/).filter(Boolean);
|
||||
const stops=(lower.match(SPANISH_STOPWORDS)||[]).length;
|
||||
if(words.length>=4&&stops>=2&&stops/words.length>=0.12) return 'es';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function detectReplyLanguage(text){
|
||||
// Lightweight reply-language heuristic for turns without a trusted STT
|
||||
// detection: script evidence first, then Spanish stopword density (an
|
||||
// accent-free Spanish sentence still routes to the Spanish voice).
|
||||
// Returns '' for English/unknown, which the private TTS service resolves
|
||||
// to its own English default voice.
|
||||
// detection: decisive script/orthography evidence first, then accent-free
|
||||
// Spanish stopword density (an accent-free Spanish sentence still routes to
|
||||
// the Spanish voice). Returns '' for English/unknown, which the private TTS
|
||||
// service resolves to its own English default voice.
|
||||
const strong=strongReplyLanguage(text);
|
||||
if(strong) return strong;
|
||||
const sample=String(text||'').slice(0,400).toLowerCase();
|
||||
@ -219,6 +239,23 @@
|
||||
return '';
|
||||
}
|
||||
|
||||
function resolveReplyLanguage(text,sttLanguage,forced){
|
||||
// The single source of truth for which Piper voice reads a reply. A user
|
||||
// force wins outright; otherwise decisive reply-text evidence corrects a
|
||||
// wrong or missing STT detection; otherwise the turn's trusted STT-detected
|
||||
// language is spoken; otherwise the accent-free stopword heuristic. Plain
|
||||
// English prose — including a reply full of European proper nouns — carries
|
||||
// no decisive evidence and matches no STT hint of its own, so it can never be
|
||||
// flipped off the English voice by a previous turn's sticky value: only a
|
||||
// real force or real reply-text evidence moves it.
|
||||
if(forced) return forced;
|
||||
const strong=strongReplyLanguage(text);
|
||||
if(strong) return strong;
|
||||
const stt=normalizeSttLanguage(sttLanguage);
|
||||
if(stt) return stt;
|
||||
return detectReplyLanguage(text)||'';
|
||||
}
|
||||
|
||||
function toast(message){
|
||||
if(typeof window.showToast==='function') window.showToast(message,3000);
|
||||
}
|
||||
@ -594,9 +631,10 @@
|
||||
orb.appendChild(conversationNode('span','voice-conversation-orb-halo'));
|
||||
orb.appendChild(conversationNode('span','voice-conversation-orb-core'));
|
||||
orb.appendChild(conversationNode('span','voice-conversation-orb-ring'));
|
||||
// FIX 2: Hermes mark watermark, centred in the orb beneath the energy layers.
|
||||
// FIX 2: the Hermes character-avatar watermark, centred in the orb beneath
|
||||
// the energy layers. It is a presentation-only span; atlas-voice.css paints
|
||||
// static/hermes-agent-192.png (the top-app-bar character mark) into it.
|
||||
const orbMark=conversationNode('span','voice-conversation-orb-mark',{'aria-hidden':'true'});
|
||||
orbMark.innerHTML=HERMES_MARK_SVG;
|
||||
orb.appendChild(orbMark);
|
||||
const stateEl=conversationNode('div','voice-conversation-state',{'aria-hidden':'true'});
|
||||
const captions=conversationNode('div','voice-conversation-captions',{'aria-live':'polite'});
|
||||
@ -966,7 +1004,7 @@
|
||||
outputDevices=[];
|
||||
modeBtn.classList.remove('active');
|
||||
setState('idle');
|
||||
if(showMessage) toast('Hands-free voice mode off');
|
||||
if(showMessage) toast('Conversation mode off');
|
||||
}
|
||||
|
||||
function restartSoon(token,delay){
|
||||
@ -2778,7 +2816,7 @@
|
||||
// the next streaming STT turn recognizes the switch promptly.
|
||||
// A user-forced language (FIX 3) wins over every auto signal: the reply is
|
||||
// spoken by the chosen voice regardless of script evidence or detection.
|
||||
const resolved=forcedLanguage||strongReplyLanguage(text)||turn.sttLanguage||detectReplyLanguage(text);
|
||||
const resolved=resolveReplyLanguage(text,turn.sttLanguage,forcedLanguage);
|
||||
if(resolved) turn.language=resolved;
|
||||
if(!forcedLanguage&&!turn.sttLanguage&&resolved) sessionLanguage=resolved;
|
||||
// One audio timeline, one voice: a new logical message after an interim
|
||||
@ -2887,7 +2925,7 @@
|
||||
outputDevices=[];
|
||||
finalizeAttempts=0;
|
||||
modeBtn.classList.add('active');
|
||||
toast('Hands-free private voice mode on');
|
||||
toast('Conversation mode on');
|
||||
openConversationOverlay();
|
||||
if(typeof window.stopTTS==='function') window.stopTTS();
|
||||
if(typeof S!=='undefined'&&S.busy){setState('thinking');return;}
|
||||
@ -2971,6 +3009,13 @@
|
||||
adaptiveChunks:adaptiveChunks,
|
||||
sentenceEnd:sentenceEnd,
|
||||
cleanForSpeech:cleanForSpeech,
|
||||
// Pure, behaviour-neutral reply-language routing helpers so a probe can
|
||||
// regression-lock FIX 4: plain English (European proper nouns included)
|
||||
// never leaves the English voice, Cyrillic → ru, clear Spanish → es, and a
|
||||
// user force always wins.
|
||||
strongReplyLanguage:strongReplyLanguage,
|
||||
detectReplyLanguage:detectReplyLanguage,
|
||||
resolveReplyLanguage:resolveReplyLanguage,
|
||||
// Read-only, behaviour-neutral: lets a deterministic probe observe that the
|
||||
// interim fold never leaves an unspoken tail outstanding while the state
|
||||
// has fallen back to Thinking (the dropped-tail regression).
|
||||
|
||||
53
dockerfiles/hermes-webui-hux/bootstrap.css
vendored
53
dockerfiles/hermes-webui-hux/bootstrap.css
vendored
@ -1,8 +1,12 @@
|
||||
/* Floating fallback: only rendered when NO nav home exists (headless/degenerate
|
||||
DOMs). Pinned to the TOP-right corner — never the bottom — so it can never
|
||||
overlap, push, or reflow the message composer's bottom control row. In the
|
||||
real app both nav homes exist, so this never appears. */
|
||||
.hux-workspace-toggle {
|
||||
background: color-mix(in srgb, var(--bg-primary, #0a101b) 88%, #174653);
|
||||
border: 1px solid rgb(94 231 215 / 45%);
|
||||
border-radius: 999px;
|
||||
bottom: max(5.8rem, calc(env(safe-area-inset-bottom) + 4.5rem));
|
||||
top: max(0.6rem, env(safe-area-inset-top));
|
||||
box-shadow: 0 10px 32px rgb(0 0 0 / 32%);
|
||||
color: var(--text-primary, #e7eef6);
|
||||
cursor: pointer;
|
||||
@ -13,6 +17,43 @@
|
||||
z-index: 115;
|
||||
}
|
||||
|
||||
/* Mobile top-app-bar Workspace toggle: an icon-only button beside the app's own
|
||||
new-chat/reload icons. Shown only below the rail breakpoint (where the rail —
|
||||
and its own toggle — is hidden), so exactly one Workspace entry is ever
|
||||
visible and nothing floats over the composer. */
|
||||
.hux-workspace-titlebar-toggle {
|
||||
align-items: center;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
color: var(--muted, #9fb0c0);
|
||||
cursor: pointer;
|
||||
display: none;
|
||||
flex-shrink: 0;
|
||||
height: 44px;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
width: 44px;
|
||||
-webkit-app-region: no-drag;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.hux-workspace-titlebar-toggle:hover {
|
||||
background: var(--hover-bg, rgb(94 231 215 / 12%));
|
||||
color: var(--text, #e7eef6);
|
||||
}
|
||||
|
||||
.hux-workspace-titlebar-toggle[aria-expanded="true"] {
|
||||
background: var(--accent-bg, rgb(94 231 215 / 18%));
|
||||
color: var(--accent-text, var(--text, #e7eef6));
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.hux-workspace-titlebar-toggle {
|
||||
display: inline-flex;
|
||||
}
|
||||
}
|
||||
|
||||
.hux-workspace-drawer {
|
||||
background: color-mix(in srgb, var(--bg-primary, #0a101b) 97%, #102f3a);
|
||||
border-left: 1px solid rgb(118 181 193 / 30%);
|
||||
@ -72,6 +113,7 @@
|
||||
|
||||
.hux-workspace-toggle:focus-visible,
|
||||
.hux-workspace-toggle-rail:focus-visible,
|
||||
.hux-workspace-titlebar-toggle:focus-visible,
|
||||
.hux-workspace-drawer button:focus-visible {
|
||||
outline: 2px solid #5ee7d7;
|
||||
outline-offset: 2px;
|
||||
@ -84,15 +126,6 @@
|
||||
color: var(--accent-text, var(--text, #e7eef6));
|
||||
}
|
||||
|
||||
/* Where the rail is visible (upstream shows it from 641px up) the floating
|
||||
fallback toggle disappears; below that the rail itself is hidden and the
|
||||
floating button remains the only entry point, so no width loses access. */
|
||||
@media (min-width: 641px) {
|
||||
.hux-workspace-toggle--railed {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.hux-workspace-drawer {
|
||||
width: 100%;
|
||||
|
||||
77
dockerfiles/hermes-webui-hux/bootstrap.js
vendored
77
dockerfiles/hermes-webui-hux/bootstrap.js
vendored
@ -233,13 +233,24 @@
|
||||
}
|
||||
|
||||
function railHost(doc) {
|
||||
// The app's left icon rail (chat, tasks, telegram, …). Optional: bundles
|
||||
// without it (and headless harnesses) keep the floating toggle.
|
||||
// The app's left icon rail (chat, tasks, telegram, …), the desktop nav home
|
||||
// (shown from ~641px up). Optional: bundles without it fall through to the
|
||||
// top app bar, then to the floating toggle.
|
||||
if (typeof doc.querySelector !== 'function') return null;
|
||||
const rail = doc.querySelector('nav.rail');
|
||||
return rail && typeof rail.insertBefore === 'function' ? rail : null;
|
||||
}
|
||||
|
||||
function titlebarHost(doc) {
|
||||
// The top app bar (hamburger · avatar · title · new-chat · reload) — the
|
||||
// mobile nav home, always visible below the rail breakpoint where the rail
|
||||
// collapses. Mounting the Workspace toggle here keeps it beside the app's
|
||||
// own icons instead of floating over the composer.
|
||||
if (typeof doc.querySelector !== 'function') return null;
|
||||
const bar = doc.querySelector('header.app-titlebar');
|
||||
return bar && typeof bar.insertBefore === 'function' ? bar : null;
|
||||
}
|
||||
|
||||
function railIcon(doc) {
|
||||
try {
|
||||
if (typeof doc.createElementNS !== 'function') return null;
|
||||
@ -258,27 +269,38 @@
|
||||
}
|
||||
}
|
||||
|
||||
function navIconToggle(doc, cls) {
|
||||
// An icon-only Workspace entry that sits WITH the app's own nav items and is
|
||||
// styled by their rules. Shared by the desktop rail and the mobile top app
|
||||
// bar so both look native beside their siblings.
|
||||
const toggle = node(doc, 'button', {'class': cls, type: 'button',
|
||||
'aria-controls': 'huxWorkspaceDrawer', 'aria-expanded': 'false',
|
||||
'data-tooltip': 'Workspace', 'aria-label': 'Workspace'});
|
||||
const icon = railIcon(doc);
|
||||
if (icon) toggle.appendChild(icon);
|
||||
else toggle.textContent = '⧉';
|
||||
return toggle;
|
||||
}
|
||||
|
||||
function createChrome(doc) {
|
||||
// The Workspace toggle mounts WITH the other nav items at every width and
|
||||
// never floats over the composer: the desktop icon rail (nav.rail) hands off
|
||||
// to the mobile top app bar (header.app-titlebar), each showing exactly one
|
||||
// toggle via bootstrap.css. A floating button appears ONLY when neither nav
|
||||
// home exists (headless DOMs), pinned to a TOP corner clear of the composer.
|
||||
const rail = railHost(doc);
|
||||
const open = node(doc, 'button', {'class': rail ?
|
||||
'hux-workspace-toggle hux-workspace-toggle--railed' : 'hux-workspace-toggle',
|
||||
type: 'button', 'aria-controls': 'huxWorkspaceDrawer', 'aria-expanded': 'false'}, 'Workspace');
|
||||
const titlebar = titlebarHost(doc);
|
||||
let railToggle = null;
|
||||
if (rail) {
|
||||
// Rail placement: an icon-only item beside the app's own tabs, styled
|
||||
// by the upstream .rail-btn/.has-tooltip rules. The floating button
|
||||
// stays in the DOM as the below-rail-breakpoint fallback and is hidden
|
||||
// by bootstrap.css wherever the rail is visible, so nothing floats
|
||||
// over the chat. Drawer behavior is unchanged.
|
||||
railToggle = node(doc, 'button', {'class': 'rail-btn has-tooltip hux-workspace-toggle-rail',
|
||||
type: 'button', 'aria-controls': 'huxWorkspaceDrawer', 'aria-expanded': 'false',
|
||||
'data-tooltip': 'Workspace', 'aria-label': 'Workspace'});
|
||||
const icon = railIcon(doc);
|
||||
if (icon) railToggle.appendChild(icon);
|
||||
else railToggle.textContent = '⧉';
|
||||
railToggle = navIconToggle(doc, 'rail-btn has-tooltip hux-workspace-toggle-rail');
|
||||
const spacer = typeof rail.querySelector === 'function' ? rail.querySelector('.rail-spacer') : null;
|
||||
rail.insertBefore(railToggle, spacer || null);
|
||||
}
|
||||
let titlebarToggle = null;
|
||||
if (titlebar) {
|
||||
titlebarToggle = navIconToggle(doc, 'hux-workspace-titlebar-toggle has-tooltip has-tooltip--bottom');
|
||||
titlebar.insertBefore(titlebarToggle, null);
|
||||
}
|
||||
const drawer = node(doc, 'aside', {id: 'huxWorkspaceDrawer', 'class': 'hux-workspace-drawer',
|
||||
'aria-labelledby': 'huxWorkspaceTitle'});
|
||||
const header = node(doc, 'header', {'class': 'hux-workspace-drawer__header'});
|
||||
@ -287,22 +309,29 @@
|
||||
const content = node(doc, 'div', {'class': 'hux-workspace-drawer__content'});
|
||||
drawer.hidden = true;
|
||||
header.appendChild(title); header.appendChild(close); drawer.appendChild(header); drawer.appendChild(content);
|
||||
doc.body.appendChild(open); doc.body.appendChild(drawer);
|
||||
doc.body.appendChild(drawer);
|
||||
let floating = null;
|
||||
if (!rail && !titlebar) {
|
||||
floating = node(doc, 'button', {'class': 'hux-workspace-toggle', type: 'button',
|
||||
'aria-controls': 'huxWorkspaceDrawer', 'aria-expanded': 'false'}, 'Workspace');
|
||||
doc.body.appendChild(floating);
|
||||
}
|
||||
const toggles = [railToggle, titlebarToggle, floating].filter(Boolean);
|
||||
const open = floating || railToggle || titlebarToggle;
|
||||
function setOpen(value) {
|
||||
drawer.hidden = !value; open.setAttribute('aria-expanded', value ? 'true' : 'false');
|
||||
if (railToggle) railToggle.setAttribute('aria-expanded', value ? 'true' : 'false');
|
||||
drawer.hidden = !value;
|
||||
toggles.forEach((toggle) => toggle.setAttribute('aria-expanded', value ? 'true' : 'false'));
|
||||
if (value && typeof close.focus === 'function') close.focus();
|
||||
}
|
||||
const onOpen = () => setOpen(true);
|
||||
const onClose = () => setOpen(false);
|
||||
const onKey = (event) => { if (event.key === 'Escape' && !drawer.hidden) setOpen(false); };
|
||||
open.addEventListener('click', onOpen); close.addEventListener('click', onClose);
|
||||
if (railToggle) railToggle.addEventListener('click', onOpen);
|
||||
toggles.forEach((toggle) => toggle.addEventListener('click', onOpen));
|
||||
close.addEventListener('click', onClose);
|
||||
doc.addEventListener('keydown', onKey);
|
||||
return Object.freeze({content, drawer, open, railToggle, setOpen, destroy() {
|
||||
return Object.freeze({content, drawer, open, railToggle, titlebarToggle, setOpen, destroy() {
|
||||
doc.removeEventListener('keydown', onKey);
|
||||
if (open.parentNode) open.parentNode.removeChild(open);
|
||||
if (railToggle && railToggle.parentNode) railToggle.parentNode.removeChild(railToggle);
|
||||
toggles.forEach((toggle) => { if (toggle.parentNode) toggle.parentNode.removeChild(toggle); });
|
||||
if (drawer.parentNode) drawer.parentNode.removeChild(drawer);
|
||||
}});
|
||||
}
|
||||
|
||||
@ -535,6 +535,36 @@ scenarios.reply_script_evidence_corrects_wrong_detection = async () => {
|
||||
return { tts: harness.ttsRequests };
|
||||
};
|
||||
|
||||
// FIX 4: reply-language resolution driven directly through the shipped pure
|
||||
// helpers. Plain English prose full of accented European place names must never
|
||||
// be flipped off the English voice; Cyrillic → ru; clear Spanish → es; a user
|
||||
// force always wins. (Root cause of the live bug: the old strongReplyLanguage
|
||||
// returned 'es' for a single accented char and outranked a correct 'en' STT.)
|
||||
scenarios.reply_language_resolution = async () => {
|
||||
const harness = makeHarness();
|
||||
await harness.flush();
|
||||
const internals = harness.context.__atlasVoiceInternals;
|
||||
const strong = (text) => internals.strongReplyLanguage(text);
|
||||
const resolve = (text, stt, forced) => internals.resolveReplyLanguage(text, stt || '', forced || '');
|
||||
const europe = 'Here is something interesting about Europe: Zürich, München and Málaga '
|
||||
+ 'are lovely, and the café in Genève is famous. Kraków is worth a visit too.';
|
||||
const cyrillic = 'Вот что интересно о Европе: Цюрих, Мюнхен и Малага прекрасны, '
|
||||
+ 'а кафе в Женеве знаменито, и по всему городу очень вкусная еда.';
|
||||
const spanish = 'Aquí tienes algo interesante sobre Europa: Zúrich, Múnich y Málaga '
|
||||
+ 'son preciosas, y la comida está muy buena en toda la ciudad.';
|
||||
return {
|
||||
europeStrong: strong(europe),
|
||||
europeDetectionless: resolve(europe, '', ''),
|
||||
europeWithEnglishStt: resolve(europe, 'en', ''),
|
||||
cyrillicStrong: strong(cyrillic),
|
||||
cyrillicDetectionless: resolve(cyrillic, '', ''),
|
||||
spanishStrong: strong(spanish),
|
||||
spanishDetectionless: resolve(spanish, '', ''),
|
||||
forcedOverridesEnglishText: resolve(europe, 'en', 'ru'),
|
||||
forcedOverridesCyrillic: resolve(cyrillic, '', 'en'),
|
||||
};
|
||||
};
|
||||
|
||||
scenarios.canonical_pcm_fallback_builds_a_valid_wav = async () => {
|
||||
const harness = makeHarness();
|
||||
await harness.flush();
|
||||
|
||||
@ -433,9 +433,12 @@ def test_conversation_language_selector_source_and_style_contract():
|
||||
assert "function selectConversationLanguage(code)" in source
|
||||
assert "aria-haspopup" in source and "'aria-expanded':'false'" in source
|
||||
assert "role:'menuitemradio'" in source
|
||||
# Forced language overrides both STT (start hint) and the reply voice.
|
||||
# Forced language overrides both STT (start hint) and the reply voice: it is
|
||||
# the first argument-gated branch of the reply-language resolver.
|
||||
assert "language:forcedLanguage||sessionLanguage||'auto'" in source
|
||||
assert "const resolved=forcedLanguage||strongReplyLanguage(text)" in source
|
||||
assert "const resolved=resolveReplyLanguage(text,turn.sttLanguage,forcedLanguage)" in source
|
||||
assert "function resolveReplyLanguage(text,sttLanguage,forced)" in source
|
||||
assert "if(forced) return forced;" in source
|
||||
# Session-only: the forced language never touches localStorage.
|
||||
assert "localStorage" not in source.split("CONVERSATION_LANGUAGES", 1)[1].split("function openConversationOverlay", 1)[0]
|
||||
# Escape peels the menu before exiting; the language button joins the trap.
|
||||
@ -448,21 +451,30 @@ def test_conversation_language_selector_source_and_style_contract():
|
||||
|
||||
|
||||
def test_conversation_orb_hermes_mark_source_and_style_contract():
|
||||
"""FIX 2: the Hermes caduceus mark is embedded in the orb as a static,
|
||||
low-opacity watermark that respects reduced motion and never animates."""
|
||||
"""FIX 2: the orb watermark is the Hermes CHARACTER avatar (the same
|
||||
hermes-agent-192.png the top app bar shows), painted as a static, low-opacity
|
||||
layer that scales with the orb, respects reduced motion and never animates —
|
||||
NOT the caduceus/staff SVG it used to embed."""
|
||||
source = VOICE_SCRIPT.read_text(encoding="utf-8")
|
||||
css = (ROOT / "dockerfiles" / "hermes-webui-atlas-voice.css").read_text(encoding="utf-8")
|
||||
|
||||
assert "const HERMES_MARK_SVG=" in source
|
||||
assert 'fill-rule="evenodd"' in source # the real favicon caduceus path
|
||||
# The span is still created, but the staff SVG is gone from both the source
|
||||
# and the DOM — the character mark now comes from CSS, not inline markup.
|
||||
assert "'voice-conversation-orb-mark'" in source
|
||||
assert "orbMark.innerHTML=HERMES_MARK_SVG" in source
|
||||
assert "HERMES_MARK_SVG" not in source
|
||||
assert 'fill-rule="evenodd"' not in source # the old caduceus path is removed
|
||||
assert "orbMark.innerHTML" not in source
|
||||
|
||||
assert ".voice-conversation-orb-mark" in css
|
||||
# Monochrome via currentColor at low opacity; carries no animation of its own.
|
||||
assert "color: rgba(233, 244, 255, 0.9)" in css
|
||||
mark_rule = css.split(".voice-conversation-orb-mark {", 1)[1].split("}", 1)[0]
|
||||
assert "animation" not in mark_rule
|
||||
# The character avatar — the exact asset the top-bar avatar references —
|
||||
# painted as a scaling, centred, low-opacity watermark with no animation.
|
||||
assert "url(hermes-agent-192.png)" in mark_rule
|
||||
assert "background-size: contain" in mark_rule
|
||||
assert "opacity: 0.16" in mark_rule
|
||||
assert "animation" not in mark_rule
|
||||
# The staff-era monochrome currentColor tint is gone.
|
||||
assert "color: rgba(233, 244, 255, 0.9)" not in css
|
||||
|
||||
|
||||
def test_conversation_thinking_working_affordance_contract():
|
||||
|
||||
@ -885,3 +885,35 @@ def test_reply_script_evidence_corrects_wrong_detection(voice_probe):
|
||||
assert requests, "voice mode never reached /api/tts"
|
||||
for request in requests:
|
||||
assert request["language"] == "es"
|
||||
|
||||
|
||||
def test_english_reply_full_of_european_names_stays_on_the_english_voice(voice_probe):
|
||||
"""FIX 4: an English paragraph packed with accented European proper nouns
|
||||
(Zürich, München, Málaga, café, Kraków) carries no decisive Spanish/Russian
|
||||
evidence, so it never trips a foreign voice. A single accented place name used
|
||||
to make strongReplyLanguage return 'es' and outrank a correct 'en' STT — the
|
||||
root cause of the live "English reply about Europe spoken in a foreign voice"
|
||||
bug."""
|
||||
r = voice_probe["reply_language_resolution"]
|
||||
assert r["europeStrong"] == ""
|
||||
assert r["europeDetectionless"] == "" # English default (Amy) — no language field
|
||||
assert r["europeWithEnglishStt"] == "en"
|
||||
|
||||
|
||||
def test_reply_language_resolution_routes_cyrillic_and_spanish(voice_probe):
|
||||
"""FIX 4: decisive script/orthography evidence still routes correctly —
|
||||
a Cyrillic paragraph to the Russian voice, a clearly-Spanish paragraph to the
|
||||
Spanish voice."""
|
||||
r = voice_probe["reply_language_resolution"]
|
||||
assert r["cyrillicStrong"] == "ru"
|
||||
assert r["cyrillicDetectionless"] == "ru"
|
||||
assert r["spanishStrong"] == "es"
|
||||
assert r["spanishDetectionless"] == "es"
|
||||
|
||||
|
||||
def test_reply_language_resolution_forced_language_always_wins(voice_probe):
|
||||
"""FIX 4: an explicit conversation-mode force beats every auto signal —
|
||||
including plain English text and decisive Cyrillic evidence."""
|
||||
r = voice_probe["reply_language_resolution"]
|
||||
assert r["forcedOverridesEnglishText"] == "ru"
|
||||
assert r["forcedOverridesCyrillic"] == "en"
|
||||
|
||||
@ -420,7 +420,11 @@ test('chrome mounts an icon-only rail toggle beside the app tabs when a rail exi
|
||||
assert.equal(chrome.railToggle.textContent, '⧉');
|
||||
assert.equal(rail.children[0], chrome.railToggle);
|
||||
assert.equal(rail.children[1], spacer);
|
||||
assert.match(chrome.open.attributes.class, /--railed/);
|
||||
// With a real nav home present there is NO floating pill: the rail toggle is
|
||||
// the primary handle and the body carries only the drawer.
|
||||
assert.equal(chrome.open, chrome.railToggle);
|
||||
assert.equal(doc.body.children.length, 1);
|
||||
assert.equal(doc.body.children[0], chrome.drawer);
|
||||
chrome.railToggle.trigger('click');
|
||||
assert.equal(chrome.drawer.hidden, false);
|
||||
assert.equal(chrome.railToggle.getAttribute('aria-expanded'), 'true');
|
||||
@ -432,6 +436,42 @@ test('chrome mounts an icon-only rail toggle beside the app tabs when a rail exi
|
||||
assert.equal(doc.body.children.length, 0);
|
||||
});
|
||||
|
||||
test('chrome mounts the Workspace toggle in the mobile top app bar, never floating over the composer', () => {
|
||||
// Narrow-viewport contract: below the rail breakpoint the rail collapses and
|
||||
// the top app bar (header.app-titlebar) is the nav home. The toggle mounts
|
||||
// there beside the app's own icons; no fixed .hux-workspace-toggle pill is
|
||||
// created, so it can never overlap, push, or reflow the message composer.
|
||||
const doc = new FakeDocument();
|
||||
const titlebar = new FakeNode('header');
|
||||
titlebar.setAttribute('class', 'app-titlebar');
|
||||
titlebar.insertBefore = (child, before) => {
|
||||
const index = titlebar.children.indexOf(before);
|
||||
titlebar.children.splice(index < 0 ? titlebar.children.length : index, 0, child);
|
||||
child.parentNode = titlebar;
|
||||
};
|
||||
doc.querySelector = (selector) => (selector === 'header.app-titlebar' ? titlebar : null);
|
||||
doc.createElementNS = (_ns, tag) => new FakeNode(tag);
|
||||
const chrome = api.createChrome(doc);
|
||||
assert.equal(chrome.railToggle, null);
|
||||
assert.ok(chrome.titlebarToggle);
|
||||
assert.equal(chrome.open, chrome.titlebarToggle);
|
||||
assert.equal(chrome.titlebarToggle.getAttribute('aria-label'), 'Workspace');
|
||||
assert.match(chrome.titlebarToggle.attributes.class, /hux-workspace-titlebar-toggle/);
|
||||
assert.equal(titlebar.children.includes(chrome.titlebarToggle), true);
|
||||
// No floating fixed pill anywhere in the body — only the drawer.
|
||||
assert.equal(doc.body.children.length, 1);
|
||||
assert.equal(doc.body.children[0], chrome.drawer);
|
||||
assert.equal(doc.body.children.some((child) => /hux-workspace-toggle(\s|$)/.test(child.attributes.class || '')), false);
|
||||
chrome.titlebarToggle.trigger('click');
|
||||
assert.equal(chrome.drawer.hidden, false);
|
||||
assert.equal(chrome.titlebarToggle.getAttribute('aria-expanded'), 'true');
|
||||
doc.trigger('keydown', {key: 'Escape'});
|
||||
assert.equal(chrome.drawer.hidden, true);
|
||||
chrome.destroy();
|
||||
assert.equal(titlebar.children.includes(chrome.titlebarToggle), false);
|
||||
assert.equal(doc.body.children.length, 0);
|
||||
});
|
||||
|
||||
test('rail toggle renders an svg icon where namespaces exist and falls back floating without a rail', () => {
|
||||
const doc = new FakeDocument();
|
||||
const rail = new FakeNode('nav');
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user