mirror of https://github.com/kortix-ai/suna.git
profile pic for agents
This commit is contained in:
parent
123117e61d
commit
b8c42f98e3
|
@ -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'),
|
||||
|
|
|
@ -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
|
||||
</>
|
||||
)}
|
||||
|
|
|
@ -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) ||
|
||||
|
|
|
@ -689,6 +689,7 @@ export default function ThreadPage({
|
|||
agentName={agent && agent.name}
|
||||
agentAvatar={agent && agent.avatar}
|
||||
agentMetadata={agent?.metadata}
|
||||
agentData={agent}
|
||||
scrollContainerRef={scrollContainerRef}
|
||||
/>
|
||||
|
||||
|
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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';
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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' => {
|
||||
|
|
|
@ -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(() => {
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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>;
|
||||
};
|
|
@ -24,6 +24,7 @@ export interface UnifiedMessage {
|
|||
name: string;
|
||||
avatar?: string;
|
||||
avatar_color?: string;
|
||||
profile_image_url?: string;
|
||||
}; // Agent information from join
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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');
|
||||
|
||||
|
|
Loading…
Reference in New Issue