diff --git a/apps/web/src/app/agents/[agentId]/page.tsx b/apps/web/src/app/agents/[agentId]/page.tsx index 400dd9a4..bd2fd6b3 100644 --- a/apps/web/src/app/agents/[agentId]/page.tsx +++ b/apps/web/src/app/agents/[agentId]/page.tsx @@ -25,6 +25,7 @@ import { CityChatWidget } from '@/components/city/CityChatWidget'; import { AgentChatWidget } from '@/components/chat/AgentChatWidget'; import { AgentPresenceBadge } from '@/components/ui/AgentPresenceBadge'; import { Button } from '@/components/ui/button'; +import { normalizeAssetUrl } from '@/lib/utils/assetUrl'; // Tab types type TabId = 'dashboard' | 'prompts' | 'microdao' | 'identity' | 'models' | 'chat'; @@ -232,8 +233,8 @@ export default function AgentConsolePage() { {/* Agent Avatar & Name */}
- {profile?.avatar_url ? ( - + {normalizeAssetUrl(profile?.avatar_url) ? ( + ) : ( )} @@ -466,7 +467,7 @@ export default function AgentConsolePage() { {/* Avatar Upload */} = { @@ -45,10 +46,10 @@ function AgentCard({ agent }: { agent: AgentSummary }) {
- {agent.avatar_url ? ( + {normalizeAssetUrl(agent.avatar_url) ? ( // eslint-disable-next-line @next/next/no-img-element {agent.display_name} diff --git a/apps/web/src/app/citizens/[slug]/page.tsx b/apps/web/src/app/citizens/[slug]/page.tsx index 6f67352f..2f823316 100644 --- a/apps/web/src/app/citizens/[slug]/page.tsx +++ b/apps/web/src/app/citizens/[slug]/page.tsx @@ -8,6 +8,7 @@ import { useCitizenProfile, useCitizenInteraction } from '@/hooks/useCitizens'; import { askCitizen } from '@/lib/api/citizens'; import { CityChatWidget } from '@/components/city/CityChatWidget'; import { ChevronLeft, Building2, MapPin, MessageSquare, HelpCircle, Loader2, Users } from 'lucide-react'; +import { normalizeAssetUrl } from '@/lib/utils/assetUrl'; type LooseRecord = Record; @@ -77,9 +78,9 @@ export default function CitizenProfilePage() {
{/* Avatar */}
- {citizen.avatar_url ? ( + {normalizeAssetUrl(citizen.avatar_url) ? ( // eslint-disable-next-line @next/next/no-img-element - + ) : ( getAgentKindIcon(citizen.kind || '') )} diff --git a/apps/web/src/app/citizens/page.tsx b/apps/web/src/app/citizens/page.tsx index ef863534..f013ef04 100644 --- a/apps/web/src/app/citizens/page.tsx +++ b/apps/web/src/app/citizens/page.tsx @@ -7,6 +7,7 @@ import { DISTRICTS } from '@/lib/microdao'; import { useCitizensList } from '@/hooks/useCitizens'; import type { PublicCitizenSummary } from '@/lib/types/citizens'; import { Users, Search, MapPin, Building2 } from 'lucide-react'; +import { normalizeAssetUrl } from '@/lib/utils/assetUrl'; const CITIZEN_KINDS = [ 'orchestrator', @@ -157,10 +158,10 @@ function CitizenCard({ citizen }: { citizen: PublicCitizenSummary }) { {/* Header */}
- {citizen.avatar_url ? ( + {normalizeAssetUrl(citizen.avatar_url) ? ( // eslint-disable-next-line @next/next/no-img-element diff --git a/apps/web/src/components/agent-dashboard/AgentSummaryCard.tsx b/apps/web/src/components/agent-dashboard/AgentSummaryCard.tsx index b80557d3..f1d1956d 100644 --- a/apps/web/src/components/agent-dashboard/AgentSummaryCard.tsx +++ b/apps/web/src/components/agent-dashboard/AgentSummaryCard.tsx @@ -5,6 +5,7 @@ import { AgentProfile, AgentRuntime, getAgentKindIcon, getAgentStatusColor } fro import { StatusBadge } from '@/components/node-dashboard'; import { getGovLevelBadge } from '@/lib/types/agents'; import { Shield, Fingerprint, Building2 } from 'lucide-react'; +import { normalizeAssetUrl } from '@/lib/utils/assetUrl'; interface AgentSummaryCardProps { profile: AgentProfile; @@ -20,9 +21,9 @@ export function AgentSummaryCard({ profile, runtime }: AgentSummaryCardProps) {
{/* Avatar */}
- {profile.dais.vis?.avatar_url ? ( + {normalizeAssetUrl(profile.dais.vis?.avatar_url) ? ( {profile.display_name} diff --git a/apps/web/src/components/chat/AgentChatWidget.tsx b/apps/web/src/components/chat/AgentChatWidget.tsx index c1e75c41..1e25dd10 100644 --- a/apps/web/src/components/chat/AgentChatWidget.tsx +++ b/apps/web/src/components/chat/AgentChatWidget.tsx @@ -4,6 +4,7 @@ import { useState, useEffect, useCallback } from 'react'; import { MessageCircle, X, Bot, Server, Building2, Loader2, AlertCircle } from 'lucide-react'; import { CityChatWidget } from '@/components/city/CityChatWidget'; import { cn } from '@/lib/utils'; +import { normalizeAssetUrl } from '@/lib/utils/assetUrl'; export type ChatContextType = 'agent' | 'node' | 'microdao'; @@ -111,7 +112,7 @@ export function AgentChatWidget({ contextType, contextId, className }: AgentChat name: chatInfo.agent_display_name || 'Agent Chat', icon: Bot, status: chatInfo.agent_status || 'offline', - avatarUrl: chatInfo.agent_avatar_url + avatarUrl: normalizeAssetUrl(chatInfo.agent_avatar_url) }; case 'node': return { @@ -125,7 +126,7 @@ export function AgentChatWidget({ contextType, contextId, className }: AgentChat name: chatInfo.microdao_name || 'MicroDAO Chat', icon: Building2, status: chatInfo.orchestrator?.status || 'offline', - avatarUrl: chatInfo.orchestrator?.avatar_url + avatarUrl: normalizeAssetUrl(chatInfo.orchestrator?.avatar_url) }; default: return { name: 'Chat', icon: MessageCircle, status: 'offline' }; diff --git a/apps/web/src/components/microdao/MicrodaoAgentsSection.tsx b/apps/web/src/components/microdao/MicrodaoAgentsSection.tsx index bf7456b7..9f285c94 100644 --- a/apps/web/src/components/microdao/MicrodaoAgentsSection.tsx +++ b/apps/web/src/components/microdao/MicrodaoAgentsSection.tsx @@ -3,6 +3,7 @@ import Link from "next/link"; import { Bot, Crown, Users, Shield, Sparkles } from "lucide-react"; import { MicrodaoAgent } from "@/hooks/useMicrodao"; +import { normalizeAssetUrl } from "@/lib/utils/assetUrl"; interface MicrodaoAgentsSectionProps { agents: MicrodaoAgent[]; @@ -140,8 +141,8 @@ function AgentCard({ agent, featured = false, compact = false }: AgentCardProps)
- {agent.avatar_url ? ( - {agent.name} + {normalizeAssetUrl(agent.avatar_url) ? ( + {agent.name} ) : ( )} @@ -169,8 +170,8 @@ function AgentCard({ agent, featured = false, compact = false }: AgentCardProps)
- {agent.avatar_url ? ( - {agent.name} + {normalizeAssetUrl(agent.avatar_url) ? ( + {agent.name} ) : ( )} diff --git a/apps/web/src/components/microdao/MicrodaoBrandingCard.tsx b/apps/web/src/components/microdao/MicrodaoBrandingCard.tsx index bb3ed6c8..0590bbb1 100644 --- a/apps/web/src/components/microdao/MicrodaoBrandingCard.tsx +++ b/apps/web/src/components/microdao/MicrodaoBrandingCard.tsx @@ -2,6 +2,7 @@ import { useState, useRef } from 'react'; import { Upload, Image, X, Building2, Loader2 } from 'lucide-react'; +import { normalizeAssetUrl } from '@/lib/utils/assetUrl'; interface MicrodaoBrandingCardProps { slug: string; @@ -20,8 +21,8 @@ export function MicrodaoBrandingCard({ }: MicrodaoBrandingCardProps) { const [uploading, setUploading] = useState<'logo' | 'banner' | null>(null); const [error, setError] = useState(null); - const [previewLogo, setPreviewLogo] = useState(logoUrl || null); - const [previewBanner, setPreviewBanner] = useState(bannerUrl || null); + const [previewLogo, setPreviewLogo] = useState(normalizeAssetUrl(logoUrl)); + const [previewBanner, setPreviewBanner] = useState(normalizeAssetUrl(bannerUrl)); const logoInputRef = useRef(null); const bannerInputRef = useRef(null); diff --git a/apps/web/src/lib/utils/assetUrl.ts b/apps/web/src/lib/utils/assetUrl.ts index 3245647f..6a4e9501 100644 --- a/apps/web/src/lib/utils/assetUrl.ts +++ b/apps/web/src/lib/utils/assetUrl.ts @@ -1,35 +1,59 @@ /** - * Normalize asset URL for display + * Normalize asset URL for display. + * This is the SINGLE source of truth for building static asset URLs. + * * Handles various URL formats from the backend: - * - /api/static/uploads/... - already correct - * - /assets/... - static assets, use as-is + * - /api/static/uploads/... - already correct, return as-is + * - /assets/... - static assets in public folder, return as-is * - /static/uploads/... - needs /api prefix - * - https://... - external URL, use as-is + * - https://... or http://... - external URL, return as-is + * - uploads/... or static/uploads/... - relative path, needs /api/static prefix + * + * IMPORTANT: Do NOT manually concatenate /api/static anywhere in the codebase. + * Always use this function instead. */ export function normalizeAssetUrl(url: string | null | undefined): string | null { if (!url) return null; - // Already correct format - if (url.startsWith('/api/static')) { - return url; - } - - // Static assets in public folder - if (url.startsWith('/assets/')) { - return url; - } - - // External URLs + // External URLs - return as-is if (url.startsWith('http://') || url.startsWith('https://')) { return url; } - // Old format - needs /api prefix + // Already correct format with /api/static + if (url.startsWith('/api/static')) { + return url; + } + + // Static assets in public folder (/assets/...) + if (url.startsWith('/assets/')) { + return url; + } + + // Old format with /static/ prefix - add /api if (url.startsWith('/static/')) { return `/api${url}`; } - // Unknown format - return as-is + // Relative path without leading slash (uploads/..., static/...) + // Remove any duplicate prefixes and normalize + let cleaned = url; + + // Remove leading /api/static if somehow duplicated + cleaned = cleaned.replace(/^\/api\/static\//, ''); + // Remove leading /static/ + cleaned = cleaned.replace(/^\/static\//, ''); + // Remove leading static/ (no slash) + cleaned = cleaned.replace(/^static\//, ''); + // Remove leading slash + cleaned = cleaned.replace(/^\/+/, ''); + + // If it looks like a relative upload path, prefix with /api/static/ + if (cleaned.startsWith('uploads/') || cleaned.includes('/')) { + return `/api/static/${cleaned}`; + } + + // Unknown format - return as-is (might be a simple filename) return url; }