+
+
+ {message.sender}
+
+ {new Date(message.timestamp * 1000).toLocaleTimeString()}
+
+
+ {/* Reply context callout - shown above user message when replying */}
+ {replyContext && (
+
+
+
+ )}
+
+
+
+ {message.options && message.options.length > 0 && (
+
+ Please select a response to continue:
+ {message.options.map((opt, index) => (
+
+ ))}
+
+ )}
+
+ {/* Reply button - positioned outside the bubble at top-right */}
+ {canReply && isHovered && (
+
}
+ variant="ghost"
+ size="sm"
+ onClick={handleReply}
+ tooltip="Reply to this message"
+ className={styles.replyButtonOutside}
+ />
+ )}
+
+ )
+
return (
setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
- {/* Message bubble container - for positioning reply button outside */}
-
-
-
- {message.sender}
-
- {new Date(message.timestamp * 1000).toLocaleTimeString()}
-
-
- {/* Reply context callout - shown above user message when replying */}
- {replyContext && (
-
-
-
- )}
-
-
-
-
- {/* Reply button - positioned outside the bubble at top-right */}
- {canReply && isHovered && (
-
}
- variant="ghost"
- size="sm"
- onClick={handleReply}
- tooltip="Reply to this message"
- className={styles.replyButtonOutside}
+ {isAgent ? (
+
+

- )}
-
+ {bubbleContainer}
+
+ ) : (
+ bubbleContainer
+ )}
{message.attachments && message.attachments.length > 0 && (
{
}
export function ChatPage() {
- const { messages, actions, connected, sendMessage, cancelTask, cancellingTaskId, openFile, openFolder, lastSeenMessageId, markMessagesAsSeen, replyTarget, setReplyTarget, clearReplyTarget, loadOlderMessages, hasMoreMessages, loadingOlderMessages } = useWebSocket()
+ const { messages, actions, connected, sendMessage, cancelTask, cancellingTaskId, openFile, openFolder, lastSeenMessageId, markMessagesAsSeen, replyTarget, setReplyTarget, clearReplyTarget, loadOlderMessages, hasMoreMessages, loadingOlderMessages, sendOptionClick } = useWebSocket()
// Derive agent status from actions and messages
const status = useDerivedAgentStatus({
@@ -556,6 +556,7 @@ export function ChatPage() {
onOpenFile={openFile}
onOpenFolder={openFolder}
onReply={handleChatReply}
+ onOptionClick={sendOptionClick}
/>
)
diff --git a/app/ui_layer/browser/frontend/src/pages/Onboarding/OnboardingPage.module.css b/app/ui_layer/browser/frontend/src/pages/Onboarding/OnboardingPage.module.css
index 31082228..31b30911 100644
--- a/app/ui_layer/browser/frontend/src/pages/Onboarding/OnboardingPage.module.css
+++ b/app/ui_layer/browser/frontend/src/pages/Onboarding/OnboardingPage.module.css
@@ -329,6 +329,324 @@
margin-top: var(--space-2);
}
+/* ── Profile Form (multi-field form step) ── */
+
+.profileForm {
+ flex: 1;
+ overflow-y: auto;
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-6);
+ padding-right: var(--space-2);
+ max-height: 420px;
+}
+
+.profileForm::-webkit-scrollbar {
+ width: 6px;
+}
+
+.profileForm::-webkit-scrollbar-track {
+ background: var(--bg-tertiary);
+ border-radius: var(--radius-full);
+}
+
+.profileForm::-webkit-scrollbar-thumb {
+ background: var(--border-secondary);
+ border-radius: var(--radius-full);
+}
+
+.profileForm::-webkit-scrollbar-thumb:hover {
+ background: var(--border-hover);
+}
+
+.formField {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-1);
+}
+
+.formFieldLabel {
+ font-size: var(--text-sm);
+ font-weight: var(--font-medium);
+ color: var(--text-primary);
+ margin-bottom: 2px;
+}
+
+.formFieldHint {
+ font-size: var(--text-xs);
+ color: var(--text-secondary);
+ margin-top: 4px;
+}
+
+/* ── Agent Identity step (avatar + name, side-by-side) ── */
+
+.identityCard {
+ display: flex;
+ flex-direction: row;
+ gap: var(--space-4);
+ /* Stretch children to the row's natural height so the avatar matches
+ the right section exactly (no feedback loop from min sizes). */
+ align-items: stretch;
+ flex-wrap: wrap;
+}
+
+.identityAvatar {
+ flex-shrink: 0;
+ display: flex;
+}
+
+/* Inside the identity card, the preview matches the row height so it
+ aligns with the right section. */
+.identityAvatar .imageUploadPreview {
+ width: 118px;
+ height: 118px;
+}
+
+.identityDetails {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-2);
+ flex: 1;
+ min-width: 220px;
+}
+
+.identityAvatarActions {
+ display: flex;
+ flex-direction: row;
+ gap: var(--space-2);
+ flex-wrap: wrap;
+ margin-top: var(--space-1);
+}
+
+/* ── Image upload field (agent profile picture) ── */
+
+.imageUploadRow {
+ display: flex;
+ align-items: center;
+ gap: var(--space-3);
+ flex-wrap: wrap;
+}
+
+.imageUploadPreview {
+ width: 96px;
+ height: 96px;
+ /* Square with the same fillet as chat bubbles */
+ border-radius: var(--radius-lg);
+ object-fit: cover;
+ border: 1px solid var(--border-primary);
+ background: var(--bg-tertiary);
+ flex-shrink: 0;
+}
+
+.imageUploadActions {
+ display: flex;
+ gap: var(--space-2);
+ flex-wrap: wrap;
+}
+
+.imageUploadError {
+ flex-basis: 100%;
+ font-size: var(--text-xs);
+ color: var(--color-error);
+}
+
+/* ── Shared radio dot styles ── */
+
+.formSelectOptionInline .optionRadio,
+.formSelectOptionVertical .optionRadio {
+ width: 14px;
+ height: 14px;
+ min-width: 14px;
+ min-height: 14px;
+ border: 2px solid var(--text-secondary);
+ border-radius: var(--radius-full);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ transition: all var(--transition-base);
+}
+
+.formSelectOptionInline .optionRadio::after,
+.formSelectOptionVertical .optionRadio::after {
+ content: '';
+ width: 6px;
+ height: 6px;
+ border-radius: var(--radius-full);
+ background: var(--color-primary);
+ opacity: 0;
+ transition: opacity var(--transition-base);
+}
+
+.formSelectOptionInline.selected .optionRadio,
+.formSelectOptionVertical.selected .optionRadio {
+ border-color: var(--color-primary);
+}
+
+.formSelectOptionInline.selected .optionRadio::after,
+.formSelectOptionVertical.selected .optionRadio::after {
+ opacity: 1;
+}
+
+.formSelectOptionInline:hover .optionRadio,
+.formSelectOptionVertical:hover .optionRadio {
+ border-color: var(--text-primary);
+}
+
+/* ── Inline select (no descriptions, e.g. Communication Tone) ── */
+
+.formSelectInline {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--space-2);
+}
+
+.formSelectOptionInline {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ padding: 8px 14px;
+ background: var(--bg-tertiary);
+ border: 1px solid var(--border-primary);
+ border-radius: var(--radius-full);
+ cursor: pointer;
+ transition: all var(--transition-base);
+ font-size: var(--text-sm);
+}
+
+.formSelectOptionInline:hover {
+ border-color: var(--text-secondary);
+}
+
+.formSelectOptionInline.selected {
+ border-color: var(--color-primary);
+ background: var(--color-primary-subtle);
+}
+
+/* ── Vertical select (with descriptions, e.g. Proactive Level) ── */
+
+.formSelectVertical {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-2);
+}
+
+.formSelectOptionVertical {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ padding: 10px 14px;
+ background: var(--bg-tertiary);
+ border: 1px solid var(--border-primary);
+ border-radius: var(--radius-lg);
+ cursor: pointer;
+ transition: all var(--transition-base);
+ font-size: var(--text-sm);
+}
+
+.formSelectOptionVertical:hover {
+ border-color: var(--text-secondary);
+}
+
+.formSelectOptionVertical.selected {
+ border-color: var(--color-primary);
+ background: var(--color-primary-subtle);
+}
+
+/* Native dropdown for large option lists (e.g., language) */
+.formDropdown {
+ width: 100%;
+ padding: var(--space-2) var(--space-3);
+ background: var(--bg-tertiary);
+ border: 1px solid var(--border-primary);
+ border-radius: var(--radius-lg);
+ color: var(--text-primary);
+ font-size: var(--text-sm);
+ font-family: var(--font-sans);
+ cursor: pointer;
+ transition: border-color var(--transition-base);
+ appearance: auto;
+}
+
+.formDropdown:focus {
+ outline: none;
+ border-color: var(--color-primary);
+}
+
+.formDropdown option {
+ background: var(--bg-secondary);
+ color: var(--text-primary);
+}
+
+.formSelectLabel {
+ font-size: var(--text-sm);
+ color: var(--text-primary);
+}
+
+.formSelectDesc {
+ font-size: var(--text-xs);
+ color: var(--text-secondary);
+ margin-left: 4px;
+}
+
+.formSelectDesc::before {
+ content: '— ';
+}
+
+/* Checkbox group for form fields */
+.formCheckboxGroup {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
+ gap: var(--space-2);
+}
+
+.formCheckboxItem {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ padding: 8px 12px;
+ background: var(--bg-tertiary);
+ border: 1px solid var(--border-primary);
+ border-radius: var(--radius-lg);
+ cursor: pointer;
+ transition: all var(--transition-base);
+ font-size: var(--text-sm);
+ color: var(--text-primary);
+}
+
+.formCheckboxItem:hover {
+ border-color: var(--text-secondary);
+}
+
+.formCheckboxItem.selected {
+ border-color: var(--color-primary);
+ background: var(--color-primary-subtle);
+}
+
+.formCheckboxItem .optionCheckbox {
+ width: 16px;
+ height: 16px;
+ min-width: 16px;
+ min-height: 16px;
+ border: 2px solid var(--text-secondary);
+ border-radius: var(--radius-sm);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ transition: all var(--transition-base);
+}
+
+.formCheckboxItem.selected .optionCheckbox {
+ border-color: var(--color-primary);
+ background: var(--color-primary);
+ color: var(--color-white, #fff);
+}
+
+.formCheckboxItem:hover .optionCheckbox {
+ border-color: var(--text-primary);
+}
+
/* Error Message */
.errorMessage {
display: flex;
diff --git a/app/ui_layer/browser/frontend/src/pages/Onboarding/OnboardingPage.tsx b/app/ui_layer/browser/frontend/src/pages/Onboarding/OnboardingPage.tsx
index 46bf5e23..3d5b7458 100644
--- a/app/ui_layer/browser/frontend/src/pages/Onboarding/OnboardingPage.tsx
+++ b/app/ui_layer/browser/frontend/src/pages/Onboarding/OnboardingPage.tsx
@@ -1,4 +1,4 @@
-import React, { useEffect, useState, useCallback } from 'react'
+import React, { useEffect, useState, useCallback, useRef } from 'react'
import { getOllamaInstallPercent } from '../../utils/ollamaInstall'
import {
Check,
@@ -27,11 +27,13 @@ import {
Wifi,
WifiOff,
RefreshCw,
+ Upload,
+ Trash2,
type LucideIcon,
} from 'lucide-react'
import { Button } from '../../components/ui'
import { useWebSocket } from '../../contexts/WebSocketContext'
-import type { OnboardingStep, OnboardingStepOption } from '../../types'
+import type { OnboardingStep, OnboardingStepOption, OnboardingFormField } from '../../types'
import styles from './OnboardingPage.module.css'
// Icon mapping for dynamic rendering
@@ -53,7 +55,7 @@ const ICON_MAP: Record
= {
Sheet,
}
-const STEP_NAMES = ['Provider', 'API Key', 'Agent Name', 'MCP Servers', 'Skills']
+const STEP_NAMES = ['Provider', 'API Key', 'Agent Name', 'User Profile', 'MCP Servers', 'Skills']
// ── Ollama local-setup component ─────────────────────────────────────────────
@@ -332,6 +334,10 @@ export function OnboardingPage() {
skipOnboardingStep,
goBackOnboardingStep,
localLLM,
+ agentProfilePictureUrl,
+ agentProfilePictureHasCustom,
+ uploadAgentProfilePicture,
+ removeAgentProfilePicture,
} = useWebSocket()
// Local form state
@@ -340,6 +346,34 @@ export function OnboardingPage() {
// URL submitted from OllamaSetup
const [ollamaUrl, setOllamaUrl] = useState('http://localhost:11434')
const [ollamaConnected, setOllamaConnected] = useState(false)
+ // Form step state (for user_profile and similar multi-field steps)
+ const [formValues, setFormValues] = useState>({})
+ // Picture upload state (for image_upload fields)
+ const [pictureUploading, setPictureUploading] = useState(false)
+ const [pictureError, setPictureError] = useState(null)
+ const pictureInputRef = useRef(null)
+
+ // Reset picture-upload feedback when transitioning between steps
+ useEffect(() => {
+ setPictureUploading(false)
+ setPictureError(null)
+ }, [onboardingStep?.name])
+
+ // Clear uploading spinner once the context reflects the new picture
+ useEffect(() => {
+ if (pictureUploading) {
+ setPictureUploading(false)
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [agentProfilePictureUrl])
+
+ // Safety: clear the spinner after a short timeout even if no ack arrives
+ // (e.g., on a failed upload that did not update the context URL).
+ useEffect(() => {
+ if (!pictureUploading) return
+ const t = window.setTimeout(() => setPictureUploading(false), 10000)
+ return () => window.clearTimeout(t)
+ }, [pictureUploading])
// Request first step when connected
useEffect(() => {
@@ -353,7 +387,17 @@ export function OnboardingPage() {
if (onboardingStep) {
setOllamaConnected(false)
- if (onboardingStep.name === 'mcp' || onboardingStep.name === 'skills') {
+ // Form step (e.g., user_profile, agent_name)
+ // Preserve existing values when navigating back — only set defaults for missing fields
+ if (onboardingStep.form_fields && onboardingStep.form_fields.length > 0) {
+ setFormValues(prev => {
+ const defaults: Record = {}
+ for (const field of onboardingStep.form_fields) {
+ defaults[field.name] = prev[field.name] ?? (field.default ?? '')
+ }
+ return defaults
+ })
+ } else if (onboardingStep.name === 'mcp' || onboardingStep.name === 'skills') {
setSelectedValue(Array.isArray(onboardingStep.default) ? onboardingStep.default : [])
} else if (onboardingStep.options.length > 0) {
const defaultOption = onboardingStep.options.find(opt => opt.default)
@@ -378,6 +422,46 @@ export function OnboardingPage() {
setOllamaConnected(true)
}, [])
+ const handlePictureSelect = useCallback(() => {
+ pictureInputRef.current?.click()
+ }, [])
+
+ const handlePictureChange = useCallback(
+ (e: React.ChangeEvent, fieldName: string) => {
+ const file = e.target.files?.[0]
+ e.target.value = ''
+ if (!file) return
+
+ setPictureError(null)
+ setPictureUploading(true)
+
+ const reader = new FileReader()
+ reader.onload = () => {
+ const result = reader.result as string
+ const base64 = result.includes(',') ? result.split(',', 2)[1] : result
+ // Mark this form field as "has picture" using the file extension
+ const ext = (file.name.split('.').pop() || '').toLowerCase()
+ setFormValues(prev => ({ ...prev, [fieldName]: ext }))
+ uploadAgentProfilePicture(file.name, file.type || 'application/octet-stream', base64)
+ }
+ reader.onerror = () => {
+ setPictureUploading(false)
+ setPictureError('Could not read file')
+ }
+ reader.readAsDataURL(file)
+ },
+ [uploadAgentProfilePicture]
+ )
+
+ const handlePictureRemove = useCallback(
+ (fieldName: string) => {
+ setPictureError(null)
+ setFormValues(prev => ({ ...prev, [fieldName]: '' }))
+ removeAgentProfilePicture()
+ },
+ [removeAgentProfilePicture]
+ )
+
const handleOptionSelect = useCallback((value: string) => {
if (!onboardingStep) return
if (onboardingStep.name === 'mcp' || onboardingStep.name === 'skills') {
@@ -396,18 +480,21 @@ export function OnboardingPage() {
if (isOllamaStep) {
submitOnboardingStep(ollamaUrl)
+ } else if (onboardingStep.form_fields && onboardingStep.form_fields.length > 0) {
+ submitOnboardingStep(formValues)
} else if (onboardingStep.options.length > 0) {
submitOnboardingStep(selectedValue)
} else {
submitOnboardingStep(textValue)
}
- }, [onboardingStep, selectedValue, textValue, ollamaUrl, submitOnboardingStep])
+ }, [onboardingStep, selectedValue, textValue, ollamaUrl, formValues, submitOnboardingStep])
const handleSkip = useCallback(() => skipOnboardingStep(), [skipOnboardingStep])
const handleBack = useCallback(() => goBackOnboardingStep(), [goBackOnboardingStep])
const isMultiSelect = onboardingStep?.name === 'mcp' || onboardingStep?.name === 'skills'
- const isWideStep = isMultiSelect
+ const isFormStep = !!(onboardingStep?.form_fields && onboardingStep.form_fields.length > 0)
+ const isWideStep = isMultiSelect || isFormStep
const isLastStep = onboardingStep ? onboardingStep.index === onboardingStep.total - 1 : false
const isOllamaStep =
@@ -419,6 +506,7 @@ export function OnboardingPage() {
if (isOllamaStep) {
return ollamaConnected || (localLLM.phase === 'connected' && !!localLLM.testResult?.success)
}
+ if (isFormStep) return true // All form fields are optional
if (onboardingStep.options.length > 0) {
return isMultiSelect ? true : !!selectedValue
}
@@ -457,6 +545,237 @@ export function OnboardingPage() {
)
}
+ // Agent Identity step — compact side-by-side layout (avatar + name)
+ if (
+ onboardingStep.name === 'agent_name' &&
+ onboardingStep.form_fields &&
+ onboardingStep.form_fields.length > 0
+ ) {
+ const nameField = onboardingStep.form_fields.find(f => f.field_type === 'text')
+ const avatarField = onboardingStep.form_fields.find(f => f.field_type === 'image_upload')
+
+ return (
+
+
+ {avatarField && (
+
+

+
handlePictureChange(e, avatarField.name)}
+ style={{ display: 'none' }}
+ />
+
+ )}
+
+ {nameField && (
+ <>
+
+
+ setFormValues((prev) => ({ ...prev, [nameField.name]: e.target.value }))
+ }
+ placeholder={nameField.placeholder || 'Enter a name'}
+ />
+ >
+ )}
+ {avatarField && (
+
+ }
+ >
+ {pictureUploading ? 'Uploading...' : 'Upload avatar'}
+
+ {agentProfilePictureHasCustom && (
+
+ )}
+
+ )}
+ {pictureError && (
+
{pictureError}
+ )}
+
+
+
+ )
+ }
+
+ // Form step (multi-field form, e.g., user_profile)
+ if (onboardingStep.form_fields && onboardingStep.form_fields.length > 0) {
+ return (
+
+
+ {onboardingStep.form_fields.map((field: OnboardingFormField) => (
+
+
+
+ {field.field_type === 'text' && (
+
setFormValues(prev => ({ ...prev, [field.name]: e.target.value }))}
+ placeholder={field.placeholder || `Enter ${field.label.toLowerCase()}`}
+ />
+ )}
+
+ {field.field_type === 'select' && field.options.length > 20 ? (
+ /* Large option list (e.g., languages) — use native dropdown */
+ <>
+
+ {field.placeholder && (
+
{field.placeholder}
+ )}
+ >
+ ) : field.field_type === 'select' ? (() => {
+ const hasDescriptions = field.options.some(o => o.description && o.description !== o.label)
+ if (hasDescriptions) {
+ /* Options with descriptions — vertical stack */
+ return (
+
+ {field.options.map(opt => {
+ const isSelected = formValues[field.name] === opt.value
+ return (
+
setFormValues(prev => ({ ...prev, [field.name]: opt.value }))}
+ >
+
+
{opt.label}
+ {opt.description && opt.description !== opt.label && (
+
{opt.description}
+ )}
+
+ )
+ })}
+
+ )
+ }
+ /* Simple options without descriptions — inline row */
+ return (
+
+ {field.options.map(opt => {
+ const isSelected = formValues[field.name] === opt.value
+ return (
+
setFormValues(prev => ({ ...prev, [field.name]: opt.value }))}
+ >
+
+
{opt.label}
+
+ )
+ })}
+
+ )
+ })() : null}
+
+ {field.field_type === 'image_upload' && (
+
+

+
+ handlePictureChange(e, field.name)}
+ style={{ display: 'none' }}
+ />
+ }
+ >
+ {pictureUploading ? 'Uploading...' : 'Upload'}
+
+ {agentProfilePictureHasCustom && (
+
+ )}
+
+ {pictureError && (
+
{pictureError}
+ )}
+
+ )}
+
+ {field.field_type === 'multi_checkbox' && (
+
+ {field.options.map(opt => {
+ const checked = Array.isArray(formValues[field.name]) &&
+ (formValues[field.name] as string[]).includes(opt.value)
+ return (
+
{
+ setFormValues(prev => {
+ const current = Array.isArray(prev[field.name]) ? (prev[field.name] as string[]) : []
+ const updated = current.includes(opt.value)
+ ? current.filter(v => v !== opt.value)
+ : [...current, opt.value]
+ return { ...prev, [field.name]: updated }
+ })
+ }}
+ >
+
+ {checked && }
+
+
{opt.label}
+
+ )
+ })}
+
+ )}
+
+ ))}
+
+
+ )
+ }
+
// Option-based step
if (onboardingStep.options.length > 0) {
return (
diff --git a/app/ui_layer/browser/frontend/src/pages/Settings/GeneralSettings.tsx b/app/ui_layer/browser/frontend/src/pages/Settings/GeneralSettings.tsx
index 82a951b2..a5cca7ff 100644
--- a/app/ui_layer/browser/frontend/src/pages/Settings/GeneralSettings.tsx
+++ b/app/ui_layer/browser/frontend/src/pages/Settings/GeneralSettings.tsx
@@ -9,6 +9,8 @@ import {
Loader2,
Download,
RefreshCw,
+ Upload,
+ Trash2,
} from 'lucide-react'
import { Button, Badge, ConfirmModal } from '../../components/ui'
import { useTheme } from '../../contexts/ThemeContext'
@@ -45,7 +47,7 @@ function getInitialAgentName(): string {
export function GeneralSettings() {
const { send, onMessage, isConnected } = useSettingsWebSocket()
- const { version } = useWebSocket()
+ const { version, agentProfilePictureUrl, agentProfilePictureHasCustom } = useWebSocket()
const { theme: globalTheme, setTheme: setGlobalTheme } = useTheme()
const [agentName, setAgentName] = useState(getInitialAgentName)
const [initialAgentName, setInitialAgentName] = useState(getInitialAgentName)
@@ -56,6 +58,21 @@ export function GeneralSettings() {
const [isSaving, setIsSaving] = useState(false)
const [saveStatus, setSaveStatus] = useState<'idle' | 'success' | 'error'>('idle')
+ // Agent profile picture
+ const [profilePictureUrl, setProfilePictureUrl] = useState(agentProfilePictureUrl)
+ const [hasCustomPicture, setHasCustomPicture] = useState(agentProfilePictureHasCustom)
+ const [pictureError, setPictureError] = useState(null)
+ const [isUploadingPicture, setIsUploadingPicture] = useState(false)
+ const pictureInputRef = useRef(null)
+
+ // Keep local preview in sync with the central context value (e.g. after reconnect)
+ useEffect(() => {
+ setProfilePictureUrl(agentProfilePictureUrl)
+ }, [agentProfilePictureUrl])
+ useEffect(() => {
+ setHasCustomPicture(agentProfilePictureHasCustom)
+ }, [agentProfilePictureHasCustom])
+
// Agent file states
const [userMdContent, setUserMdContent] = useState('')
const [originalUserMdContent, setOriginalUserMdContent] = useState('')
@@ -134,10 +151,45 @@ export function GeneralSettings() {
// Set up message handlers
const cleanups = [
onMessage('settings_get', (data: unknown) => {
- const d = data as { success: boolean; settings?: { agentName: string; theme: string } }
+ const d = data as {
+ success: boolean
+ settings?: {
+ agentName: string
+ theme: string
+ agentProfilePictureUrl?: string
+ agentProfilePictureHasCustom?: boolean
+ }
+ }
if (d.success && d.settings) {
setAgentName(d.settings.agentName)
setTheme(d.settings.theme)
+ if (d.settings.agentProfilePictureUrl) {
+ setProfilePictureUrl(d.settings.agentProfilePictureUrl)
+ }
+ if (typeof d.settings.agentProfilePictureHasCustom === 'boolean') {
+ setHasCustomPicture(d.settings.agentProfilePictureHasCustom)
+ }
+ }
+ }),
+ onMessage('agent_profile_picture_upload', (data: unknown) => {
+ const d = data as { success: boolean; url?: string; has_custom?: boolean; error?: string }
+ setIsUploadingPicture(false)
+ if (d.success && d.url) {
+ setProfilePictureUrl(d.url)
+ setHasCustomPicture(d.has_custom ?? true)
+ setPictureError(null)
+ } else {
+ setPictureError(d.error || 'Upload failed')
+ }
+ }),
+ onMessage('agent_profile_picture_remove', (data: unknown) => {
+ const d = data as { success: boolean; url?: string; has_custom?: boolean; error?: string }
+ if (d.success) {
+ setProfilePictureUrl(d.url || '/api/agent-profile-picture')
+ setHasCustomPicture(d.has_custom ?? false)
+ setPictureError(null)
+ } else {
+ setPictureError(d.error || 'Remove failed')
}
}),
onMessage('settings_update', (data: unknown) => {
@@ -292,6 +344,41 @@ export function GeneralSettings() {
setTimeout(() => setSaveStatus('idle'), 3000)
}
+ const handlePictureSelect = () => {
+ pictureInputRef.current?.click()
+ }
+
+ const handlePictureChange = (e: React.ChangeEvent) => {
+ const file = e.target.files?.[0]
+ e.target.value = '' // allow re-selecting the same file later
+ if (!file) return
+
+ setPictureError(null)
+ setIsUploadingPicture(true)
+
+ const reader = new FileReader()
+ reader.onload = () => {
+ const result = reader.result as string
+ // Strip data URL prefix → raw base64
+ const base64 = result.includes(',') ? result.split(',', 2)[1] : result
+ send('agent_profile_picture_upload', {
+ name: file.name,
+ mimeType: file.type || 'application/octet-stream',
+ content: base64,
+ })
+ }
+ reader.onerror = () => {
+ setIsUploadingPicture(false)
+ setPictureError('Could not read file')
+ }
+ reader.readAsDataURL(file)
+ }
+
+ const handlePictureRemove = () => {
+ setPictureError(null)
+ send('agent_profile_picture_remove')
+ }
+
const handleReset = () => {
confirm({
title: 'Reset Agent',
@@ -384,6 +471,58 @@ export function GeneralSettings() {
+
+
+
+

+
+
+
+ ) : (
+
+ )
+ }
+ >
+ {isUploadingPicture ? 'Uploading...' : 'Upload'}
+
+ {hasCustomPicture && (
+ }
+ >
+ Remove
+
+ )}
+
+
+
+ Shown next to agent messages in chat. PNG/JPG/WEBP/GIF, max 5 MB.
+
+ {pictureError && (
+
+ {pictureError}
+
+ )}
+
+
([])
@@ -150,6 +153,12 @@ export function SkillsSettings() {
showToast('error', d.error || 'Failed to get skill info')
}
}),
+ onMessage('skill_run', (data: unknown) => {
+ const d = data as { success: boolean; name?: string; error?: string }
+ if (!d.success) {
+ showToast('error', d.error || 'Failed to run skill')
+ }
+ }),
]
send('skill_list')
@@ -185,6 +194,12 @@ export function SkillsSettings() {
send('skill_info', { name })
}
+ const handleRunSkill = (name: string) => {
+ send('skill_run', { name })
+ setViewingSkill(null)
+ navigate('/chat')
+ }
+
const handleInstallSkill = () => {
const source = installSource.trim()
if (!source) {
@@ -533,6 +548,15 @@ export function SkillsSettings() {
+ {viewingSkill.enabled && (
+
+ )}