From 9c5329dce4c4c0f9ccec5c44f36e88b25988322d Mon Sep 17 00:00:00 2001 From: Michael Yagudaev Date: Sun, 10 May 2026 11:55:44 -0700 Subject: [PATCH] feat(desktop): add live "Hello" introduction step to onboarding wizard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the text-only smoke-test step with a live conversational intro: the agent greets the user out loud in the chosen voice, asks for their name, then asks for a brief bio. Each user response auto-populates a form field with a typed-input fallback. On Done, the captured profile is written to USER.md in the brain workspace so the relay's instruction builder can include it in every future session — closing the "who am I again?" amnesia on cold start. - New `user-profile.ts` (main) reads/writes USER.md with Name + About sections - `user:get` / `user:save` IPC + preload bridge mirroring the identity API - Relay's `loadAgentIdentity` now appends an "About the user" block from USER.md when present - New `systemPromptOverride` field on the relay session config replaces the agent persona section with a hardcoded onboarding script for this step (paired with `brainAgent: 'none'` so no tools register mid-greeting) - Wizard sequence updated: testcall -> introduction; legacy 'testcall' cursor in saved state is migrated transparently - Tests: USER.md round-trip, instructions loader (USER.md inclusion + override), step-id migration, prompt-builder + name-extractor helpers - Docs: end-user "First-run setup" guide with screenshot + updates to the implementer onboarding doc Closes NAN-699 Co-Authored-By: Claude Opus 4.7 (1M context) --- desktop/src/main/ipc-handlers.ts | 17 + desktop/src/main/onboarding.test.ts | 26 + desktop/src/main/onboarding.ts | 12 +- desktop/src/main/user-profile.test.ts | 128 ++++ desktop/src/main/user-profile.ts | 84 +++ desktop/src/preload/index.ts | 7 +- desktop/src/renderer/src/App.tsx | 3 +- .../src/renderer/src/lib/onboarding-api.ts | 21 +- desktop/src/renderer/src/lib/use-realtime.ts | 5 + .../src/pages/onboarding/OnboardingWizard.tsx | 55 +- .../pages/onboarding/StepIntroduction.test.ts | 55 ++ .../src/pages/onboarding/StepIntroduction.tsx | 680 ++++++++++++++++++ .../src/pages/onboarding/StepTestCall.tsx | 497 ------------- docs/astro.config.mjs | 3 +- docs/public/onboarding-introduction.png | Bin 0 -> 191059 bytes docs/src/content/docs/desktop/first-run.mdx | 73 ++ docs/src/content/docs/desktop/onboarding.mdx | 23 +- relay-server/src/instructions.ts | 47 +- relay-server/src/types.ts | 5 + relay-server/test/instructions.test.ts | 134 ++++ 20 files changed, 1354 insertions(+), 521 deletions(-) create mode 100644 desktop/src/main/user-profile.test.ts create mode 100644 desktop/src/main/user-profile.ts create mode 100644 desktop/src/renderer/src/pages/onboarding/StepIntroduction.test.ts create mode 100644 desktop/src/renderer/src/pages/onboarding/StepIntroduction.tsx delete mode 100644 desktop/src/renderer/src/pages/onboarding/StepTestCall.tsx create mode 100644 docs/public/onboarding-introduction.png create mode 100644 docs/src/content/docs/desktop/first-run.mdx create mode 100644 relay-server/test/instructions.test.ts diff --git a/desktop/src/main/ipc-handlers.ts b/desktop/src/main/ipc-handlers.ts index f646d144..07b7cecb 100644 --- a/desktop/src/main/ipc-handlers.ts +++ b/desktop/src/main/ipc-handlers.ts @@ -22,6 +22,11 @@ import { speakGreetingPreview, writeAgentIdentity, } from './identity' +import { + type UserProfile, + readUserProfile, + writeUserProfile, +} from './user-profile' import { getAllocatedPorts } from './ports' import { type OnboardingPayload, @@ -550,6 +555,18 @@ export function registerIpcHandlers() { }, ) + // User profile (name, bio) — persisted as USER.md in the bundled + // openclaw workspace. The relay's instruction builder loads this file + // alongside SOUL/IDENTITY so the agent knows who the user is. + ipcMain.handle('user:get', () => readUserProfile()) + ipcMain.handle('user:save', (_e, patch: Partial) => { + const saved = writeUserProfile(patch) + serviceManager.restart('relay', () => buildRelayEnv()).catch((err) => { + console.warn('[relay] restart after user profile save failed', err) + }) + return saved + }) + // Static voice preview used by the Settings voice picker. The clips // ship with the app under resources/voice-previews// — this // handler never hits the network and does not need an API key. diff --git a/desktop/src/main/onboarding.test.ts b/desktop/src/main/onboarding.test.ts index 6259d849..69c89204 100644 --- a/desktop/src/main/onboarding.test.ts +++ b/desktop/src/main/onboarding.test.ts @@ -72,6 +72,32 @@ describe('ensureBundledRelayDefaults', () => { }) }) +describe('getOnboardingState — step migration', () => { + beforeEach(() => { + settings.clear() + }) + + afterEach(() => { + vi.resetModules() + }) + + it("remaps a stored 'testcall' cursor to 'introduction'", async () => { + const { getOnboardingState } = await import('./onboarding') + const originalGet = fakeStmt.get + fakeStmt.get = ((..._args: unknown[]) => ({ + currentStep: 'testcall', + payload: '{}', + completedAt: null, + })) as typeof fakeStmt.get + try { + const state = getOnboardingState() + expect(state.currentStep).toBe('introduction') + } finally { + fakeStmt.get = originalGet + } + }) +}) + describe('getBundledRelayApiKey', () => { beforeEach(() => { settings.clear() diff --git a/desktop/src/main/onboarding.ts b/desktop/src/main/onboarding.ts index 500f8882..ca483361 100644 --- a/desktop/src/main/onboarding.ts +++ b/desktop/src/main/onboarding.ts @@ -15,7 +15,7 @@ export type WizardStepId = | 'provider' | 'brain' | 'identity' - | 'testcall' + | 'introduction' export type OnboardingPayload = { signedIn?: boolean @@ -86,7 +86,7 @@ export function getOnboardingState(): OnboardingState { } return { - currentStep: row.currentStep, + currentStep: migrateStepId(row.currentStep), payload: parsePayload(row.payload), completedAt: row.completedAt, } @@ -170,6 +170,14 @@ function parsePayload(raw: string): OnboardingPayload { } } +function migrateStepId(step: string): WizardStepId { + // The introduction step replaced the legacy 'testcall' placeholder. Remap + // any persisted value so a user mid-wizard during the upgrade lands on the + // new step instead of an unknown id. + if (step === 'testcall') return 'introduction' + return step as WizardStepId +} + function mergePayload( current: OnboardingPayload, patch: OnboardingPayload, diff --git a/desktop/src/main/user-profile.test.ts b/desktop/src/main/user-profile.test.ts new file mode 100644 index 00000000..15d5d152 --- /dev/null +++ b/desktop/src/main/user-profile.test.ts @@ -0,0 +1,128 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const writes: { path: string; content: string }[] = [] +const fileSystem = new Map() + +vi.mock('fs', () => ({ + existsSync: (path: string) => fileSystem.has(path), + mkdirSync: () => undefined, + readFileSync: (path: string) => fileSystem.get(path) ?? '', + writeFileSync: (path: string, content: string) => { + writes.push({ path, content }) + fileSystem.set(path, content) + }, +})) + +vi.mock('node:fs/promises', () => ({ + mkdir: async () => undefined, + readFile: async (path: string, encoding?: string) => { + if (!fileSystem.has(path)) { + const err = new Error('ENOENT') as NodeJS.ErrnoException + err.code = 'ENOENT' + throw err + } + const value = fileSystem.get(path) ?? '' + if (encoding === 'utf8' || encoding === 'utf-8') return value + return Buffer.from(value, 'utf8') + }, + writeFile: async (path: string, content: string) => { + writes.push({ path, content }) + fileSystem.set(path, content) + }, +})) + +vi.mock('electron', () => ({ + app: { + getPath: () => '/tmp/voiceclaw-user-profile-test', + getAppPath: () => '/tmp/voiceclaw-app-path', + isPackaged: false, + }, + net: { + fetch: () => { + throw new Error('net.fetch not stubbed in this test') + }, + }, +})) + +vi.mock('./db', () => ({ + getDb: () => { + throw new Error('getDb not stubbed in user-profile tests') + }, +})) + +describe('writeUserProfile', () => { + beforeEach(() => { + writes.length = 0 + fileSystem.clear() + }) + + afterEach(() => { + vi.resetModules() + }) + + it('writes Name and About sections to USER.md', async () => { + const { writeUserProfile, getUserProfilePath } = await import('./user-profile') + const result = writeUserProfile({ name: 'Michael', bio: 'I build voice agents.' }) + expect(result.name).toBe('Michael') + expect(result.bio).toBe('I build voice agents.') + expect(writes).toHaveLength(1) + expect(writes[0].path).toBe(getUserProfilePath()) + expect(writes[0].content).toContain('## Name\nMichael') + expect(writes[0].content).toContain('## About\nI build voice agents.') + }) + + it('falls back to defaults when fields are blank', async () => { + const { writeUserProfile, DEFAULT_USER } = await import('./user-profile') + const result = writeUserProfile({}) + expect(result.name).toBe(DEFAULT_USER.name) + expect(result.bio).toBe(DEFAULT_USER.bio) + expect(writes[0].content).toContain('## Name\nFriend') + expect(writes[0].content).toContain('## About\n_(not provided)_') + }) + + it('trims whitespace on inputs', async () => { + const { writeUserProfile } = await import('./user-profile') + const result = writeUserProfile({ name: ' Mike ', bio: ' Hi. ' }) + expect(result.name).toBe('Mike') + expect(result.bio).toBe('Hi.') + }) +}) + +describe('readUserProfile', () => { + beforeEach(() => { + writes.length = 0 + fileSystem.clear() + }) + + afterEach(() => { + vi.resetModules() + }) + + it('returns defaults when no USER.md exists', async () => { + const { readUserProfile, DEFAULT_USER } = await import('./user-profile') + expect(readUserProfile()).toEqual(DEFAULT_USER) + }) + + it('round-trips a written profile', async () => { + const { writeUserProfile, readUserProfile } = await import('./user-profile') + writeUserProfile({ name: 'Sage', bio: 'Quiet mornings.' }) + const profile = readUserProfile() + expect(profile.name).toBe('Sage') + expect(profile.bio).toBe('Quiet mornings.') + }) + + it('treats the placeholder bio as empty', async () => { + const { writeUserProfile, readUserProfile, DEFAULT_USER } = await import('./user-profile') + writeUserProfile({ name: 'Alex' }) + const profile = readUserProfile() + expect(profile.name).toBe('Alex') + expect(profile.bio).toBe(DEFAULT_USER.bio) + }) + + it('preserves multi-line bio bodies', async () => { + const { writeUserProfile, readUserProfile } = await import('./user-profile') + const bio = 'Line one.\nLine two.' + writeUserProfile({ name: 'Beatrix', bio }) + expect(readUserProfile().bio).toBe(bio) + }) +}) diff --git a/desktop/src/main/user-profile.ts b/desktop/src/main/user-profile.ts new file mode 100644 index 00000000..5b95f07e --- /dev/null +++ b/desktop/src/main/user-profile.ts @@ -0,0 +1,84 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs' +import { join } from 'path' +import { getWorkspaceDir } from './identity' + +export type UserProfile = { + name: string + bio: string +} + +export const DEFAULT_USER: UserProfile = { + name: 'Friend', + bio: '', +} + +export function getUserProfilePath(): string { + return join(getWorkspaceDir(), 'USER.md') +} + +export function readUserProfile(): UserProfile { + const path = getUserProfilePath() + if (!existsSync(path)) return { ...DEFAULT_USER } + let content = '' + try { + content = readFileSync(path, 'utf8') + } catch { + return { ...DEFAULT_USER } + } + return { + name: readSection(content, 'Name') || DEFAULT_USER.name, + bio: readSection(content, 'About') || DEFAULT_USER.bio, + } +} + +export function writeUserProfile(profile: Partial): UserProfile { + const merged: UserProfile = { + name: profile.name?.trim() || DEFAULT_USER.name, + bio: profile.bio?.trim() ?? DEFAULT_USER.bio, + } + const dir = getWorkspaceDir() + mkdirSync(dir, { recursive: true }) + writeFileSync(getUserProfilePath(), renderUserProfileMarkdown(merged), { mode: 0o600 }) + return merged +} + +export function hasUserProfile(): boolean { + return existsSync(getUserProfilePath()) +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function renderUserProfileMarkdown(profile: UserProfile): string { + const bio = profile.bio.trim() + return [ + '# USER.md - Who Are You Talking To?', + '', + '## Name', + profile.name, + '', + '## About', + bio.length > 0 ? bio : '_(not provided)_', + '', + ].join('\n') +} + +function readSection(content: string, heading: string): string | null { + const lines = content.split('\n') + const target = `## ${heading}`.toLowerCase() + let inSection = false + const captured: string[] = [] + for (const line of lines) { + const trimmed = line.trim() + if (trimmed.toLowerCase().startsWith('## ')) { + if (inSection) break + inSection = trimmed.toLowerCase() === target + continue + } + if (inSection) captured.push(line) + } + const body = captured.join('\n').trim() + if (!body || body === '_(not provided)_') return null + return body +} diff --git a/desktop/src/preload/index.ts b/desktop/src/preload/index.ts index cfd2a0b8..665237b2 100644 --- a/desktop/src/preload/index.ts +++ b/desktop/src/preload/index.ts @@ -9,7 +9,7 @@ type WizardStepId = | 'provider' | 'brain' | 'identity' - | 'testcall' + | 'introduction' type OnboardingPayload = { signedIn?: boolean @@ -249,6 +249,11 @@ const electronAPI = { | { ok: false; error: string } >, }, + user: { + get: () => ipcRenderer.invoke('user:get') as Promise<{ name: string; bio: string }>, + save: (patch: { name?: string; bio?: string }) => + ipcRenderer.invoke('user:save', patch) as Promise<{ name: string; bio: string }>, + }, screen: { getSources: () => ipcRenderer.invoke('screen:getSources') as Promise< diff --git a/desktop/src/renderer/src/App.tsx b/desktop/src/renderer/src/App.tsx index 93d39389..5835fe5e 100644 --- a/desktop/src/renderer/src/App.tsx +++ b/desktop/src/renderer/src/App.tsx @@ -15,7 +15,8 @@ const ONBOARDING_STEP_IDS: WizardStepId[] = [ 'permissions', 'provider', 'brain', - 'testcall', + 'identity', + 'introduction', ] export function App() { diff --git a/desktop/src/renderer/src/lib/onboarding-api.ts b/desktop/src/renderer/src/lib/onboarding-api.ts index 81f4d434..905a33ce 100644 --- a/desktop/src/renderer/src/lib/onboarding-api.ts +++ b/desktop/src/renderer/src/lib/onboarding-api.ts @@ -8,7 +8,7 @@ export type WizardStepId = | 'provider' | 'brain' | 'identity' - | 'testcall' + | 'introduction' export type ProviderId = 'gemini' | 'openai' | 'xai' @@ -25,6 +25,16 @@ export type AgentIdentityPatch = { voice?: string } +export type UserProfile = { + name: string + bio: string +} + +export type UserProfilePatch = { + name?: string + bio?: string +} + export type OnboardingPayload = { signedIn?: boolean permissions?: { @@ -99,6 +109,10 @@ declare global { | { ok: false; error: string } > } + user: { + get: () => Promise + save: (patch: UserProfilePatch) => Promise + } } } } @@ -141,3 +155,8 @@ export const identityApi = { getVoicePreview: (params: { voice: string }) => window.electronAPI.identity.getVoicePreview(params), } + +export const userApi = { + get: () => window.electronAPI.user.get(), + save: (patch: UserProfilePatch) => window.electronAPI.user.save(patch), +} diff --git a/desktop/src/renderer/src/lib/use-realtime.ts b/desktop/src/renderer/src/lib/use-realtime.ts index 8116558c..5aed8190 100644 --- a/desktop/src/renderer/src/lib/use-realtime.ts +++ b/desktop/src/renderer/src/lib/use-realtime.ts @@ -39,6 +39,10 @@ export interface RealtimeConfig { deviceModel?: string } instructionsOverride?: string + // Replaces the agent identity portion of the system prompt with this string. + // The relay still applies conversation rules and device context. Used by + // short-lived sessions like onboarding intro that need a tight script. + systemPromptOverride?: string conversationHistory?: { role: 'user' | 'assistant', text: string, timestamp?: number, relativeMs?: number }[] tracingEnabled?: boolean } @@ -327,6 +331,7 @@ export function useRealtime(callbacks: RealtimeCallbacks): RealtimeControls { sessionKey: config.sessionKey, deviceContext: config.deviceContext, instructionsOverride: config.instructionsOverride, + systemPromptOverride: config.systemPromptOverride, conversationHistory: config.conversationHistory, }), ) diff --git a/desktop/src/renderer/src/pages/onboarding/OnboardingWizard.tsx b/desktop/src/renderer/src/pages/onboarding/OnboardingWizard.tsx index a68cd970..2bb0b332 100644 --- a/desktop/src/renderer/src/pages/onboarding/OnboardingWizard.tsx +++ b/desktop/src/renderer/src/pages/onboarding/OnboardingWizard.tsx @@ -1,4 +1,4 @@ -import { useCallback, useState } from 'react' +import { useCallback, useEffect, useState } from 'react' import './brand.css' import { StepWelcome } from './StepWelcome' import { StepSignIn } from './StepSignIn' @@ -6,14 +6,20 @@ import { StepPermissions } from './StepPermissions' import { StepProvider } from './StepProvider' import { StepBrain } from './StepBrain' import { StepIdentity } from './StepIdentity' -import { StepTestCall } from './StepTestCall' -import { identityApi } from '../../lib/onboarding-api' +import { StepIntroduction } from './StepIntroduction' +import { + identityApi, + userApi, + type ProviderId, + type UserProfile, +} from '../../lib/onboarding-api' import { onboarding, type OnboardingPayload, type OnboardingState, type WizardStepId, } from '../../lib/onboarding-api' +import { isVoiceForProvider } from '../../lib/voice-prefs' export type { WizardStepId } @@ -24,9 +30,13 @@ const STEPS: WizardStepId[] = [ 'provider', 'brain', 'identity', - 'testcall', + 'introduction', ] +const DEFAULT_AGENT_NAME = 'Pam' +const DEFAULT_VOICE = 'Zephyr' +const DEFAULT_USER: UserProfile = { name: 'Friend', bio: '' } + type Props = { initialState?: OnboardingState onComplete?: () => void @@ -40,8 +50,21 @@ type Props = { export function OnboardingWizard({ initialState, onComplete, previewMode = false }: Props) { const [stepId, setStepId] = useState(initialState?.currentStep ?? 'welcome') const [payload, setPayload] = useState(initialState?.payload ?? {}) + const [existingUser, setExistingUser] = useState(DEFAULT_USER) const currentIndex = STEPS.indexOf(stepId) + useEffect(() => { + if (previewMode) return + void (async () => { + try { + const u = await userApi.get() + setExistingUser(u) + } catch (err) { + console.warn('[onboarding] user load failed', err) + } + })() + }, [previewMode]) + const persist = useCallback( async (nextStep: WizardStepId, patch: OnboardingPayload = {}) => { const merged = mergePayload(payload, patch) @@ -81,10 +104,11 @@ export function OnboardingWizard({ initialState, onComplete, previewMode = false if (!previewMode) { try { if (Object.keys(patch).length > 0) { - await onboarding.updateStep('testcall', patch) + await onboarding.updateStep('introduction', patch) } await onboarding.complete() - await window.electronAPI.db.setSetting('pending_greeting', 'true') + // After the introduction step the agent has already greeted the + // user out loud — no need to re-greet on first chat load. } catch (err) { console.warn('[onboarding] complete failed', err) } @@ -163,16 +187,23 @@ export function OnboardingWizard({ initialState, onComplete, previewMode = false previewMode={previewMode} /> ) - case 'testcall': + case 'introduction': { + const agentName = payload.identity?.name?.trim() || DEFAULT_AGENT_NAME + const voice = payload.identity?.voice || DEFAULT_VOICE + const providerId: ProviderId = payload.provider ?? providerFromVoice(voice) return ( - finish()} onBack={back} onStartOver={startOver} - providerId={payload.provider ?? 'gemini'} + agentName={agentName} + voice={voice} + providerId={providerId} + initialUser={existingUser} previewMode={previewMode} /> ) + } } } @@ -180,6 +211,12 @@ export function OnboardingWizard({ initialState, onComplete, previewMode = false // Helpers // --------------------------------------------------------------------------- +function providerFromVoice(voice: string): ProviderId { + if (isVoiceForProvider('xai', voice)) return 'xai' + if (isVoiceForProvider('openai', voice)) return 'openai' + return 'gemini' +} + function mergePayload( current: OnboardingPayload, patch: OnboardingPayload, diff --git a/desktop/src/renderer/src/pages/onboarding/StepIntroduction.test.ts b/desktop/src/renderer/src/pages/onboarding/StepIntroduction.test.ts new file mode 100644 index 00000000..980143e4 --- /dev/null +++ b/desktop/src/renderer/src/pages/onboarding/StepIntroduction.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it, vi } from 'vitest' + +vi.mock('../../lib/db', () => ({ + getSetting: async () => null, +})) + +vi.mock('../../lib/use-realtime', () => ({ + useRealtime: () => ({ + start: () => {}, + stop: () => {}, + setMuted: () => {}, + setOutputMuted: () => {}, + isConnected: false, + isReconnecting: false, + sessionId: null, + }), +})) + +const { buildIntroPrompt, extractFirstName } = await import('./StepIntroduction') + +describe('buildIntroPrompt', () => { + it("interpolates the agent's name into the greeting line", () => { + const prompt = buildIntroPrompt('Pam') + expect(prompt).toContain("Hi! I'm Pam.") + expect(prompt).toMatch(/onboarding with a brand-new user/i) + }) + + it('forbids tool use', () => { + const prompt = buildIntroPrompt('Pam') + expect(prompt).toMatch(/Do NOT call any tools/) + }) +}) + +describe('extractFirstName', () => { + const cases: Array<[string, string]> = [ + ['Michael', 'Michael'], + ['Michael.', 'Michael'], + ["I'm Michael", 'Michael'], + ['I am Michael', 'Michael'], + ['My name is Michael Yagudaev', 'Michael'], + ['Call me Mike.', 'Mike'], + ["It's Sage", 'Sage'], + ['this is Beatrix', 'Beatrix'], + ['michael', 'Michael'], + ['MICHAEL', 'Michael'], + ['', ''], + [' ', ''], + ] + + for (const [input, expected] of cases) { + it(`maps "${input}" → "${expected}"`, () => { + expect(extractFirstName(input)).toBe(expected) + }) + } +}) diff --git a/desktop/src/renderer/src/pages/onboarding/StepIntroduction.tsx b/desktop/src/renderer/src/pages/onboarding/StepIntroduction.tsx new file mode 100644 index 00000000..6179d199 --- /dev/null +++ b/desktop/src/renderer/src/pages/onboarding/StepIntroduction.tsx @@ -0,0 +1,680 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { Mic, MicOff, Pencil, Volume2, VolumeX } from 'lucide-react' +import { StepFrame } from './StepFrame' +import { + identityApi, + userApi, + type ProviderId, + type UserProfile, +} from '../../lib/onboarding-api' +import { useRealtime, type AdapterErrorPayload } from '../../lib/use-realtime' +import { getSetting } from '../../lib/db' + +const DEFAULT_REALTIME_MODEL = 'gemini-3.1-flash-live-preview' +const DEFAULT_USER_NAME = 'Friend' + +type Turn = { + id: number + role: 'user' | 'assistant' + text: string + isFinal: boolean +} + +type CaptureStage = 'awaiting_name' | 'awaiting_bio' | 'done' + +type Props = { + onContinue?: () => void + onBack?: () => void + onStartOver?: () => void + agentName: string + voice: string + providerId: ProviderId + initialUser?: UserProfile + previewMode?: boolean +} + +export function StepIntroduction({ + onContinue, + onBack, + onStartOver, + agentName, + voice, + providerId, + initialUser, + previewMode = false, +}: Props) { + const [name, setName] = useState(initialUser?.name && initialUser.name !== DEFAULT_USER_NAME ? initialUser.name : '') + const [bio, setBio] = useState(initialUser?.bio ?? '') + const [nameTyping, setNameTyping] = useState(Boolean(initialUser?.name && initialUser.name !== DEFAULT_USER_NAME)) + const [bioTyping, setBioTyping] = useState(Boolean(initialUser?.bio && initialUser.bio.length > 0)) + const [turns, setTurns] = useState([]) + const [stage, setStage] = useState( + previewMode || (initialUser?.name && initialUser?.bio) ? 'done' : 'awaiting_name', + ) + const [callError, setCallError] = useState('') + const [voiceUnavailable, setVoiceUnavailable] = useState(previewMode) + const [muted, setMuted] = useState(false) + const [outputMuted, setOutputMuted] = useState(false) + const [saving, setSaving] = useState(false) + const turnIdRef = useRef(0) + const stageRef = useRef(stage) + stageRef.current = stage + const callbacks = useMemo( + () => ({ + onTranscriptDelta: (text: string, role: 'user' | 'assistant') => { + if (!text) return + setTurns((prev) => updateStreamingTurn(prev, role, text, false, turnIdRef)) + }, + onTranscriptDone: (text: string, role: 'user' | 'assistant') => { + const trimmed = text.trim() + if (!trimmed) return + setTurns((prev) => updateStreamingTurn(prev, role, trimmed, true, turnIdRef)) + if (role !== 'user') return + if (stageRef.current === 'awaiting_name') { + const captured = extractFirstName(trimmed) + if (captured) { + setName((current) => (current.trim() ? current : captured)) + } + setStage('awaiting_bio') + } else if (stageRef.current === 'awaiting_bio') { + setBio((current) => (current.trim() ? current : trimmed)) + setStage('done') + } + }, + onError: (message: string, _code: number, payload?: AdapterErrorPayload) => { + setCallError(payload?.userMessage ?? message) + }, + onDisconnect: () => { + setVoiceUnavailable(true) + }, + }), + [], + ) + + const realtime = useRealtime(callbacks) + const startedRef = useRef(false) + + useEffect(() => { + if (previewMode) return + if (startedRef.current) return + startedRef.current = true + void (async () => { + try { + const apiKey = (await getSetting('realtime_api_key')) ?? '' + const serverUrl = (await getSetting('realtime_server_url')) || (await defaultRelayUrl()) + if (!apiKey) { + setVoiceUnavailable(true) + setCallError('No relay API key configured — voice intro disabled. Type your answers below.') + return + } + const model = (await getSetting('realtime_model')) || DEFAULT_REALTIME_MODEL + realtime.start({ + serverUrl, + voice, + model, + brainAgent: 'none', + apiKey, + systemPromptOverride: buildIntroPrompt(agentName), + deviceContext: { + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, + locale: navigator.language, + deviceModel: 'Desktop (Electron)', + }, + }) + } catch (err) { + setVoiceUnavailable(true) + setCallError(err instanceof Error ? err.message : 'Could not start voice intro.') + } + })() + return () => { + realtime.stop() + } + // realtime is stable enough across renders — starting it once on mount is the contract. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [previewMode]) + + const handleDone = useCallback(async () => { + const cleanedName = name.trim() || DEFAULT_USER_NAME + const cleanedBio = bio.trim() + if (!previewMode) { + setSaving(true) + try { + await userApi.save({ name: cleanedName, bio: cleanedBio }) + } catch (err) { + console.warn('[onboarding] user save failed', err) + } finally { + setSaving(false) + } + } + realtime.stop() + onContinue?.() + }, [name, bio, onContinue, previewMode, realtime]) + + const handleSkip = useCallback(async () => { + if (!previewMode) { + setSaving(true) + try { + await userApi.save({ name: DEFAULT_USER_NAME, bio: '' }) + } catch (err) { + console.warn('[onboarding] user skip save failed', err) + } finally { + setSaving(false) + } + } + realtime.stop() + onContinue?.() + }, [onContinue, previewMode, realtime]) + + const toggleMute = useCallback(() => { + setMuted((prev) => { + const next = !prev + realtime.setMuted(next) + return next + }) + }, [realtime]) + + const toggleOutputMute = useCallback(() => { + setOutputMuted((prev) => { + const next = !prev + realtime.setOutputMuted(next) + return next + }) + }, [realtime]) + + const status = computeCallStatus({ + isConnecting: !realtime.isConnected && !voiceUnavailable && !previewMode, + isReconnecting: realtime.isReconnecting, + voiceUnavailable, + stage, + agentName, + }) + + return ( + void handleDone(), + disabled: saving, + tone: 'accent', + }} + secondaryAction={{ label: 'Back', onClick: onBack }} + skipAction={{ label: 'Skip for now', onClick: () => void handleSkip() }} + onStartOver={onStartOver} + intense + > +
+ +
+ setNameTyping((m) => !m)} + stage={stage} + target="awaiting_name" + highlight={stage === 'awaiting_name' && name.trim().length > 0} + > + { + setName(e.target.value) + setNameTyping(true) + }} + placeholder={voiceUnavailable ? 'Type your name' : 'Captured from voice…'} + className="w-full rounded-[14px] border px-4 py-3 text-[15px] outline-none" + style={{ + borderColor: 'var(--line-strong)', + backgroundColor: 'var(--panel-strong)', + color: 'var(--ink)', + }} + /> + + setBioTyping((m) => !m)} + stage={stage} + target="awaiting_bio" + highlight={stage === 'awaiting_bio' && bio.trim().length > 0} + > +