Skip to content

Commit abe1ef5

Browse files
heavygeecursoragent
andcommitted
fix(web): show Gemini/Qwen voice descriptions in Settings
Surface catalog descriptions on the voice row and in the picker, with a hint when preview is ElevenLabs-only. Disabled preview buttons stay visible. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 6036734 commit abe1ef5

6 files changed

Lines changed: 151 additions & 27 deletions

File tree

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
#!/usr/bin/env node
2+
/**
3+
* Dogfood Settings voice picker (Gemini descriptions + backend chooser).
4+
* Env: HAPI_URL (default http://127.0.0.1:3006), HAPI_ACCESS_TOKEN (optional)
5+
*/
6+
import { chromium } from 'playwright'
7+
import { mkdirSync, writeFileSync } from 'node:fs'
8+
import { resolve } from 'node:path'
9+
10+
const BASE_URL = process.env.HAPI_URL ?? 'http://127.0.0.1:3006'
11+
const ACCESS_TOKEN = process.env.HAPI_ACCESS_TOKEN ?? ''
12+
const OUT_DIR = resolve('localdocs/playwright-runs')
13+
mkdirSync(OUT_DIR, { recursive: true })
14+
const stamp = Date.now()
15+
16+
const browser = await chromium.launch({
17+
headless: true,
18+
executablePath: process.env.PLAYWRIGHT_CHROME_PATH ?? '/usr/bin/google-chrome',
19+
args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage'],
20+
})
21+
const context = await browser.newContext({ viewport: { width: 420, height: 900 } })
22+
23+
if (ACCESS_TOKEN) {
24+
await context.addInitScript(({ token, baseUrl }) => {
25+
localStorage.setItem(`hapi_access_token::${baseUrl}`, token)
26+
localStorage.setItem('hapi-voice-backend', 'gemini-live')
27+
}, { token: ACCESS_TOKEN, baseUrl: BASE_URL })
28+
} else {
29+
await context.addInitScript(({ baseUrl }) => {
30+
localStorage.setItem('hapi-voice-backend', 'gemini-live')
31+
}, { baseUrl: BASE_URL })
32+
}
33+
34+
const page = await context.newPage()
35+
const errors = []
36+
page.on('pageerror', (err) => errors.push(String(err)))
37+
38+
await page.goto(`${BASE_URL}/settings`, { waitUntil: 'networkidle', timeout: 60000 })
39+
await page.waitForTimeout(2000)
40+
41+
const bodyText = await page.locator('body').innerText()
42+
const hasHint = bodyText.includes('voice character notes') || bodyText.includes('Voice character notes')
43+
const hasBackend = bodyText.includes('Voice backend') || bodyText.includes('Gemini Live')
44+
45+
// Open voice picker
46+
const voiceRow = page.getByRole('button', { name: /^Voice\b/i }).first()
47+
await voiceRow.click({ timeout: 15000 }).catch(() => {})
48+
await page.waitForTimeout(500)
49+
50+
const afterOpen = await page.locator('body').innerText()
51+
const hasDescription = afterOpen.includes('Conversational, friendly')
52+
53+
const screenshotPath = resolve(OUT_DIR, `voice-settings-${stamp}.png`)
54+
await page.screenshot({ path: screenshotPath, fullPage: true })
55+
56+
const report = {
57+
baseUrl: BASE_URL,
58+
hasBackend,
59+
hasHint,
60+
hasDescription,
61+
errors,
62+
screenshotPath,
63+
}
64+
writeFileSync(resolve(OUT_DIR, `voice-settings-${stamp}.json`), JSON.stringify(report, null, 2))
65+
66+
console.log(JSON.stringify(report, null, 2))
67+
68+
await browser.close()
69+
70+
if (errors.length > 0) process.exit(2)
71+
if (!hasDescription && !hasHint) {
72+
console.error('FAIL: neither description nor static-catalog hint visible on Settings')
73+
process.exit(1)
74+
}

web/src/api/voice.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,8 @@ export interface VoiceInfo {
5757
name: string
5858
previewUrl: string
5959
category: string
60+
/** Static-catalog hint (Gemini/Qwen); ElevenLabs uses API name only. */
61+
description?: string
6062
}
6163

6264
export async function fetchVoices(api: ApiClient): Promise<VoiceInfo[]> {

web/src/lib/locales/en.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -418,6 +418,9 @@ export default {
418418
'settings.voice.autoDetect': 'Auto-detect',
419419
'settings.voice.voice': 'Voice',
420420
'settings.voice.voiceDefault': 'Default',
421+
'settings.voice.preview.elevenlabsOnly': 'Voice preview is only available for ElevenLabs',
422+
'settings.voice.preview.unavailable': 'Preview unavailable without an ElevenLabs API key',
423+
'settings.voice.staticCatalogHint': 'Open the list to see voice character notes. Audio preview is ElevenLabs only.',
421424
'settings.voice.session.label': 'Session behavior',
422425
'settings.voice.proactive': 'Start voice session with summary',
423426
'settings.voice.proactive.description': 'When on, starting a voice session opens with a spoken summary of current agent activity. When off, the assistant greets you and waits for you to speak.',

web/src/lib/locales/zh-CN.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -420,6 +420,9 @@ export default {
420420
'settings.voice.autoDetect': '自动检测',
421421
'settings.voice.voice': '声音',
422422
'settings.voice.voiceDefault': '默认',
423+
'settings.voice.preview.elevenlabsOnly': '仅 ElevenLabs 支持试听',
424+
'settings.voice.preview.unavailable': '未配置 ElevenLabs API 密钥时无法试听',
425+
'settings.voice.staticCatalogHint': '展开列表可查看声音特点说明;仅 ElevenLabs 支持试听。',
423426
'settings.voice.session.label': '会话行为',
424427
'settings.voice.proactive': '以摘要开始语音会话',
425428
'settings.voice.proactive.description': '开启后,启动语音会话时将朗读当前代理活动的摘要。关闭后,助手向您打招呼并等待您先开口。',

web/src/routes/settings/index.test.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -436,10 +436,21 @@ describe('SettingsPage', () => {
436436
await waitFor(() => {
437437
expect(screen.getByText('Puck')).toBeInTheDocument()
438438
expect(screen.getByText('Aoede')).toBeInTheDocument()
439+
expect(screen.getByText('Conversational, friendly')).toBeInTheDocument()
439440
})
440441
expect(mockFetchVoices).not.toHaveBeenCalled()
441442
})
442443

444+
it('shows static catalog hint when Gemini backend is selected', async () => {
445+
mockFetchVoiceBackend.mockResolvedValue({ backend: 'gemini-live', backends: ['gemini-live'] })
446+
447+
renderWithProviders(<SettingsPage />)
448+
449+
await waitFor(() => {
450+
expect(screen.getByText('Open the list to see voice character notes. Audio preview is ElevenLabs only.')).toBeInTheDocument()
451+
})
452+
})
453+
443454
it('persists Gemini voice selection under gemini storage key', async () => {
444455
mockFetchVoiceBackend.mockResolvedValue({ backend: 'gemini-live', backends: ['gemini-live'] })
445456

web/src/routes/settings/index.tsx

Lines changed: 58 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -402,6 +402,7 @@ export default function SettingsPage() {
402402
? staticVoiceOptions.map(v => ({
403403
id: v.id,
404404
name: v.label,
405+
description: v.description,
405406
previewUrl: '',
406407
category: 'premade'
407408
}))
@@ -413,8 +414,13 @@ export default function SettingsPage() {
413414
?? fallbackVoices.find(v => v.id === voiceId)?.name
414415
?? voiceId)
415416
: null
417+
const currentVoiceDescription = voiceId
418+
? (voiceOptions.find(v => v.id === voiceId)?.description
419+
?? staticVoiceOptions.find(v => v.id === voiceId)?.description)
420+
: undefined
421+
const staticVoiceCatalog = voiceBackend === 'gemini-live' || voiceBackend === 'qwen-realtime'
416422

417-
const showVoicePreview = voiceBackend === 'elevenlabs'
423+
const voicePreviewEnabled = voiceBackend === 'elevenlabs'
418424
const showVoiceBackendChooser = configuredVoiceBackends.length > 1
419425
const currentVoiceBackendLabel = voiceBackend ? VOICE_BACKEND_LABELS[voiceBackend] : null
420426

@@ -1138,15 +1144,29 @@ export default function SettingsPage() {
11381144
aria-haspopup="listbox"
11391145
>
11401146
<span className="text-[var(--app-fg)]">{t('settings.voice.voice')}</span>
1141-
<span className="flex items-center gap-1 text-[var(--app-hint)]">
1142-
<span>{currentVoiceName ?? t('settings.voice.voiceDefault')}</span>
1143-
<ChevronDownIcon className={`transition-transform ${isVoicePickerOpen ? 'rotate-180' : ''}`} />
1147+
<span className="flex min-w-0 items-center gap-1 text-[var(--app-hint)]">
1148+
<span className="min-w-0 text-right">
1149+
<span className="block truncate">
1150+
{currentVoiceName ?? t('settings.voice.voiceDefault')}
1151+
</span>
1152+
{currentVoiceDescription && (
1153+
<span className="block text-xs leading-snug text-[var(--app-hint)]">
1154+
{currentVoiceDescription}
1155+
</span>
1156+
)}
1157+
</span>
1158+
<ChevronDownIcon className={`shrink-0 transition-transform ${isVoicePickerOpen ? 'rotate-180' : ''}`} />
11441159
</span>
11451160
</button>
1161+
{staticVoiceCatalog && (
1162+
<p className="px-3 pb-2 text-xs text-[var(--app-hint)]">
1163+
{t('settings.voice.staticCatalogHint')}
1164+
</p>
1165+
)}
11461166

11471167
{isVoicePickerOpen && (
11481168
<div
1149-
className="absolute right-3 top-full mt-1 min-w-[220px] max-h-[300px] overflow-y-auto rounded-lg border border-[var(--app-border)] bg-[var(--app-bg)] shadow-lg z-50"
1169+
className="absolute right-3 top-full z-50 mt-1 max-h-[300px] min-w-[260px] overflow-y-auto rounded-lg border border-[var(--app-border)] bg-[var(--app-bg)] shadow-lg"
11501170
role="listbox"
11511171
aria-label={t('settings.voice.voice')}
11521172
>
@@ -1176,7 +1196,7 @@ export default function SettingsPage() {
11761196
key={voice.id}
11771197
role="option"
11781198
aria-selected={isSelected}
1179-
className={`flex items-center w-full text-base transition-colors ${
1199+
className={`flex w-full items-start text-base transition-colors ${
11801200
isSelected
11811201
? 'text-[var(--app-link)] bg-[var(--app-subtle-bg)]'
11821202
: 'text-[var(--app-fg)] hover:bg-[var(--app-subtle-bg)]'
@@ -1185,32 +1205,43 @@ export default function SettingsPage() {
11851205
<button
11861206
type="button"
11871207
onClick={() => handleVoiceChange(voice.id)}
1188-
className="flex flex-1 items-center justify-between px-3 py-2 text-left min-w-0"
1208+
className="flex min-w-0 flex-1 items-start justify-between px-3 py-2.5 text-left"
11891209
>
1190-
<span className="truncate">
1191-
{voice.name}
1192-
{voice.category === 'cloned' && (
1193-
<span className="ml-2 text-xs text-[var(--app-hint)]">clone</span>
1210+
<span className="min-w-0 pr-2">
1211+
<span className="block font-medium leading-snug">
1212+
{voice.name}
1213+
{voice.category === 'cloned' && (
1214+
<span className="ml-2 text-xs font-normal text-[var(--app-hint)]">clone</span>
1215+
)}
1216+
</span>
1217+
{voice.description && (
1218+
<span className="mt-0.5 block text-xs leading-snug text-[var(--app-hint)]">
1219+
{voice.description}
1220+
</span>
11941221
)}
11951222
</span>
11961223
{isSelected && <span className="ml-2 shrink-0"><CheckIcon /></span>}
11971224
</button>
1198-
{showVoicePreview && (
1199-
<button
1200-
type="button"
1201-
onClick={(e) => handleVoicePreview(voice.previewUrl, voice.id, e)}
1202-
aria-label={isPlaying ? 'Stop preview' : 'Preview voice'}
1203-
title={voice.previewUrl ? (isPlaying ? 'Stop preview' : 'Preview voice') : 'Preview unavailable without an ElevenLabs API key'}
1204-
disabled={!voice.previewUrl}
1205-
className={`flex h-full shrink-0 items-center px-3 py-2 ${
1206-
voice.previewUrl
1207-
? 'text-[var(--app-hint)] hover:text-[var(--app-fg)]'
1208-
: 'text-[var(--app-divider)] cursor-not-allowed'
1209-
}`}
1210-
>
1211-
{isPlaying ? <StopIcon /> : <PlayIcon />}
1212-
</button>
1213-
)}
1225+
<button
1226+
type="button"
1227+
onClick={(e) => handleVoicePreview(voice.previewUrl, voice.id, e)}
1228+
aria-label={isPlaying ? 'Stop preview' : 'Preview voice'}
1229+
title={
1230+
voicePreviewEnabled && voice.previewUrl
1231+
? (isPlaying ? 'Stop preview' : 'Preview voice')
1232+
: voicePreviewEnabled
1233+
? t('settings.voice.preview.unavailable')
1234+
: t('settings.voice.preview.elevenlabsOnly')
1235+
}
1236+
disabled={!voicePreviewEnabled || !voice.previewUrl}
1237+
className={`flex shrink-0 items-center self-center px-3 py-2 ${
1238+
voicePreviewEnabled && voice.previewUrl
1239+
? 'text-[var(--app-hint)] hover:text-[var(--app-fg)]'
1240+
: 'text-[var(--app-divider)] cursor-not-allowed'
1241+
}`}
1242+
>
1243+
{isPlaying ? <StopIcon /> : <PlayIcon />}
1244+
</button>
12141245
</div>
12151246
)
12161247
})}

0 commit comments

Comments
 (0)