feat: implement Agent Chat Widget for entity pages

TASK_PHASE_AGENT_CHAT_WIDGET_MVP.md completed:

Backend:
- Add /api/v1/agents/{agent_id}/chat-room endpoint
- Add /api/v1/nodes/{node_id}/chat-room endpoint
- Add /api/v1/microdaos/{slug}/chat-room endpoint

Frontend:
- Create AgentChatWidget.tsx floating chat component
- Integrate into /agents/:agentId page
- Integrate into /nodes/:nodeId page
- Integrate into /microdao/:slug page

Ontology rule implemented:
'No page without agents' = ability to directly talk to agents on that page
This commit is contained in:
Apple
2025-11-30 09:10:45 -08:00
parent fb4a33f016
commit 6297adc0dc
6 changed files with 692 additions and 0 deletions

View File

@@ -21,6 +21,7 @@ import { updateAgentVisibility, AgentVisibilityUpdate } from '@/lib/api/agents';
import { ensureOrchestratorRoom } from '@/lib/api/microdao';
import { Bot, Settings, FileText, Building2, Cpu, MessageSquare, BarChart3, Users, Globe, Lock, Eye, EyeOff, ChevronLeft, Loader2, MessageCircle, PlusCircle } from 'lucide-react';
import { CityChatWidget } from '@/components/city/CityChatWidget';
import { AgentChatWidget } from '@/components/chat/AgentChatWidget';
import { Button } from '@/components/ui/button';
// Tab types
@@ -664,6 +665,9 @@ export default function AgentConsolePage() {
</div>
)}
</div>
{/* Floating Chat Widget */}
<AgentChatWidget contextType="agent" contextId={agentId} />
</div>
);
}

View File

@@ -9,6 +9,7 @@ import { MicrodaoRoomsSection } from "@/components/microdao/MicrodaoRoomsSection
import { MicrodaoRoomsAdminPanel } from "@/components/microdao/MicrodaoRoomsAdminPanel";
import { ChevronLeft, Users, MessageSquare, Crown, Building2, Globe, Lock, Layers, BarChart3, Bot, MessageCircle } from "lucide-react";
import { CityChatWidget } from "@/components/city/CityChatWidget";
import { AgentChatWidget } from "@/components/chat/AgentChatWidget";
import { ensureOrchestratorRoom } from "@/lib/api/microdao";
export default function MicrodaoDetailPage() {
@@ -398,6 +399,9 @@ export default function MicrodaoDetailPage() {
</div>
)}
</div>
{/* Floating Chat Widget */}
<AgentChatWidget contextType="microdao" contextId={slug} />
</div>
);
}

View File

@@ -15,6 +15,7 @@ import {
NodeStandardComplianceCard
} from '@/components/node-dashboard';
import { NodeGuardianCard } from '@/components/nodes/NodeGuardianCard';
import { AgentChatWidget } from '@/components/chat/AgentChatWidget';
function getNodeLabel(nodeId: string): string {
if (nodeId.includes('node-1')) return 'НОДА1';
@@ -182,6 +183,9 @@ export default function NodeCabinetPage() {
</div>
</div>
</div>
{/* Floating Chat Widget */}
<AgentChatWidget contextType="node" contextId={nodeId} />
);
}
@@ -337,6 +341,9 @@ export default function NodeCabinetPage() {
<ExternalLink className="w-3 h-3" />
</Link>
</div>
{/* Floating Chat Widget */}
<AgentChatWidget contextType="node" contextId={nodeId} />
</div>
);
}

View File

@@ -0,0 +1,296 @@
'use client';
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';
export type ChatContextType = 'agent' | 'node' | 'microdao';
interface AgentInfo {
id: string;
display_name: string;
avatar_url?: string | null;
kind?: string;
role?: string;
status?: string;
}
interface ChatRoomInfo {
room_slug: string;
room_id?: string | null;
matrix_room_id?: string | null;
chat_available: boolean;
// Agent context
agent_id?: string;
agent_display_name?: string;
agent_avatar_url?: string | null;
agent_status?: string;
agent_kind?: string;
// Node context
node_id?: string;
node_name?: string;
node_status?: string;
agents?: AgentInfo[];
// MicroDAO context
microdao_id?: string;
microdao_slug?: string;
microdao_name?: string;
orchestrator?: AgentInfo | null;
}
interface AgentChatWidgetProps {
contextType: ChatContextType;
contextId: string;
className?: string;
}
/**
* Floating Agent Chat Widget
*
* Displays a floating chat button in the bottom-right corner.
* When expanded, shows a chat panel connected to the appropriate Matrix room.
*
* Usage:
* - <AgentChatWidget contextType="agent" contextId="daarwizz" />
* - <AgentChatWidget contextType="node" contextId="node-1-hetzner-gex44" />
* - <AgentChatWidget contextType="microdao" contextId="daarion" />
*/
export function AgentChatWidget({ contextType, contextId, className }: AgentChatWidgetProps) {
const [isExpanded, setIsExpanded] = useState(false);
const [chatInfo, setChatInfo] = useState<ChatRoomInfo | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Fetch chat room info based on context
const fetchChatInfo = useCallback(async () => {
setLoading(true);
setError(null);
try {
let endpoint = '';
switch (contextType) {
case 'agent':
endpoint = `/api/v1/agents/${encodeURIComponent(contextId)}/chat-room`;
break;
case 'node':
endpoint = `/api/v1/nodes/${encodeURIComponent(contextId)}/chat-room`;
break;
case 'microdao':
endpoint = `/api/v1/microdaos/${encodeURIComponent(contextId)}/chat-room`;
break;
}
const res = await fetch(endpoint);
if (!res.ok) {
const err = await res.json();
throw new Error(err.detail || 'Failed to load chat info');
}
const data = await res.json();
setChatInfo(data);
} catch (err) {
console.error('Failed to fetch chat info:', err);
setError(err instanceof Error ? err.message : 'Failed to load chat');
} finally {
setLoading(false);
}
}, [contextType, contextId]);
useEffect(() => {
fetchChatInfo();
}, [fetchChatInfo]);
// Get display info based on context
const getDisplayInfo = () => {
if (!chatInfo) return { name: 'Chat', icon: MessageCircle, status: 'offline' };
switch (contextType) {
case 'agent':
return {
name: chatInfo.agent_display_name || 'Agent Chat',
icon: Bot,
status: chatInfo.agent_status || 'offline',
avatarUrl: chatInfo.agent_avatar_url
};
case 'node':
return {
name: chatInfo.node_name || 'Node Support',
icon: Server,
status: chatInfo.node_status || 'offline',
agents: chatInfo.agents
};
case 'microdao':
return {
name: chatInfo.microdao_name || 'MicroDAO Chat',
icon: Building2,
status: chatInfo.orchestrator?.status || 'offline',
avatarUrl: chatInfo.orchestrator?.avatar_url
};
default:
return { name: 'Chat', icon: MessageCircle, status: 'offline' };
}
};
const displayInfo = getDisplayInfo();
const IconComponent = displayInfo.icon;
const isOnline = displayInfo.status === 'online';
// Collapsed button
if (!isExpanded) {
return (
<div className={cn("fixed bottom-6 right-6 z-50", className)}>
<button
onClick={() => setIsExpanded(true)}
className={cn(
"group relative w-14 h-14 rounded-full shadow-lg transition-all duration-300",
"bg-gradient-to-br from-violet-600 to-purple-700 hover:from-violet-500 hover:to-purple-600",
"flex items-center justify-center",
"hover:scale-110 active:scale-95"
)}
>
{/* Avatar or Icon */}
{displayInfo.avatarUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={displayInfo.avatarUrl}
alt={displayInfo.name}
className="w-10 h-10 rounded-full object-cover"
/>
) : (
<IconComponent className="w-6 h-6 text-white" />
)}
{/* Status indicator */}
<span className={cn(
"absolute bottom-0 right-0 w-4 h-4 rounded-full border-2 border-slate-900",
isOnline ? "bg-emerald-500" : "bg-slate-500"
)} />
{/* Tooltip */}
<span className="absolute right-full mr-3 px-3 py-1.5 bg-slate-800 text-white text-sm rounded-lg whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none">
Чат з {displayInfo.name}
</span>
</button>
{/* Pulse animation for online status */}
{isOnline && (
<span className="absolute bottom-0 right-0 w-4 h-4 rounded-full bg-emerald-500 animate-ping opacity-75" />
)}
</div>
);
}
// Expanded chat panel
return (
<div className={cn(
"fixed bottom-6 right-6 z-50",
"w-[380px] max-w-[calc(100vw-48px)]",
"h-[500px] max-h-[calc(100vh-100px)]",
"bg-slate-900 rounded-2xl shadow-2xl border border-white/10",
"flex flex-col overflow-hidden",
"animate-in slide-in-from-bottom-4 duration-300",
className
)}>
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-white/10 bg-white/5">
<div className="flex items-center gap-3">
{/* Avatar/Icon */}
<div className={cn(
"w-10 h-10 rounded-full flex items-center justify-center overflow-hidden",
"bg-gradient-to-br from-violet-500/30 to-purple-600/30"
)}>
{displayInfo.avatarUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={displayInfo.avatarUrl}
alt={displayInfo.name}
className="w-full h-full object-cover"
/>
) : (
<IconComponent className="w-5 h-5 text-violet-400" />
)}
</div>
<div>
<h3 className="text-sm font-semibold text-white">{displayInfo.name}</h3>
<div className="flex items-center gap-1.5">
<span className={cn(
"w-2 h-2 rounded-full",
isOnline ? "bg-emerald-500" : "bg-slate-500"
)} />
<span className={cn(
"text-xs",
isOnline ? "text-emerald-400" : "text-slate-500"
)}>
{isOnline ? 'Online' : 'Offline'}
</span>
</div>
</div>
</div>
<button
onClick={() => setIsExpanded(false)}
className="p-2 hover:bg-white/10 rounded-lg transition-colors"
>
<X className="w-5 h-5 text-white/60" />
</button>
</div>
{/* Chat content */}
<div className="flex-1 overflow-hidden">
{loading ? (
<div className="flex items-center justify-center h-full">
<Loader2 className="w-8 h-8 text-violet-400 animate-spin" />
</div>
) : error ? (
<div className="flex flex-col items-center justify-center h-full p-6 text-center">
<AlertCircle className="w-12 h-12 text-red-400 mb-4" />
<p className="text-white/60 text-sm mb-4">{error}</p>
<button
onClick={fetchChatInfo}
className="px-4 py-2 bg-violet-500/20 hover:bg-violet-500/30 text-violet-400 rounded-lg text-sm transition-colors"
>
Спробувати знову
</button>
</div>
) : !chatInfo?.chat_available ? (
<div className="flex flex-col items-center justify-center h-full p-6 text-center">
<MessageCircle className="w-12 h-12 text-white/20 mb-4" />
<p className="text-white/60 text-sm mb-2">Чат тимчасово недоступний</p>
<p className="text-white/40 text-xs">
Кімната чату ще не налаштована для цієї сутності.
</p>
</div>
) : (
<CityChatWidget
roomSlug={chatInfo.room_slug}
mode="embedded"
showHeader={false}
className="h-full border-0 rounded-none"
/>
)}
</div>
{/* Node agents list (for node context) */}
{contextType === 'node' && chatInfo?.agents && chatInfo.agents.length > 0 && (
<div className="px-4 py-2 border-t border-white/10 bg-white/5">
<p className="text-xs text-white/40 mb-2">Агенти підтримки:</p>
<div className="flex gap-2">
{chatInfo.agents.map((agent) => (
<div
key={agent.id}
className="flex items-center gap-1.5 px-2 py-1 bg-white/5 rounded-lg"
>
<Bot className="w-3 h-3 text-cyan-400" />
<span className="text-xs text-white/70">{agent.display_name}</span>
<span className="text-[10px] text-white/40">({agent.role})</span>
</div>
))}
</div>
</div>
)}
</div>
);
}