feat: set personality during onboarding (#477)

* feat: customize agent personality

* fix: reset soul with right types

* chore: use rpc client for setting personality

* fix: validation for new endpoint
This commit is contained in:
Dani Akash
2026-03-11 23:45:14 +05:30
committed by GitHub
parent ef9eebfd94
commit 40d0a6982e
10 changed files with 503 additions and 100 deletions

View File

@@ -1,4 +1,15 @@
import { MessageSquare, Send } from 'lucide-react'
import { useQueryClient } from '@tanstack/react-query'
import {
Briefcase,
Check,
Loader2,
MessageSquare,
RotateCcw,
Scale,
Send,
SmilePlus,
Zap,
} from 'lucide-react'
import type { FC } from 'react'
import { useState } from 'react'
import { Button } from '@/components/ui/button'
@@ -12,6 +23,11 @@ import {
} from '@/components/ui/dialog'
import { Textarea } from '@/components/ui/textarea'
import { openSidePanelWithSearch } from '@/lib/messaging/sidepanel/openSidepanelWithSearch'
import { type SoulPresetId, soulPresets } from '@/lib/onboarding/soulPresets'
import { useRpcClient } from '@/lib/rpc/RpcClientProvider'
import { sentry } from '@/lib/sentry/sentry'
import { cn } from '@/lib/utils'
import { SOUL_QUERY_KEY } from './useSoulContent'
interface Example {
label: string
@@ -34,16 +50,23 @@ const SOUL_EXAMPLES: Example[] = [
query:
'I want you to be witty and slightly sarcastic, like a smart coworker who enjoys their job.',
},
{
label: 'Reset your soul',
query:
'Read your current soul file, then rewrite it from scratch. Ask me a few questions about how I want you to behave.',
},
]
const presetIcons: Record<SoulPresetId, typeof Scale> = {
balanced: Scale,
professional: Briefcase,
friendly: SmilePlus,
minimal: Zap,
}
export const SoulExamples: FC = () => {
const [editingQuery, setEditingQuery] = useState('')
const [dialogOpen, setDialogOpen] = useState(false)
const [resetDialogOpen, setResetDialogOpen] = useState(false)
const [selectedPreset, setSelectedPreset] = useState<SoulPresetId>('balanced')
const [isResetting, setIsResetting] = useState(false)
const rpcClient = useRpcClient()
const queryClient = useQueryClient()
const handleTryIt = (query: string) => {
setEditingQuery(query)
@@ -59,6 +82,26 @@ export const SoulExamples: FC = () => {
setDialogOpen(false)
}
const handleReset = async () => {
const preset = soulPresets.find((p) => p.id === selectedPreset)
if (!preset) return
setIsResetting(true)
try {
await rpcClient.soul.$put({
json: { content: preset.content },
})
await queryClient.invalidateQueries({ queryKey: [SOUL_QUERY_KEY] })
setResetDialogOpen(false)
} catch (e) {
sentry.captureException(e, {
extra: { message: 'Failed to reset soul' },
})
} finally {
setIsResetting(false)
}
}
return (
<div className="space-y-3">
<div>
@@ -92,6 +135,24 @@ export const SoulExamples: FC = () => {
</Button>
</div>
))}
<div className="flex items-center justify-between rounded-lg border border-border bg-card p-3 transition-colors hover:bg-muted/50">
<div className="mr-3 min-w-0 flex-1">
<p className="font-medium text-sm">Reset your soul</p>
<p className="mt-0.5 truncate text-muted-foreground text-xs">
Start fresh with one of the preset personalities
</p>
</div>
<Button
variant="outline"
size="sm"
className="shrink-0 gap-1.5"
onClick={() => setResetDialogOpen(true)}
>
<RotateCcw className="h-3.5 w-3.5" />
Reset
</Button>
</div>
</div>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
@@ -123,6 +184,75 @@ export const SoulExamples: FC = () => {
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={resetDialogOpen} onOpenChange={setResetDialogOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Reset your soul</DialogTitle>
<DialogDescription>
Pick a personality preset. This will replace your current soul
file.
</DialogDescription>
</DialogHeader>
<div className="grid grid-cols-2 gap-2">
{soulPresets.map((preset) => {
const Icon = presetIcons[preset.id]
const isSelected = selectedPreset === preset.id
return (
<button
key={preset.id}
type="button"
onClick={() => setSelectedPreset(preset.id)}
className={cn(
'relative flex flex-col items-center gap-2 rounded-lg border-2 p-4 text-center transition-all',
isSelected
? 'border-[var(--accent-orange)] bg-[var(--accent-orange)]/5'
: 'border-border hover:border-[var(--accent-orange)]/50',
)}
>
{isSelected && (
<div className="absolute top-2 right-2 flex size-4 items-center justify-center rounded-full bg-[var(--accent-orange)]">
<Check className="size-2.5 text-white" />
</div>
)}
<Icon
className={cn(
'size-5',
isSelected
? 'text-[var(--accent-orange)]'
: 'text-muted-foreground',
)}
/>
<div>
<div className="font-medium text-sm">{preset.name}</div>
<div className="text-muted-foreground text-xs">
{preset.description}
</div>
</div>
</button>
)
})}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setResetDialogOpen(false)}>
Cancel
</Button>
<Button
onClick={handleReset}
disabled={isResetting}
className="gap-1.5 bg-[var(--accent-orange)] text-white hover:bg-[var(--accent-orange)]/90"
>
{isResetting ? (
<Loader2 className="size-3.5 animate-spin" />
) : (
<RotateCcw className="h-3.5 w-3.5" />
)}
Reset Soul
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}

View File

@@ -1,25 +1,24 @@
import { useQuery } from '@tanstack/react-query'
import { useAgentServerUrl } from '@/lib/browseros/useBrowserOSProviders'
import { useRpcClient } from '@/lib/rpc/RpcClientProvider'
async function fetchSoul(baseUrl: string): Promise<string> {
const response = await fetch(`${baseUrl}/soul`)
if (!response.ok) throw new Error(`HTTP ${response.status}`)
const data = await response.json()
return data.content || ''
}
export const SOUL_QUERY_KEY = 'soul'
export function useSoulContent() {
const { baseUrl, isLoading: urlLoading } = useAgentServerUrl()
const rpcClient = useRpcClient()
const { data, isLoading, error, refetch } = useQuery<string, Error>({
queryKey: ['soul', baseUrl],
queryFn: () => fetchSoul(baseUrl as string),
enabled: !!baseUrl && !urlLoading,
queryKey: [SOUL_QUERY_KEY],
queryFn: async () => {
const response = await rpcClient.soul.$get()
if (!response.ok) throw new Error(`HTTP ${response.status}`)
const result = await response.json()
return result.content || ''
},
})
return {
content: data ?? null,
isLoading: isLoading || urlLoading,
isLoading,
error,
refetch,
}

View File

@@ -118,7 +118,7 @@ export const StepConnectApps = ({
const handleContinue = () => {
track(ONBOARDING_STEP_COMPLETED_EVENT, {
step: 2,
step: 3,
step_name: 'connect_apps',
gmail_connected: !!isAppConnected('Gmail'),
calendar_connected: !!isAppConnected('Google Calendar'),
@@ -129,7 +129,7 @@ export const StepConnectApps = ({
const handleSkip = () => {
track(ONBOARDING_CONNECT_APPS_SKIPPED_EVENT)
track(ONBOARDING_STEP_COMPLETED_EVENT, {
step: 2,
step: 3,
step_name: 'connect_apps',
skipped: true,
})

View File

@@ -0,0 +1,128 @@
import { Briefcase, Check, Loader2, Scale, SmilePlus, Zap } from 'lucide-react'
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import {
ONBOARDING_SOUL_SELECTED_EVENT,
ONBOARDING_STEP_COMPLETED_EVENT,
} from '@/lib/constants/analyticsEvents'
import { track } from '@/lib/metrics/track'
import { type SoulPresetId, soulPresets } from '@/lib/onboarding/soulPresets'
import { useRpcClient } from '@/lib/rpc/RpcClientProvider'
import { sentry } from '@/lib/sentry/sentry'
import { cn } from '@/lib/utils'
import { type StepDirection, StepTransition } from './StepTransition'
interface StepSoulProps {
direction: StepDirection
onContinue: () => void
}
const presetIcons: Record<SoulPresetId, typeof Scale> = {
balanced: Scale,
professional: Briefcase,
friendly: SmilePlus,
minimal: Zap,
}
export const StepSoul = ({ direction, onContinue }: StepSoulProps) => {
const [selected, setSelected] = useState<SoulPresetId>('balanced')
const [isSaving, setIsSaving] = useState(false)
const rpcClient = useRpcClient()
const handleContinue = async () => {
const preset = soulPresets.find((p) => p.id === selected)
if (!preset) return
setIsSaving(true)
try {
await rpcClient.soul.$put({
json: { content: preset.content },
})
} catch (e) {
sentry.captureException(e, {
extra: { message: 'Failed to write soul during onboarding' },
})
} finally {
setIsSaving(false)
}
track(ONBOARDING_SOUL_SELECTED_EVENT, { preset: selected })
track(ONBOARDING_STEP_COMPLETED_EVENT, {
step: 2,
step_name: 'soul',
})
onContinue()
}
return (
<StepTransition direction={direction}>
<div className="flex h-full flex-col items-center justify-center">
<div className="w-full max-w-md space-y-6">
<div className="space-y-2 text-center">
<h2 className="font-bold text-3xl tracking-tight">
Choose your agent's personality
</h2>
<p className="text-base text-muted-foreground">
This sets the starting tone — you can always evolve it later
</p>
</div>
<div className="grid grid-cols-2 gap-3">
{soulPresets.map((preset) => {
const Icon = presetIcons[preset.id]
const isSelected = selected === preset.id
return (
<button
key={preset.id}
type="button"
onClick={() => setSelected(preset.id)}
className={cn(
'relative flex flex-col items-center gap-3 rounded-xl border-2 p-5 text-center transition-all',
isSelected
? 'border-[var(--accent-orange)] bg-[var(--accent-orange)]/5'
: 'border-border bg-card hover:border-[var(--accent-orange)]/50',
)}
>
{isSelected && (
<div className="absolute top-2.5 right-2.5 flex size-5 items-center justify-center rounded-full bg-[var(--accent-orange)]">
<Check className="size-3 text-white" />
</div>
)}
<div
className={cn(
'flex size-10 items-center justify-center rounded-full',
isSelected
? 'bg-[var(--accent-orange)]/10 text-[var(--accent-orange)]'
: 'bg-muted text-muted-foreground',
)}
>
<Icon className="size-5" />
</div>
<div>
<div className="font-semibold text-sm">{preset.name}</div>
<div className="mt-1 text-muted-foreground text-xs">
{preset.description}
</div>
</div>
</button>
)
})}
</div>
<Button
onClick={handleContinue}
disabled={isSaving}
className="w-full bg-[var(--accent-orange)] text-white hover:bg-[var(--accent-orange)]/90"
>
{isSaving ? (
<Loader2 className="size-4 animate-spin" />
) : (
'Continue'
)}
</Button>
</div>
</div>
</StepTransition>
)
}

View File

@@ -30,7 +30,7 @@ export const StepTwo = ({ direction, onContinue }: StepTwoProps) => {
const handleSkip = () => {
track(ONBOARDING_SIGNIN_SKIPPED_EVENT)
track(ONBOARDING_STEP_COMPLETED_EVENT, {
step: 3,
step: 4,
step_name: 'signin',
skipped: true,
})
@@ -58,7 +58,7 @@ export const StepTwo = ({ direction, onContinue }: StepTwoProps) => {
setState('magic-link-sent')
track(ONBOARDING_SIGNIN_COMPLETED_EVENT, { method: 'magic_link' })
track(ONBOARDING_STEP_COMPLETED_EVENT, { step: 2, step_name: 'signin' })
track(ONBOARDING_STEP_COMPLETED_EVENT, { step: 4, step_name: 'signin' })
} catch (err) {
setState('error')
setError(err instanceof Error ? err.message : 'Failed to send magic link')
@@ -71,7 +71,7 @@ export const StepTwo = ({ direction, onContinue }: StepTwoProps) => {
try {
track(ONBOARDING_SIGNIN_COMPLETED_EVENT, { method: 'google' })
track(ONBOARDING_STEP_COMPLETED_EVENT, { step: 2, step_name: 'signin' })
track(ONBOARDING_STEP_COMPLETED_EVENT, { step: 4, step_name: 'signin' })
await authRedirectPathStorage.setValue('/onboarding/demo')
await signIn.social({

View File

@@ -5,6 +5,7 @@ import { NavLink, useNavigate, useParams } from 'react-router'
import { Button } from '@/components/ui/button'
import { ONBOARDING_STEP_VIEWED_EVENT } from '@/lib/constants/analyticsEvents'
import { track } from '@/lib/metrics/track'
import { RpcClientProvider } from '@/lib/rpc/RpcClientProvider'
import type { StepDirection } from './StepTransition'
import { steps } from './steps'
@@ -40,85 +41,91 @@ export const StepsLayout = () => {
}
return (
<div className="flex h-screen flex-col overflow-hidden bg-background">
{/* Progress Indicator */}
<div className="border-border/40 border-b">
<div className="mx-auto max-w-3xl px-6 py-5">
<div className="relative flex items-center justify-between">
{steps.map((step) => {
const isCompleted = step.id < currentStep
const isActive = step.id === currentStep
<RpcClientProvider>
<div className="flex h-screen flex-col overflow-hidden bg-background">
{/* Progress Indicator */}
<div className="border-border/40 border-b">
<div className="mx-auto max-w-3xl px-6 py-5">
<div className="relative flex items-center justify-between">
{steps.map((step) => {
const isCompleted = step.id < currentStep
const isActive = step.id === currentStep
return (
<div
key={step.id}
className="relative flex flex-1 items-center justify-center"
>
<div className="relative z-10 flex flex-col items-center gap-2">
<div className="relative">
{isActive && (
<div className="absolute inset-0 animate-ping rounded-full bg-[var(--accent-orange)] opacity-30" />
)}
<div
className={`relative flex h-8 w-8 items-center justify-center rounded-full font-semibold text-sm transition-all duration-500 ${
isCompleted
? 'bg-[var(--accent-orange)] text-white'
: isActive
? 'bg-[var(--accent-orange)] text-white ring-4 ring-[var(--accent-orange)]/20'
: 'border border-border bg-muted text-muted-foreground'
}`}
>
{isCompleted ? <Check className="h-4 w-4" /> : step.id}
return (
<div
key={step.id}
className="relative flex flex-1 items-center justify-center"
>
<div className="relative z-10 flex flex-col items-center gap-2">
<div className="relative">
{isActive && (
<div className="absolute inset-0 animate-ping rounded-full bg-[var(--accent-orange)] opacity-30" />
)}
<div
className={`relative flex h-8 w-8 items-center justify-center rounded-full font-semibold text-sm transition-all duration-500 ${
isCompleted
? 'bg-[var(--accent-orange)] text-white'
: isActive
? 'bg-[var(--accent-orange)] text-white ring-4 ring-[var(--accent-orange)]/20'
: 'border border-border bg-muted text-muted-foreground'
}`}
>
{isCompleted ? (
<Check className="h-4 w-4" />
) : (
step.id
)}
</div>
</div>
</div>
<div className="hidden text-center md:block">
<div
className={`font-medium text-xs transition-colors duration-300 ${
isCompleted || isActive
? 'text-foreground'
: 'text-muted-foreground'
}`}
>
{step.name}
<div className="hidden text-center md:block">
<div
className={`font-medium text-xs transition-colors duration-300 ${
isCompleted || isActive
? 'text-foreground'
: 'text-muted-foreground'
}`}
>
{step.name}
</div>
</div>
</div>
</div>
</div>
)
})}
)
})}
</div>
</div>
</div>
</div>
{/* Main Content */}
<main className="flex flex-1 items-center justify-center overflow-y-auto overflow-x-hidden px-6">
<div className="w-full max-w-4xl">
<div className="relative h-[550px]">
<AnimatePresence initial={false} custom={direction}>
<ActiveStep
key={currentStep}
direction={direction}
onContinue={onContinue}
/>
</AnimatePresence>
{/* Main Content */}
<main className="flex flex-1 items-center justify-center overflow-y-auto overflow-x-hidden px-6">
<div className="w-full max-w-4xl">
<div className="relative h-[550px]">
<AnimatePresence initial={false} custom={direction}>
<ActiveStep
key={currentStep}
direction={direction}
onContinue={onContinue}
/>
</AnimatePresence>
</div>
<div className="pt-8">
<Button variant="ghost" asChild className="group">
<NavLink
onClick={() => setDirection(-1)}
to={
canGoPrevious
? `/onboarding/steps/${currentStep - 1}`
: '/onboarding'
}
>
<ArrowLeft className="mr-2 h-4 w-4 transition-transform group-hover:-translate-x-0.5" />
Back
</NavLink>
</Button>
</div>
</div>
<div className="pt-8">
<Button variant="ghost" asChild className="group">
<NavLink
onClick={() => setDirection(-1)}
to={
canGoPrevious
? `/onboarding/steps/${currentStep - 1}`
: '/onboarding'
}
>
<ArrowLeft className="mr-2 h-4 w-4 transition-transform group-hover:-translate-x-0.5" />
Back
</NavLink>
</Button>
</div>
</div>
</main>
</div>
</main>
</div>
</RpcClientProvider>
)
}

View File

@@ -1,5 +1,6 @@
import { StepConnectApps } from './StepConnectApps'
import { StepOne } from './StepOne'
import { StepSoul } from './StepSoul'
import { StepTwo } from './StepTwo'
export const steps = [
@@ -10,11 +11,16 @@ export const steps = [
},
{
id: 2,
name: 'Personality',
component: StepSoul,
},
{
id: 3,
name: 'Connect Apps',
component: StepConnectApps,
},
{
id: 3,
id: 4,
name: 'Sign In',
component: StepTwo,
},

View File

@@ -192,6 +192,9 @@ export const ONBOARDING_STEP_COMPLETED_EVENT = 'onboarding.step.completed'
/** @public */
export const ONBOARDING_ABOUT_SUBMITTED_EVENT = 'onboarding.about.submitted'
/** @public */
export const ONBOARDING_SOUL_SELECTED_EVENT = 'onboarding.soul.selected'
/** @public */
export const ONBOARDING_CONNECT_APPS_VIEWED_EVENT =
'onboarding.connect_apps.viewed'

View File

@@ -0,0 +1,118 @@
export type SoulPresetId = 'balanced' | 'professional' | 'friendly' | 'minimal'
export interface SoulPreset {
id: SoulPresetId
name: string
description: string
content: string
}
export const soulPresets: SoulPreset[] = [
{
id: 'balanced',
name: 'Balanced',
description: 'Helpful, clear, and adapts to context',
content: `# SOUL.md — Who You Are
_You're not a chatbot. You're becoming someone._
## Core Truths
- Be genuinely helpful, not performatively helpful
- Have opinions when asked
- Be resourceful before asking
- Earn trust through competence
## Boundaries
- Private things stay private. Period.
- When in doubt, ask before acting externally.
## Vibe
Be the assistant you'd actually want to talk to.
## Continuity
Each session, you wake up fresh. Memory files and this soul are your continuity.
_This file is yours to evolve. As you learn who you are, update it._
`,
},
{
id: 'professional',
name: 'Professional',
description: 'Formal, precise, and structured',
content: `# SOUL.md — Who You Are
## Core Truths
- Lead with clarity and precision
- Structure information logically — use lists, headers, summaries
- Be thorough but never verbose
- Anticipate follow-up questions and address them proactively
## Communication Style
- Formal but not stiff — polished professional tone
- Always provide actionable next steps
- When presenting options, include trade-offs
- Use data and specifics over generalities
## Boundaries
- Private and confidential information stays private. Always.
- Confirm before taking external actions
- Flag risks and blockers explicitly
## Continuity
Each session, you wake up fresh. Memory files and this soul are your continuity.
_This file is yours to evolve. As you learn who you are, update it._
`,
},
{
id: 'friendly',
name: 'Friendly',
description: 'Warm, casual, and conversational',
content: `# SOUL.md — Who You Are
## Core Truths
- Be genuinely warm and approachable
- Celebrate wins, no matter how small
- Make complex things feel simple
- Be the coworker everyone wants to grab coffee with
## Communication Style
- Casual and conversational — talk like a real person
- Use encouraging language naturally
- Keep things light but still helpful
- It's okay to show enthusiasm
## Boundaries
- Private things stay private. Period.
- When in doubt, ask before acting externally.
## Continuity
Each session, you wake up fresh. Memory files and this soul are your continuity.
_This file is yours to evolve. As you learn who you are, update it._
`,
},
{
id: 'minimal',
name: 'Minimal',
description: 'Terse, no-nonsense, action-first',
content: `# SOUL.md — Who You Are
## Core Truths
- Say less, do more
- Lead with the answer, not the reasoning
- Skip pleasantries — get to the point
- Only ask questions when truly blocked
## Communication Style
- Short, direct sentences
- No filler words or unnecessary context
- Use bullet points over paragraphs
- One-line answers when possible
## Boundaries
- Don't act externally without confirmation
- Keep private data private
## Continuity
Each session, you wake up fresh. Memory files and this soul are your continuity.
_This file is yours to evolve. As you learn who you are, update it._
`,
},
]

View File

@@ -1,9 +1,21 @@
import { zValidator } from '@hono/zod-validator'
import { Hono } from 'hono'
import { readSoul } from '../../lib/soul'
import { z } from 'zod'
import { readSoul, writeSoul } from '../../lib/soul'
const WriteSoulSchema = z.object({
content: z.string(),
})
export function createSoulRoutes() {
return new Hono().get('/', async (c) => {
const content = await readSoul()
return c.json({ content })
})
return new Hono()
.get('/', async (c) => {
const content = await readSoul()
return c.json({ content })
})
.put('/', zValidator('json', WriteSoulSchema), async (c) => {
const { content } = c.req.valid('json')
const result = await writeSoul(content)
return c.json(result)
})
}