Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions desktop/src/main/ipc-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ import {
speakGreetingPreview,
writeAgentIdentity,
} from './identity'
import {
type UserProfile,
readUserProfile,
writeUserProfile,
} from './user-profile'
import { getAllocatedPorts } from './ports'
import {
type OnboardingPayload,
Expand Down Expand Up @@ -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<UserProfile>) => {
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/<provider>/ — this
// handler never hits the network and does not need an API key.
Expand Down
26 changes: 26 additions & 0 deletions desktop/src/main/onboarding.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
12 changes: 10 additions & 2 deletions desktop/src/main/onboarding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export type WizardStepId =
| 'provider'
| 'brain'
| 'identity'
| 'testcall'
| 'introduction'

export type OnboardingPayload = {
signedIn?: boolean
Expand Down Expand Up @@ -86,7 +86,7 @@ export function getOnboardingState(): OnboardingState {
}

return {
currentStep: row.currentStep,
currentStep: migrateStepId(row.currentStep),
payload: parsePayload(row.payload),
completedAt: row.completedAt,
}
Expand Down Expand Up @@ -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,
Expand Down
128 changes: 128 additions & 0 deletions desktop/src/main/user-profile.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

const writes: { path: string; content: string }[] = []
const fileSystem = new Map<string, string>()

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)
})
})
84 changes: 84 additions & 0 deletions desktop/src/main/user-profile.ts
Original file line number Diff line number Diff line change
@@ -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>): 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
}
7 changes: 6 additions & 1 deletion desktop/src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ type WizardStepId =
| 'provider'
| 'brain'
| 'identity'
| 'testcall'
| 'introduction'

type OnboardingPayload = {
signedIn?: boolean
Expand Down Expand Up @@ -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<
Expand Down
3 changes: 2 additions & 1 deletion desktop/src/renderer/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ const ONBOARDING_STEP_IDS: WizardStepId[] = [
'permissions',
'provider',
'brain',
'testcall',
'identity',
'introduction',
]

export function App() {
Expand Down
21 changes: 20 additions & 1 deletion desktop/src/renderer/src/lib/onboarding-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export type WizardStepId =
| 'provider'
| 'brain'
| 'identity'
| 'testcall'
| 'introduction'

export type ProviderId = 'gemini' | 'openai' | 'xai'

Expand All @@ -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?: {
Expand Down Expand Up @@ -99,6 +109,10 @@ declare global {
| { ok: false; error: string }
>
}
user: {
get: () => Promise<UserProfile>
save: (patch: UserProfilePatch) => Promise<UserProfile>
}
}
}
}
Expand Down Expand Up @@ -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),
}
Loading
Loading