profile pic for agents

This commit is contained in:
Saumya 2025-08-11 20:25:11 +05:30
parent 123117e61d
commit b8c42f98e3
19 changed files with 555 additions and 148 deletions

View File

@ -702,6 +702,7 @@ async def get_thread_agent(thread_id: str, user_id: str = Depends(get_current_us
tags=agent_data.get('tags', []),
avatar=agent_config.get('avatar'),
avatar_color=agent_config.get('avatar_color'),
profile_image_url=agent_config.get('profile_image_url'),
created_at=agent_data['created_at'],
updated_at=agent_data.get('updated_at', agent_data['created_at']),
current_version_id=agent_data.get('current_version_id'),
@ -1579,6 +1580,7 @@ async def get_agents(
tags=agent.get('tags', []),
avatar=agent_config.get('avatar'),
avatar_color=agent_config.get('avatar_color'),
profile_image_url=agent_config.get('profile_image_url'),
created_at=agent['created_at'],
updated_at=agent['updated_at'],
current_version_id=agent.get('current_version_id'),
@ -1704,6 +1706,7 @@ async def get_agent(agent_id: str, user_id: str = Depends(get_current_user_id_fr
tags=agent_data.get('tags', []),
avatar=agent_config.get('avatar'),
avatar_color=agent_config.get('avatar_color'),
profile_image_url=agent_config.get('profile_image_url'),
created_at=agent_data['created_at'],
updated_at=agent_data.get('updated_at', agent_data['created_at']),
current_version_id=agent_data.get('current_version_id'),
@ -2018,6 +2021,7 @@ async def create_agent(
tags=agent.get('tags', []),
avatar=agent.get('avatar'),
avatar_color=agent.get('avatar_color'),
profile_image_url=agent.get('profile_image_url'),
created_at=agent['created_at'],
updated_at=agent.get('updated_at', agent['created_at']),
current_version_id=agent.get('current_version_id'),
@ -2372,6 +2376,7 @@ async def update_agent(
tags=agent.get('tags', []),
avatar=agent_config.get('avatar'),
avatar_color=agent_config.get('avatar_color'),
profile_image_url=agent_config.get('profile_image_url'),
created_at=agent['created_at'],
updated_at=agent.get('updated_at', agent['created_at']),
current_version_id=agent.get('current_version_id'),

View File

@ -73,7 +73,7 @@ export default function AgentConfigurationPage() {
const [originalData, setOriginalData] = useState<FormData>(formData);
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
const initialTab = tabParam === 'configuration' ? 'configuration' : 'agent-builder';
const initialTab = tabParam === 'agent-builder' ? 'agent-builder' : 'configuration';
const [activeTab, setActiveTab] = useState(initialTab);
useEffect(() => {
@ -531,12 +531,12 @@ export default function AgentConfigurationPage() {
>
{isSaving ? (
<>
<Loader2 className="h-3 w-3 animate-spin mr-2" />
<Loader2 className="h-3 w-3 animate-spin" />
Saving...
</>
) : (
<>
<Save className="h-3 w-3 mr-2" />
<Save className="h-3 w-3" />
Save
</>
)}

View File

@ -162,13 +162,13 @@ export default function AgentsPage() {
marketplace_published_at: template.marketplace_published_at,
avatar: template.avatar,
avatar_color: template.avatar_color,
profile_image_url: template.profile_image_url,
template_id: template.template_id,
is_kortix_team: template.is_kortix_team,
mcp_requirements: template.mcp_requirements,
metadata: template.metadata,
};
// Apply search filtering to each item
const matchesSearch = !marketplaceSearchQuery.trim() || (() => {
const searchLower = marketplaceSearchQuery.toLowerCase();
return item.name.toLowerCase().includes(searchLower) ||

View File

@ -689,6 +689,7 @@ export default function ThreadPage({
agentName={agent && agent.name}
agentAvatar={agent && agent.avatar}
agentMetadata={agent?.metadata}
agentData={agent}
scrollContainerRef={scrollContainerRef}
/>

View File

@ -27,6 +27,7 @@ interface Agent {
is_default: boolean;
created_at?: string;
updated_at?: string;
profile_image_url?: string;
}
interface AgentPreviewProps {
@ -63,6 +64,25 @@ export const AgentPreview = ({ agent, agentMetadata }: AgentPreviewProps) => {
const { avatar, color } = getAgentStyling();
const agentAvatarComponent = React.useMemo(() => {
if (isSunaAgent) {
return <KortixLogo size={16} />;
}
if (agent.profile_image_url) {
return (
<img
src={agent.profile_image_url}
alt={agent.name}
className="h-4 w-4 rounded-sm object-cover"
/>
);
}
if (avatar) {
return <div className="text-base leading-none">{avatar}</div>;
}
return <KortixLogo size={16} />;
}, [agent.profile_image_url, agent.name, avatar, isSunaAgent]);
const initiateAgentMutation = useInitiateAgentWithInvalidation();
const addUserMessageMutation = useAddUserMessageMutation();
const startAgentMutation = useStartAgentMutation();
@ -328,13 +348,20 @@ export const AgentPreview = ({ agent, agentMetadata }: AgentPreviewProps) => {
streamHookStatus={streamHookStatus}
isPreviewMode={true}
agentName={agent.name}
agentAvatar={avatar}
agentAvatar={agentAvatarComponent}
agentMetadata={agentMetadata}
agentData={agent}
emptyStateComponent={
<div className="flex flex-col items-center text-center text-muted-foreground/80">
<div className="flex w-20 aspect-square items-center justify-center rounded-2xl bg-muted-foreground/10 p-4 mb-4">
{isSunaAgent ? (
<KortixLogo size={36} />
) : agent.profile_image_url ? (
<img
src={agent.profile_image_url}
alt={agent.name}
className="w-12 h-12 rounded-xl object-cover"
/>
) : (
<div className="text-4xl">{avatar}</div>
)}

View File

@ -107,21 +107,21 @@ const AgentModal: React.FC<AgentModalProps> = ({
<DialogContent className="max-w-md p-0 overflow-hidden border-none">
<DialogTitle className="sr-only">Agent actions</DialogTitle>
<div className="relative">
<div className={`h-32 flex items-center justify-center relative bg-gradient-to-br from-opacity-90 to-opacity-100`} style={{ backgroundColor: isSunaAgent ? '' : color }}>
<div className={`p-4 h-24 flex items-start justify-start relative`}>
{isSunaAgent ? (
<div className="p-6">
<KortixLogo size={48} />
<KortixLogo size={24} />
</div>
) : agent.profile_image_url ? (
<img src={agent.profile_image_url} alt={agent.name} className="h-24 w-24 rounded-xl object-cover shadow" />
<img src={agent.profile_image_url} alt={agent.name} className="h-16 w-16 rounded-xl object-cover" />
) : (
<div className="text-6xl drop-shadow-sm">
<div className="text-6xl">
{avatar}
</div>
)}
</div>
<div className="p-4 space-y-4">
<div className="p-4 space-y-2">
<div>
<div className="flex items-center gap-2 mb-2">
<h2 className="text-xl font-semibold text-foreground">

View File

@ -1,8 +1,7 @@
import React from 'react';
import React, { useState } from 'react';
import { Sparkles, Settings, MoreHorizontal, Download, Image as ImageIcon } from 'lucide-react';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { EditableText } from '@/components/ui/editable';
// import { StylePicker } from '../style-picker';
import { cn } from '@/lib/utils';
import { toast } from 'sonner';
import { KortixLogo } from '@/components/sidebar/kortix-logo';
@ -15,7 +14,7 @@ import {
import { Button } from '@/components/ui/button';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { createClient } from '@/lib/supabase/client';
import { ProfilePictureDialog } from './profile-picture-dialog';
interface AgentHeaderProps {
agentId: string;
@ -37,6 +36,7 @@ interface AgentHeaderProps {
isExporting?: boolean;
agentMetadata?: {
is_suna_default?: boolean;
centrally_managed?: boolean;
restrictions?: {
name_editable?: boolean;
};
@ -56,6 +56,7 @@ export function AgentHeader({
isExporting = false,
agentMetadata,
}: AgentHeaderProps) {
const [isProfileDialogOpen, setIsProfileDialogOpen] = useState(false);
const isSunaAgent = agentMetadata?.is_suna_default || false;
const restrictions = agentMetadata?.restrictions || {};
const isNameEditable = !isViewingOldVersion && (restrictions.name_editable !== false);
@ -70,42 +71,8 @@ export function AgentHeader({
onFieldChange('name', value);
};
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
try {
const file = e.target.files?.[0];
if (!file) return;
const supabase = createClient();
const { data: { session } } = await supabase.auth.getSession();
if (!session) {
toast.error('You must be logged in to upload images');
return;
}
const form = new FormData();
form.append('file', file);
const res = await fetch(`${process.env.NEXT_PUBLIC_BACKEND_URL}/agents/profile-image/upload`, {
method: 'POST',
body: form,
headers: {
'Authorization': `Bearer ${session.access_token}`,
}
});
if (!res.ok) throw new Error('Upload failed');
const data = await res.json();
if (data?.url) {
onFieldChange('profile_image_url', data.url);
toast.success('Profile image updated');
}
} catch (err) {
console.error(err);
toast.error('Failed to upload image');
} finally {
e.target.value = '';
}
const handleImageUpdate = (url: string | null) => {
onFieldChange('profile_image_url', url);
};
return (
@ -118,18 +85,21 @@ export function AgentHeader({
</div>
) : (
<div className="flex items-center gap-2">
<label className="cursor-pointer">
<input type="file" accept="image/*" className="hidden" onChange={handleImageUpload} />
<Avatar className="h-9 w-9 rounded-lg ring-1 ring-black/5 hover:ring-black/10">
<button
className="cursor-pointer transition-opacity hover:opacity-80"
onClick={() => setIsProfileDialogOpen(true)}
type="button"
>
<Avatar className="h-9 w-9 rounded-lg ring-1 ring-black/5 hover:ring-black/10 transition-colors">
{displayData.profile_image_url ? (
<AvatarImage src={displayData.profile_image_url} alt={displayData.name} />
) : (
<AvatarFallback className="rounded-lg text-xs">
<AvatarFallback className="rounded-lg text-xs hover:bg-muted">
<ImageIcon className="h-4 w-4" />
</AvatarFallback>
)}
</Avatar>
</label>
</button>
</div>
)}
</div>
@ -148,52 +118,46 @@ export function AgentHeader({
</div>
<div className="flex items-center gap-2">
{onExport && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="sm"
onClick={onExport}
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
disabled={isExporting}
>
<Download className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
Export agent
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{!isSunaAgent && (
<Tabs value={activeTab} onValueChange={onTabChange}>
<TabsList className="grid grid-cols-2 bg-muted/50 h-9">
<TabsTrigger
value="agent-builder"
disabled={isViewingOldVersion}
className={cn(
"flex items-center gap-2 text-sm data-[state=active]:bg-background data-[state=active]:shadow-sm",
isViewingOldVersion && "opacity-50 cursor-not-allowed"
)}
>
<Sparkles className="h-3 w-3" />
Prompt to Build
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="agent-builder" className="flex items-center gap-1.5">
<Sparkles className="h-3.5 w-3.5" />
<span className="hidden sm:inline">Prompt to Build</span>
</TabsTrigger>
<TabsTrigger
value="configuration"
className="flex items-center gap-2 text-sm data-[state=active]:bg-background data-[state=active]:shadow-sm"
>
<Settings className="h-3 w-3" />
Manual Config
<TabsTrigger value="configuration" className="flex items-center gap-1.5">
<Settings className="h-3.5 w-3.5" />
<span className="hidden sm:inline">Manual Config</span>
</TabsTrigger>
</TabsList>
</Tabs>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{onExport && (
<DropdownMenuItem
onClick={onExport}
disabled={isExporting}
className="flex items-center gap-2"
>
<Download className="h-4 w-4" />
{isExporting ? 'Exporting...' : 'Export Agent'}
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
<ProfilePictureDialog
isOpen={isProfileDialogOpen}
onClose={() => setIsProfileDialogOpen(false)}
currentImageUrl={displayData.profile_image_url}
agentName={displayData.name}
onImageUpdate={handleImageUpdate}
/>
</div>
);
}

View File

@ -2,3 +2,4 @@ export { AgentHeader } from './agent-header';
export { VersionAlert } from './version-alert';
export { AgentBuilderTab } from './agent-builder-tab';
export { ConfigurationTab } from './configuration-tab';
export { ProfilePictureDialog } from './profile-picture-dialog';

View File

@ -0,0 +1,297 @@
'use client';
import React, { useState } from 'react';
import { Upload, Link2, X, Image as ImageIcon } from 'lucide-react';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { toast } from 'sonner';
import { createClient } from '@/lib/supabase/client';
interface ProfilePictureDialogProps {
isOpen: boolean;
onClose: () => void;
currentImageUrl?: string;
agentName: string;
onImageUpdate: (url: string | null) => void;
}
export function ProfilePictureDialog({
isOpen,
onClose,
currentImageUrl,
agentName,
onImageUpdate,
}: ProfilePictureDialogProps) {
const [isUploading, setIsUploading] = useState(false);
const [isUrlSubmitting, setIsUrlSubmitting] = useState(false);
const [customUrl, setCustomUrl] = useState('');
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [dragActive, setDragActive] = useState(false);
const handleFileUpload = async (file: File) => {
if (!file.type.startsWith('image/')) {
toast.error('Please select an image file');
return;
}
if (file.size > 5 * 1024 * 1024) { // 5MB limit
toast.error('File size must be less than 5MB');
return;
}
setIsUploading(true);
try {
const supabase = createClient();
const { data: { session } } = await supabase.auth.getSession();
if (!session) {
toast.error('You must be logged in to upload images');
return;
}
const form = new FormData();
form.append('file', file);
const res = await fetch(`${process.env.NEXT_PUBLIC_BACKEND_URL}/agents/profile-image/upload`, {
method: 'POST',
body: form,
headers: {
'Authorization': `Bearer ${session.access_token}`,
}
});
if (!res.ok) {
const error = await res.json().catch(() => ({ message: 'Upload failed' }));
throw new Error(error.message || 'Upload failed');
}
const data = await res.json();
if (data?.url) {
onImageUpdate(data.url);
toast.success('Profile image uploaded successfully!');
onClose();
}
} catch (err) {
console.error('Upload error:', err);
toast.error(err instanceof Error ? err.message : 'Failed to upload image');
} finally {
setIsUploading(false);
}
};
const handleFileInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
handleFileUpload(file);
}
// Reset input
e.target.value = '';
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
setDragActive(false);
const file = e.dataTransfer.files?.[0];
if (file) {
handleFileUpload(file);
}
};
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
setDragActive(true);
};
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault();
setDragActive(false);
};
const handleUrlSubmit = async () => {
if (!customUrl.trim()) {
toast.error('Please enter a valid URL');
return;
}
// Basic URL validation
try {
new URL(customUrl);
} catch {
toast.error('Please enter a valid URL');
return;
}
setIsUrlSubmitting(true);
try {
onImageUpdate(customUrl);
toast.success('Profile image URL updated successfully!');
onClose();
} catch (err) {
toast.error('Failed to update profile image URL');
} finally {
setIsUrlSubmitting(false);
}
};
const handleUrlPreview = () => {
if (customUrl) {
try {
new URL(customUrl);
setPreviewUrl(customUrl);
} catch {
toast.error('Please enter a valid URL');
}
}
};
const handleRemoveImage = () => {
onImageUpdate(null);
toast.success('Profile image removed');
onClose();
};
const handleClose = () => {
setCustomUrl('');
setPreviewUrl(null);
setDragActive(false);
onClose();
};
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Update Profile Picture</DialogTitle>
</DialogHeader>
<div className="space-y-4 mt-4">
<div className="flex flex-col items-center space-y-3">
<Avatar className="h-20 w-20 rounded-xl ring-1 ring-border">
{currentImageUrl ? (
<AvatarImage src={currentImageUrl} alt={agentName} />
) : (
<AvatarFallback className="rounded-xl">
<ImageIcon className="h-8 w-8 text-muted-foreground" />
</AvatarFallback>
)}
</Avatar>
<p className="text-sm text-muted-foreground text-center">
Current profile picture for <span className="font-medium">{agentName}</span>
</p>
</div>
<Tabs defaultValue="upload" className="w-full">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="upload" className="flex items-center gap-2">
<Upload className="h-4 w-4" />
Upload File
</TabsTrigger>
<TabsTrigger value="url" className="flex items-center gap-2">
<Link2 className="h-4 w-4" />
Custom URL
</TabsTrigger>
</TabsList>
<TabsContent value="upload" className="space-y-4 mt-4">
<div
className={`border-2 border-dashed rounded-lg p-8 text-center transition-colors ${
dragActive
? 'border-primary bg-primary/5'
: 'border-muted-foreground/25 hover:border-muted-foreground/50'
}`}
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
>
<Upload className="mx-auto h-12 w-12 text-muted-foreground mb-4" />
<p className="text-sm font-medium mb-2">
Drop an image here, or click to select
</p>
<p className="text-xs text-muted-foreground mb-4">
PNG, JPG, GIF up to 5MB
</p>
<input
type="file"
accept="image/*"
onChange={handleFileInputChange}
className="hidden"
id="file-upload"
disabled={isUploading}
/>
<Button
asChild
variant="outline"
disabled={isUploading}
className="cursor-pointer"
>
<label htmlFor="file-upload">
{isUploading ? 'Uploading...' : 'Select File'}
</label>
</Button>
</div>
</TabsContent>
<TabsContent value="url" className="space-y-4 mt-4">
<div className="space-y-3">
<Label htmlFor="image-url">Image URL</Label>
<Input
id="image-url"
type="url"
placeholder="https://example.com/image.png"
value={customUrl}
onChange={(e) => setCustomUrl(e.target.value)}
onBlur={handleUrlPreview}
/>
{previewUrl && (
<div className="flex items-center justify-center p-4 border rounded-lg">
<Avatar className="h-16 w-16 rounded-xl">
<AvatarImage
src={previewUrl}
alt="Preview"
onError={() => {
setPreviewUrl(null);
toast.error('Unable to load image from URL');
}}
/>
<AvatarFallback className="rounded-xl">
<ImageIcon className="h-6 w-6 text-muted-foreground" />
</AvatarFallback>
</Avatar>
</div>
)}
<Button
onClick={handleUrlSubmit}
disabled={!customUrl.trim() || isUrlSubmitting}
className="w-full"
>
{isUrlSubmitting ? 'Updating...' : 'Update Image URL'}
</Button>
</div>
</TabsContent>
</Tabs>
<div className="flex justify-end pt-4 gap-2">
<Button
variant="outline"
onClick={handleRemoveImage}
className="flex items-center gap-2"
disabled={!currentImageUrl}
>
<X className="h-4 w-4" />
Remove Image
</Button>
<Button variant="outline" onClick={handleClose}>
Cancel
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@ -316,6 +316,13 @@ export const StreamlinedInstallDialog: React.FC<StreamlinedInstallDialogProps> =
<DialogContent className="sm:max-w-lg max-h-[90vh] overflow-y-auto">
<DialogHeader className="space-y-4">
<div className="flex items-center gap-3">
{item.profile_image_url ? (
<img
src={item.profile_image_url}
alt={item.name}
className="h-12 w-12 flex-shrink-0 rounded-lg object-cover shadow-lg"
/>
) : (
<div
className="h-12 w-12 flex-shrink-0 rounded-lg flex items-center justify-center"
style={{
@ -325,6 +332,7 @@ export const StreamlinedInstallDialog: React.FC<StreamlinedInstallDialogProps> =
>
<span className="text-lg">{avatar}</span>
</div>
)}
<div>
<DialogTitle className="text-left flex items-center gap-2">
Install {item.name}

View File

@ -10,6 +10,7 @@ export interface MarketplaceTemplate {
marketplace_published_at?: string;
avatar?: string;
avatar_color?: string;
profile_image_url?: string;
template_id: string;
is_kortix_team?: boolean;
model?: string;

View File

@ -128,6 +128,13 @@ export const MarketplaceAgentPreviewDialog: React.FC<MarketplaceAgentPreviewDial
<DialogContent className="max-w-2xl max-h-[90vh] p-0 overflow-hidden">
<DialogHeader className='p-6'>
<DialogTitle className='sr-only'>Agent Preview</DialogTitle>
{agent.profile_image_url ? (
<img
src={agent.profile_image_url}
alt={agent.name}
className='h-20 w-20 aspect-square rounded-2xl object-cover shadow-lg'
/>
) : (
<div className='relative h-20 w-20 aspect-square bg-muted rounded-2xl flex items-center justify-center' style={{ backgroundColor: avatar_color }}>
<div className="text-4xl drop-shadow-lg">
{avatar || '🤖'}
@ -139,6 +146,7 @@ export const MarketplaceAgentPreviewDialog: React.FC<MarketplaceAgentPreviewDial
}}
/>
</div>
)}
</DialogHeader>
<div className="-mt-4 flex flex-col max-h-[calc(90vh-8rem)] overflow-hidden">
<div className="p-6 py-0 pb-4">

View File

@ -16,7 +16,6 @@ import {
import { toast } from 'sonner';
import { isLocalMode, isYearlyCommitmentDowngrade, isPlanChangeAllowed, getPlanInfo } from '@/lib/config';
import { useSubscription, useSubscriptionCommitment } from '@/hooks/react-query';
import { useAuth } from '@/components/AuthProvider';
import posthog from 'posthog-js';
// Constants
@ -547,19 +546,10 @@ export function PricingSection({
noPadding = false,
}: PricingSectionProps) {
const { user, isLoading: authLoading } = useAuth();
const {
data: subscriptionData,
isLoading: isFetchingPlan,
error: subscriptionQueryError,
refetch: refetchSubscription
} = useSubscription({
enabled: !!user && !authLoading,
});
const { data: subscriptionData, isLoading: isFetchingPlan, error: subscriptionQueryError, refetch: refetchSubscription } = useSubscription();
const subCommitmentQuery = useSubscriptionCommitment(subscriptionData?.subscription_id);
const isAuthenticated = !!user && !!subscriptionData && subscriptionQueryError === null;
const isAuthenticated = !!subscriptionData && subscriptionQueryError === null;
const currentSubscription = subscriptionData || null;
const getDefaultBillingPeriod = useCallback((): 'monthly' | 'yearly' | 'yearly_commitment' => {

View File

@ -29,6 +29,7 @@ import { Skeleton } from '@/components/ui/skeleton';
import { NewAgentDialog } from '@/components/agents/new-agent-dialog';
import { useAgentWorkflows } from '@/hooks/react-query/agents/use-agent-workflows';
import { PlaybookExecuteDialog } from '@/components/playbooks/playbook-execute-dialog';
import { AgentAvatar } from '@/components/thread/content/agent-avatar';
type UnifiedConfigMenuProps = {
isLoggedIn?: boolean;
@ -209,18 +210,7 @@ const LoggedInMenu: React.FC<UnifiedConfigMenuProps> = ({
// combinedModels defined earlier
const renderAgentIcon = (agent: any) => {
const isSuna = agent?.metadata?.is_suna_default;
if (isSuna) return <KortixLogo size={16} />;
if (agent?.avatar) return (
// avatar can be URL or emoji string handle both without extra chrome
typeof agent.avatar === 'string' && agent.avatar.startsWith('http') ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={agent.avatar} alt={agent.name} className="h-4 w-4 rounded-sm object-cover" />
) : (
<span className="text-base leading-none">{agent.avatar}</span>
)
);
return <KortixLogo size={16} />;
return <AgentAvatar agentId={agent?.agent_id} size={16} className="h-4 w-4" fallbackName={agent?.name} />;
};
const displayAgent = useMemo(() => {

View File

@ -13,6 +13,7 @@ import {
} from '@/components/thread/utils';
import { KortixLogo } from '@/components/sidebar/kortix-logo';
import { AgentLoader } from './loader';
import { AgentAvatar, AgentName } from './agent-avatar';
import { parseXmlToolCalls, isNewXmlFormat } from '@/components/thread/tool-views/xml-parser';
import { ShowToolStream } from './ShowToolStream';
import { ComposioUrlDetector } from './composio-url-detector';
@ -282,6 +283,7 @@ export interface ThreadContentProps {
threadMetadata?: any; // Add thread metadata prop
scrollContainerRef?: React.RefObject<HTMLDivElement>; // Add scroll container ref prop
agentMetadata?: any; // Add agent metadata prop
agentData?: any; // Add full agent data prop
}
export const ThreadContent: React.FC<ThreadContentProps> = ({
@ -307,6 +309,7 @@ export const ThreadContent: React.FC<ThreadContentProps> = ({
threadMetadata,
scrollContainerRef,
agentMetadata,
agentData,
}) => {
const messagesContainerRef = useRef<HTMLDivElement>(null);
const latestMessageRef = useRef<HTMLDivElement>(null);
@ -357,6 +360,25 @@ export const ThreadContent: React.FC<ThreadContentProps> = ({
};
}
if (agentData && !isSunaDefaultAgent) {
const profileUrl = agentData.profile_image_url;
const avatar = profileUrl ? (
<img src={profileUrl} alt={agentData.name || agentName} className="h-5 w-5 rounded object-cover" />
) : agentData.avatar ? (
<div className="h-5 w-5 flex items-center justify-center rounded text-xs">
<span className="text-lg">{agentData.avatar}</span>
</div>
) : (
<div className="h-5 w-5 flex items-center justify-center rounded text-xs">
<KortixLogo size={16} />
</div>
);
return {
name: agentData.name || agentName,
avatar
};
}
if (recentAssistantWithAgent?.agents?.name) {
const isSunaAgent = recentAssistantWithAgent.agents.name === 'Suna' || isSunaDefaultAgent;
// Prefer profile image if available on the agent payload
@ -402,7 +424,7 @@ export const ThreadContent: React.FC<ThreadContentProps> = ({
name: agentName || 'Suna',
avatar: agentAvatar
};
}, [threadMetadata, displayMessages, agentName, agentAvatar, agentMetadata]);
}, [threadMetadata, displayMessages, agentName, agentAvatar, agentMetadata, agentData]);
// Simplified scroll handler - flex-column-reverse handles positioning
const handleScroll = useCallback(() => {
@ -705,15 +727,27 @@ export const ThreadContent: React.FC<ThreadContentProps> = ({
</div>
);
} else if (group.type === 'assistant_group') {
// Get agent_id from the first assistant message in this group
const firstAssistantMsg = group.messages.find(m => m.type === 'assistant');
const groupAgentId = firstAssistantMsg?.agent_id;
return (
<div key={group.key} ref={groupIndex === groupedMessages.length - 1 ? latestMessageRef : null}>
<div className="flex flex-col gap-2">
<div className="flex items-center">
<div className="rounded-md flex items-center justify-center relative">
{getAgentInfo().avatar}
{groupAgentId ? (
<AgentAvatar agentId={groupAgentId} size={20} className="h-5 w-5" />
) : (
getAgentInfo().avatar
)}
</div>
<p className='ml-2 text-sm text-muted-foreground'>
{getAgentInfo().name}
{groupAgentId ? (
<AgentName agentId={groupAgentId} fallback={getAgentInfo().name} />
) : (
getAgentInfo().name
)}
</p>
</div>

View File

@ -0,0 +1,82 @@
'use client';
import React from 'react';
import { useAgent } from '@/hooks/react-query/agents/use-agents';
import { KortixLogo } from '@/components/sidebar/kortix-logo';
import { Skeleton } from '@/components/ui/skeleton';
interface AgentAvatarProps {
agentId?: string;
size?: number;
className?: string;
fallbackName?: string;
}
export const AgentAvatar: React.FC<AgentAvatarProps> = ({
agentId,
size = 16,
className = "",
fallbackName = "Suna"
}) => {
const { data: agent, isLoading } = useAgent(agentId || '');
if (isLoading && agentId) {
return (
<div
className={`bg-muted animate-pulse rounded ${className}`}
style={{ width: size, height: size }}
/>
);
}
if (!agent && !agentId) {
return <KortixLogo size={size} />;
}
const isSuna = agent?.metadata?.is_suna_default;
if (isSuna) {
return <KortixLogo size={size} />;
}
if (agent?.profile_image_url) {
return (
<img
src={agent.profile_image_url}
alt={agent.name || fallbackName}
className={`rounded object-cover ${className}`}
style={{ width: size, height: size }}
/>
);
}
if (agent?.avatar) {
return (
<div
className={`flex items-center justify-center text-xs ${className}`}
style={{ width: size, height: size, fontSize: size * 0.75 }}
>
{agent.avatar}
</div>
);
}
return <KortixLogo size={size} />;
};
interface AgentNameProps {
agentId?: string;
fallback?: string;
}
export const AgentName: React.FC<AgentNameProps> = ({
agentId,
fallback = "Suna"
}) => {
const { data: agent, isLoading } = useAgent(agentId || '');
if (isLoading && agentId) {
return <span className="text-muted-foreground">Loading...</span>;
}
return <span>{agent?.name || fallback}</span>;
};

View File

@ -24,6 +24,7 @@ export interface UnifiedMessage {
name: string;
avatar?: string;
avatar_color?: string;
profile_image_url?: string;
}; // Agent information from join
}

View File

@ -45,6 +45,7 @@ export interface AgentTemplate {
creator_name?: string;
avatar?: string;
avatar_color?: string;
profile_image_url?: string;
is_kortix_team?: boolean;
metadata?: {
source_agent_id?: string;

View File

@ -6,10 +6,7 @@ export const useAccounts = (options?: SWRConfiguration) => {
const supabaseClient = createClient();
return useSWR<GetAccountsResponse>(
async () => {
const { data: { user } } = await supabaseClient.auth.getUser();
return user ? ['accounts', user.id] : null;
},
!!supabaseClient && ['accounts'],
async () => {
const { data, error } = await supabaseClient.rpc('get_accounts');