fix: NODE1_REPAIR - healthchecks, dependencies, SSR env, telegram gateway

TASK_PHASE_NODE1_REPAIR:
- Fix daarion-web SSR: use CITY_API_BASE_URL instead of 127.0.0.1
- Fix auth API routes: use AUTH_API_URL env var
- Add wget to Dockerfiles for healthchecks (stt, ocr, web-search, swapper, vector-db, rag)
- Update healthchecks to use wget instead of curl
- Fix vector-db-service: update torch==2.4.0, sentence-transformers==2.6.1
- Fix rag-service: correct haystack imports for v2.x
- Fix telegram-gateway: remove msg.ack() for non-JetStream NATS
- Add /health endpoint to nginx mvp-routes.conf
- Add room_role, is_public, sort_order columns to city_rooms migration
- Add TASK_PHASE_NODE1_REPAIR.md and DEPLOY_NODE1_REPAIR.md docs

Previous tasks included:
- TASK 039-044: Orchestrator rooms, Matrix chat cleanup, CrewAI integration
This commit is contained in:
Apple
2025-11-29 05:17:08 -08:00
parent 0bab4bba08
commit a6e531a098
69 changed files with 4693 additions and 1310 deletions

View File

@@ -18,8 +18,10 @@ import {
import { api, Agent, AgentInvokeResponse } from '@/lib/api';
import { VisibilityScope, getNodeBadgeLabel } from '@/lib/types/agents';
import { updateAgentVisibility, AgentVisibilityUpdate } from '@/lib/api/agents';
import { Bot, Settings, FileText, Building2, Cpu, MessageSquare, BarChart3, Users, Globe, Lock, Eye, EyeOff, ChevronLeft, Loader2, MessageCircle } from 'lucide-react';
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 { Button } from '@/components/ui/button';
// Tab types
type TabId = 'dashboard' | 'prompts' | 'microdao' | 'identity' | 'models' | 'chat';
@@ -68,6 +70,7 @@ export default function AgentConsolePage() {
const [input, setInput] = useState('');
const [invoking, setInvoking] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
const [isCreatingTeam, setIsCreatingTeam] = useState(false);
// Load agent for chat
useEffect(() => {
@@ -130,6 +133,23 @@ export default function AgentConsolePage() {
setInvoking(false);
}
};
const handleCreateTeamChat = async () => {
if (!dashboard?.profile.primary_microdao_slug) return;
setIsCreatingTeam(true);
try {
await ensureOrchestratorRoom(dashboard.profile.primary_microdao_slug);
refresh(); // Reload to get new room info if possible (though dashboard might not include it immediately unless updated)
// Ideally we should fetch the room specifically or wait for refresh
alert("Командний чат створено! Перезавантажте сторінку, якщо він не з'явився.");
} catch (e) {
console.error("Failed to create team chat", e);
alert("Failed to create team chat");
} finally {
setIsCreatingTeam(false);
}
};
// Loading state
if (dashboardLoading && !dashboard) {
@@ -178,6 +198,20 @@ export default function AgentConsolePage() {
const profile = dashboard?.profile;
const nodeLabel = profile?.node_id ? getNodeBadgeLabel(profile.node_id) : 'Unknown';
// Check for Orchestrator Team Chat capability
const showOrchestratorChat = profile?.is_orchestrator && profile?.crew_info?.has_crew_team;
// We need to know if the room actually exists.
// Currently dashboard doesn't return specific team room in profile,
// but we can infer it or fetch it.
// For MVP, let's assume we show "Create" button if not found in a separate check,
// or just show the widget and let it handle "not found"? No, widget needs roomSlug.
// Since we don't have the room slug in profile.crew_info (it might be null),
// we rely on the user clicking "Create" if it's not there, or we try to construct the slug?
// Backend: `get_or_create` logic creates slug like `{microdao_slug}-team`.
// We can try to use that slug if `crew_team_key` is present.
const teamRoomSlug = profile?.primary_microdao_slug ? `${profile.primary_microdao_slug}-team` : null;
return (
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-purple-900/20 to-slate-900">
{/* Header */}
@@ -463,6 +497,70 @@ export default function AgentConsolePage() {
{/* Chat Tab */}
{activeTab === 'chat' && (
<div className="space-y-6">
{/* Orchestrator Team Chat */}
{showOrchestratorChat && (
<div className="bg-fuchsia-900/10 backdrop-blur-md rounded-2xl border border-fuchsia-500/20 p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-fuchsia-200 flex items-center gap-2">
<Bot className="w-5 h-5 text-fuchsia-400" />
Командний чат оркестратора
</h3>
<Link
href={`/microdao/${profile.primary_microdao_slug}`}
className="text-xs text-fuchsia-400 hover:text-fuchsia-300 underline"
>
{profile.primary_microdao_name}
</Link>
</div>
{/*
Here we assume that if has_crew_team is true, the room might exist or we can create it.
For simplicity in MVP, we use the known slug format or show button.
Actually, if the room is not created yet, CityChatWidget will error or show loading forever?
CityChatWidget handles 404 gracefully?
Better approach: Try to load it. If 404, show Create button.
But CityChatWidget doesn't expose "notFound" state easily upwards.
Alternative: Just show Create button if not sure, or try to auto-create.
Let's try to show the widget with the expected slug.
*/}
{teamRoomSlug ? (
<div className="h-[400px]">
<CityChatWidget
roomSlug={teamRoomSlug}
hideTitle
className="border-fuchsia-500/20 h-full"
/>
{/* Fallback for creation if widget fails/empty?
Ideally we should check if room exists via API first.
For now, let's add a manual "Ensure Room" button below just in case.
*/}
<div className="mt-2 flex justify-end">
<button
onClick={handleCreateTeamChat}
className="text-[10px] text-fuchsia-500/50 hover:text-fuchsia-400"
>
(Re)Initialize Team Room
</button>
</div>
</div>
) : (
<div className="text-center py-8 space-y-4">
<p className="text-fuchsia-200/70">Команда CrewAI активна, але чат ще не створено.</p>
<Button
onClick={handleCreateTeamChat}
disabled={isCreatingTeam}
className="bg-fuchsia-600 hover:bg-fuchsia-500 text-white border border-fuchsia-400/50"
>
{isCreatingTeam ? "Створення..." : "Створити командний чат"}
</Button>
</div>
)}
</div>
)}
{/* Direct Chat with Agent via DAGI Router */}
<div className="bg-white/5 backdrop-blur-md rounded-2xl border border-white/10 overflow-hidden">
<div className="p-4 border-b border-white/10">

View File

@@ -1,6 +1,6 @@
import { NextRequest } from 'next/server'
const INTERNAL_API_URL = process.env.INTERNAL_API_URL || 'http://127.0.0.1:8080'
const INTERNAL_API_URL = process.env.INTERNAL_API_URL || process.env.AUTH_API_URL || 'http://daarion-auth:8080'
export async function POST(req: NextRequest) {
const body = await req.text()

View File

@@ -1,6 +1,6 @@
import { NextRequest } from 'next/server'
const INTERNAL_API_URL = process.env.INTERNAL_API_URL || 'http://127.0.0.1:8080'
const INTERNAL_API_URL = process.env.INTERNAL_API_URL || process.env.AUTH_API_URL || 'http://daarion-auth:8080'
export async function POST(req: NextRequest) {
const body = await req.text()

View File

@@ -1,6 +1,6 @@
import { NextRequest } from 'next/server'
const INTERNAL_API_URL = process.env.INTERNAL_API_URL || 'http://127.0.0.1:8080'
const INTERNAL_API_URL = process.env.INTERNAL_API_URL || process.env.AUTH_API_URL || 'http://daarion-auth:8080'
export async function GET(req: NextRequest) {
const response = await fetch(`${INTERNAL_API_URL}/api/auth/me`, {

View File

@@ -1,6 +1,6 @@
import { NextRequest } from 'next/server'
const INTERNAL_API_URL = process.env.INTERNAL_API_URL || 'http://127.0.0.1:8080'
const INTERNAL_API_URL = process.env.INTERNAL_API_URL || process.env.AUTH_API_URL || 'http://daarion-auth:8080'
export async function POST(req: NextRequest) {
const body = await req.text()

View File

@@ -1,6 +1,6 @@
import { NextRequest } from 'next/server'
const INTERNAL_API_URL = process.env.INTERNAL_API_URL || 'http://127.0.0.1:8080'
const INTERNAL_API_URL = process.env.INTERNAL_API_URL || process.env.AUTH_API_URL || 'http://daarion-auth:8080'
export async function POST(req: NextRequest) {
const body = await req.text()

View File

@@ -0,0 +1,45 @@
import { NextRequest, NextResponse } from "next/server";
import { getAccessToken } from "@auth0/nextjs-auth0"; // Or your auth mechanism
const CITY_API_URL = process.env.INTERNAL_API_URL || process.env.CITY_API_BASE_URL || "http://daarion-city-service:7001";
/**
* POST /api/microdao/[slug]/rooms/orchestrator-team
* Ensure Orchestrator Team Room exists
*/
export async function POST(
request: NextRequest,
context: { params: Promise<{ slug: string }> }
) {
try {
const { slug } = await context.params;
const authHeader = request.headers.get("Authorization");
const response = await fetch(`${CITY_API_URL}/city/microdao/${slug}/ensure-orchestrator-room`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": authHeader || "",
},
});
if (!response.ok) {
const text = await response.text();
console.error("Failed to ensure orchestrator room:", response.status, text);
return NextResponse.json(
{ error: "Failed to ensure room", detail: text },
{ status: response.status }
);
}
const data = await response.json();
return NextResponse.json(data);
} catch (error) {
console.error("Error ensuring orchestrator room:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}

View File

@@ -256,15 +256,19 @@ export default function CitizenProfilePage() {
</section>
{/* Live Chat Widget */}
{interaction?.primary_room_slug && (
<section className="bg-white/5 border border-white/10 rounded-2xl p-6 space-y-4">
<h2 className="text-white font-semibold flex items-center gap-2">
<Users className="w-5 h-5 text-cyan-400" />
Live-чат
</h2>
<CityChatWidget roomSlug={interaction.primary_room_slug} />
</section>
)}
<section className="bg-white/5 border border-white/10 rounded-2xl p-6 space-y-4">
<h2 className="text-white font-semibold flex items-center gap-2">
<Users className="w-5 h-5 text-cyan-400" />
Live-чат
</h2>
{interaction?.primary_room_slug ? (
<CityChatWidget roomSlug={interaction.primary_room_slug} mode="embedded" />
) : (
<div className="text-sm text-slate-400 p-4 border border-white/10 rounded-xl bg-slate-900/50">
Цей громадянин ще не має публічного чату.
</div>
)}
</section>
{/* DAIS Public Passport */}
<section className="bg-white/5 border border-white/10 rounded-2xl p-6 space-y-4">

View File

@@ -3,7 +3,7 @@ import { ArrowLeft, Users, FileText, Clock, MessageCircle } from 'lucide-react'
import { api, CityRoom } from '@/lib/api'
import { formatDate } from '@/lib/utils'
import { notFound } from 'next/navigation'
import { MatrixChatRoom } from '@/components/chat/MatrixChatRoom'
import { CityChatWidget } from '@/components/city/CityChatWidget'
// Force dynamic rendering - don't prerender at build time
export const dynamic = 'force-dynamic'
@@ -72,9 +72,11 @@ export default async function RoomPage({ params }: PageProps) {
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Chat Area */}
<div className="lg:col-span-2">
<div className="glass-panel h-[500px] sm:h-[600px] flex flex-col overflow-hidden">
<MatrixChatRoom roomSlug={room.slug} />
</div>
<CityChatWidget
roomSlug={room.slug}
mode="embedded"
className="h-[600px] min-h-[500px]"
/>
{/* Matrix Room Info */}
{room.matrix_room_id && (

View File

@@ -1,6 +1,6 @@
"use client";
import { useParams } from "next/navigation";
import { useParams, useRouter } from "next/navigation";
import Link from "next/link";
import { useMicrodaoDetail, useMicrodaoRooms } from "@/hooks/useMicrodao";
import { DISTRICT_COLORS } from "@/lib/microdao";
@@ -9,9 +9,11 @@ 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 { ensureOrchestratorRoom } from "@/lib/api/microdao";
export default function MicrodaoDetailPage() {
const params = useParams();
const router = useRouter();
const slug = params?.slug as string;
const { microdao, isLoading, error, mutate: refreshMicrodao } = useMicrodaoDetail(slug);
const { rooms, mutate: refreshRooms } = useMicrodaoRooms(slug);
@@ -21,6 +23,16 @@ export default function MicrodaoDetailPage() {
refreshMicrodao();
};
const handleEnsureOrchestratorRoom = async () => {
try {
await ensureOrchestratorRoom(slug);
handleRoomUpdated();
} catch (e) {
console.error("Failed to ensure orchestrator room", e);
alert("Failed to create team room. Check console for details.");
}
};
if (isLoading) {
return (
<div className="min-h-screen bg-gradient-to-br from-slate-950 via-slate-900 to-slate-950 flex items-center justify-center">
@@ -57,6 +69,10 @@ export default function MicrodaoDetailPage() {
// Use fetched rooms if available, otherwise fallback to microdao.rooms
const displayRooms = rooms.length > 0 ? rooms : (microdao.rooms || []);
// Check management rights (Mock for MVP: assuming true if orchestrator exists)
// TODO: Use actual user auth state
const canManage = !!orchestrator;
return (
<div className="min-h-screen bg-gradient-to-br from-slate-950 via-slate-900 to-slate-950">
<div className="max-w-5xl mx-auto px-4 py-8 space-y-8">
@@ -190,19 +206,21 @@ export default function MicrodaoDetailPage() {
)}
{/* Orchestrator Room Management Panel */}
{orchestrator && (
{orchestrator && canManage && (
<MicrodaoRoomsAdminPanel
microdaoSlug={slug}
rooms={displayRooms}
canManage={true} // TODO: check if current user is orchestrator
canManage={true}
onRoomUpdated={handleRoomUpdated}
/>
)}
{/* Multi-Room Section with Chats */}
{/* Multi-Room Section with Chats (includes Team Chat) */}
<MicrodaoRoomsSection
rooms={displayRooms}
primaryRoomSlug={microdao.primary_city_room?.slug}
canManage={canManage}
onEnsureOrchestratorRoom={handleEnsureOrchestratorRoom}
/>
<div className="grid md:grid-cols-2 gap-8">
@@ -366,13 +384,13 @@ export default function MicrodaoDetailPage() {
</div>
{/* Visibility Settings (only for orchestrator) */}
{orchestrator && (
{orchestrator && canManage && (
<div className="pt-8 border-t border-white/5">
<MicrodaoVisibilityCard
microdaoId={microdao.id}
isPublic={microdao.is_public}
isPlatform={microdao.is_platform}
isOrchestrator={true} // TODO: check if current user is orchestrator
isOrchestrator={true}
onUpdated={() => {
window.location.reload();
}}

View File

@@ -9,20 +9,40 @@ interface ChatMessageProps {
isOwn?: boolean
}
function formatName(id: string | null | undefined, isAgent: boolean, isOwn: boolean): string {
if (isOwn) return 'You';
if (!id) return 'Anonymous';
if (isAgent) {
return id.replace('ag_', '');
}
// Handle Matrix ID: @username:server -> username
if (id.startsWith('@')) {
return id.split(':')[0].substring(1);
}
// Handle legacy user ID: u_username -> username
return id.replace('u_', '');
}
export function ChatMessage({ message, isOwn = false }: ChatMessageProps) {
const isAgent = !!message.author_agent_id
const authorName = isAgent
? message.author_agent_id?.replace('ag_', '') || 'Agent'
: message.author_user_id?.replace('u_', '') || 'User'
const isAgent = !!message.author_agent_id;
// Determine raw ID
const rawId = isAgent ? message.author_agent_id : message.author_user_id;
// Format display name
const authorName = formatName(rawId, isAgent, isOwn);
const time = new Date(message.created_at).toLocaleTimeString('uk-UA', {
hour: '2-digit',
minute: '2-digit'
})
});
return (
<div className={cn(
'flex gap-3 px-4 py-2 hover:bg-white/5 transition-colors',
'flex gap-3 px-4 py-2 hover:bg-white/5 transition-colors group',
isOwn && 'flex-row-reverse'
)}>
{/* Avatar */}
@@ -51,11 +71,11 @@ export function ChatMessage({ message, isOwn = false }: ChatMessageProps) {
)}>
{authorName}
</span>
<span className="text-xs text-slate-500">{time}</span>
<span className="text-xs text-slate-500 opacity-0 group-hover:opacity-100 transition-opacity">{time}</span>
</div>
<div className={cn(
'inline-block max-w-[80%] px-3 py-2 rounded-2xl text-sm',
'inline-block max-w-[80%] px-3 py-2 rounded-2xl text-sm break-words',
isOwn
? 'bg-cyan-500/20 text-white rounded-tr-sm'
: 'bg-slate-800/50 text-slate-200 rounded-tl-sm'
@@ -66,4 +86,3 @@ export function ChatMessage({ message, isOwn = false }: ChatMessageProps) {
</div>
)
}

View File

@@ -1,419 +0,0 @@
'use client'
import { useState, useEffect, useRef, useCallback, useMemo } from 'react'
import { MessageSquare, Wifi, WifiOff, Loader2, RefreshCw, AlertCircle, Users } from 'lucide-react'
import { ChatMessage } from './ChatMessage'
import { ChatInput } from './ChatInput'
import { MatrixRestClient, createMatrixClient, ChatMessage as MatrixChatMessage, PresenceEvent } from '@/lib/matrix-client'
import { cn } from '@/lib/utils'
import { useAuth } from '@/context/AuthContext'
import { getAccessToken } from '@/lib/auth'
import { usePresenceHeartbeat } from '@/hooks/usePresenceHeartbeat'
interface MatrixChatRoomProps {
roomSlug: string
}
type ConnectionStatus = 'loading' | 'connecting' | 'online' | 'error' | 'unauthenticated'
interface BootstrapData {
matrix_hs_url: string
matrix_user_id: string
matrix_access_token: string
matrix_device_id: string
matrix_room_id: string
matrix_room_alias: string
room: {
id: string
slug: string
name: string
description?: string
}
}
// Helper to format user name from Matrix ID
function formatUserName(userId: string): string {
return userId
.split(':')[0]
.replace('@daarion_', 'User ')
.replace('@', '');
}
export function MatrixChatRoom({ roomSlug }: MatrixChatRoomProps) {
const { user } = useAuth()
const token = getAccessToken()
const [messages, setMessages] = useState<MatrixChatMessage[]>([])
const [status, setStatus] = useState<ConnectionStatus>('loading')
const [error, setError] = useState<string | null>(null)
const [bootstrap, setBootstrap] = useState<BootstrapData | null>(null)
const messagesEndRef = useRef<HTMLDivElement>(null)
const matrixClient = useRef<MatrixRestClient | null>(null)
// Presence & Typing state
const [onlineUsers, setOnlineUsers] = useState<Map<string, 'online' | 'offline' | 'unavailable'>>(new Map())
const [typingUsers, setTypingUsers] = useState<Set<string>>(new Set())
const typingTimeoutRef = useRef<NodeJS.Timeout | null>(null)
// Presence heartbeat - keeps user "online" in Matrix
usePresenceHeartbeat({
matrixUserId: bootstrap?.matrix_user_id ?? null,
accessToken: bootstrap?.matrix_access_token ?? null,
intervalMs: 30000, // Every 30 seconds
awayAfterMs: 5 * 60 * 1000, // 5 minutes of inactivity
enabled: status === 'online',
})
// Scroll to bottom when new messages arrive
const scrollToBottom = useCallback(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [])
useEffect(() => {
scrollToBottom()
}, [messages, scrollToBottom])
// Initialize Matrix connection
const initializeMatrix = useCallback(async () => {
if (!token) {
setStatus('unauthenticated')
return
}
setStatus('loading')
setError(null)
try {
// 1. Get bootstrap data
const res = await fetch(`/api/city/chat/bootstrap?room_slug=${roomSlug}`, {
headers: {
'Authorization': `Bearer ${token}`
}
})
if (!res.ok) {
const err = await res.json()
throw new Error(err.detail || 'Failed to get chat bootstrap')
}
const data: BootstrapData = await res.json()
setBootstrap(data)
// 2. Create Matrix client
setStatus('connecting')
const client = createMatrixClient(data)
matrixClient.current = client
// 3. Join room
try {
await client.joinRoom(data.matrix_room_id)
} catch (e) {
// Ignore join errors (might already be in room)
console.log('Join room result:', e)
}
// 4. Get initial messages
const messagesRes = await client.getMessages(data.matrix_room_id, { limit: 50 })
const initialMessages = messagesRes.chunk
.filter(e => e.type === 'm.room.message' && e.content?.body)
.map(e => client.mapToChatMessage(e))
.reverse() // Oldest first
setMessages(initialMessages)
// 5. Set up presence and typing handlers
client.onPresence = (event: PresenceEvent) => {
if (!event.sender || !event.content?.presence) return;
setOnlineUsers(prev => {
const next = new Map(prev);
next.set(event.sender, event.content.presence);
return next;
});
};
client.onTyping = (roomId: string, userIds: string[]) => {
if (roomId !== data.matrix_room_id) return;
// Filter out current user
const others = userIds.filter(id => id !== data.matrix_user_id);
setTypingUsers(new Set(others));
};
// 6. Start sync for real-time updates
await client.initialSync()
client.startSync((newMessage) => {
setMessages(prev => {
// Avoid duplicates
if (prev.some(m => m.id === newMessage.id)) {
return prev
}
return [...prev, newMessage]
})
})
setStatus('online')
} catch (err) {
console.error('Matrix initialization error:', err)
setError(err instanceof Error ? err.message : 'Unknown error')
setStatus('error')
}
}, [token, roomSlug])
useEffect(() => {
initializeMatrix()
return () => {
if (matrixClient.current) {
matrixClient.current.onPresence = undefined;
matrixClient.current.onTyping = undefined;
matrixClient.current.stopSync();
}
if (typingTimeoutRef.current) {
clearTimeout(typingTimeoutRef.current);
}
}
}, [initializeMatrix])
// Calculate online count
const onlineCount = useMemo(() => {
let count = 0;
onlineUsers.forEach((status, userId) => {
if (status === 'online' || status === 'unavailable') {
// Don't count current user
if (userId !== bootstrap?.matrix_user_id) {
count++;
}
}
});
return count;
}, [onlineUsers, bootstrap]);
// Handle typing notification
const handleTyping = useCallback(() => {
if (!matrixClient.current || !bootstrap) return;
// Send typing notification
matrixClient.current.sendTyping(bootstrap.matrix_room_id, true);
// Clear previous timeout
if (typingTimeoutRef.current) {
clearTimeout(typingTimeoutRef.current);
}
// Stop typing after 3 seconds of inactivity
typingTimeoutRef.current = setTimeout(() => {
if (matrixClient.current && bootstrap) {
matrixClient.current.sendTyping(bootstrap.matrix_room_id, false);
}
}, 3000);
}, [bootstrap]);
const handleSendMessage = async (body: string) => {
if (!matrixClient.current || !bootstrap) return
try {
// Stop typing indicator
matrixClient.current.sendTyping(bootstrap.matrix_room_id, false);
if (typingTimeoutRef.current) {
clearTimeout(typingTimeoutRef.current);
}
// Optimistically add message
const tempId = `temp_${Date.now()}`
const tempMessage: MatrixChatMessage = {
id: tempId,
senderId: bootstrap.matrix_user_id,
senderName: 'You',
text: body,
timestamp: new Date(),
isUser: true
}
setMessages(prev => [...prev, tempMessage])
// Send to Matrix
const result = await matrixClient.current.sendMessage(bootstrap.matrix_room_id, body)
// Update temp message with real ID
setMessages(prev => prev.map(m =>
m.id === tempId ? { ...m, id: result.event_id } : m
))
} catch (err) {
console.error('Failed to send message:', err)
// Remove failed message
setMessages(prev => prev.filter(m => !m.id.startsWith('temp_')))
setError('Не вдалося надіслати повідомлення')
}
}
const handleRetry = () => {
initializeMatrix()
}
// Map MatrixChatMessage to legacy format for ChatMessage component
const mapToLegacyFormat = (msg: MatrixChatMessage) => ({
id: msg.id,
room_id: bootstrap?.room.id || '',
author_user_id: msg.isUser ? 'current_user' : msg.senderId,
author_agent_id: null,
body: msg.text,
created_at: msg.timestamp.toISOString()
})
return (
<div className="flex flex-col h-full">
{/* Connection status */}
<div className="px-4 py-2 border-b border-white/10 flex items-center justify-between">
<div className="flex items-center gap-2">
<MessageSquare className="w-4 h-4 text-cyan-400" />
<span className="text-sm font-medium text-white">
{bootstrap?.room.name || 'Matrix Chat'}
</span>
{status === 'online' && (
<>
<span className="text-slate-500">·</span>
<div className="flex items-center gap-1 text-emerald-400 text-xs">
<Users className="w-3 h-3" />
<span>{onlineCount} online</span>
</div>
</>
)}
</div>
<div className={cn(
'flex items-center gap-1.5 px-2 py-1 rounded-full text-xs',
status === 'loading' && 'bg-slate-500/20 text-slate-400',
status === 'connecting' && 'bg-amber-500/20 text-amber-400',
status === 'online' && 'bg-emerald-500/20 text-emerald-400',
status === 'error' && 'bg-red-500/20 text-red-400',
status === 'unauthenticated' && 'bg-amber-500/20 text-amber-400'
)}>
{status === 'loading' && (
<>
<Loader2 className="w-3 h-3 animate-spin" />
<span>Завантаження...</span>
</>
)}
{status === 'connecting' && (
<>
<Loader2 className="w-3 h-3 animate-spin" />
<span>Підключення до Matrix...</span>
</>
)}
{status === 'online' && (
<>
<Wifi className="w-3 h-3" />
<span>Онлайн</span>
</>
)}
{status === 'error' && (
<>
<WifiOff className="w-3 h-3" />
<span>Помилка</span>
</>
)}
{status === 'unauthenticated' && (
<>
<AlertCircle className="w-3 h-3" />
<span>Потрібен вхід</span>
</>
)}
</div>
</div>
{/* Error / Auth required message */}
{(status === 'error' || status === 'unauthenticated') && (
<div className="px-4 py-3 bg-red-500/10 border-b border-red-500/20">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-sm text-red-400">
<AlertCircle className="w-4 h-4" />
<span>
{status === 'unauthenticated'
? 'Увійдіть, щоб приєднатися до чату'
: error || 'Помилка підключення'
}
</span>
</div>
{status === 'error' && (
<button
onClick={handleRetry}
className="flex items-center gap-1 px-3 py-1 text-xs bg-red-500/20 hover:bg-red-500/30 text-red-400 rounded-full transition-colors"
>
<RefreshCw className="w-3 h-3" />
Повторити
</button>
)}
</div>
</div>
)}
{/* Messages area */}
<div className="flex-1 overflow-y-auto py-4 space-y-1">
{messages.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-center px-4">
<div className="w-16 h-16 mb-4 rounded-full bg-slate-800/50 flex items-center justify-center">
<MessageSquare className="w-8 h-8 text-slate-600" />
</div>
<h3 className="text-lg font-medium text-white mb-2">
{status === 'online'
? 'Поки що немає повідомлень'
: status === 'unauthenticated'
? 'Увійдіть для доступу до чату'
: 'Підключення до Matrix...'
}
</h3>
<p className="text-sm text-slate-400 max-w-sm">
{status === 'online'
? 'Будьте першим, хто напише в цій кімнаті! Ваше повідомлення синхронізується з Matrix.'
: status === 'unauthenticated'
? 'Для участі в чаті потрібна авторизація'
: 'Встановлюємо зʼєднання з Matrix сервером...'
}
</p>
</div>
) : (
<>
{messages.map((message) => (
<ChatMessage
key={message.id}
message={mapToLegacyFormat(message)}
isOwn={message.isUser}
/>
))}
<div ref={messagesEndRef} />
</>
)}
</div>
{/* Typing indicator */}
{typingUsers.size > 0 && (
<div className="px-4 py-1.5 text-sm text-cyan-400/80 animate-pulse flex items-center gap-2">
<div className="flex gap-1">
<span className="w-1.5 h-1.5 bg-cyan-400 rounded-full animate-bounce" style={{ animationDelay: '0ms' }} />
<span className="w-1.5 h-1.5 bg-cyan-400 rounded-full animate-bounce" style={{ animationDelay: '150ms' }} />
<span className="w-1.5 h-1.5 bg-cyan-400 rounded-full animate-bounce" style={{ animationDelay: '300ms' }} />
</div>
<span>
{typingUsers.size === 1
? `${formatUserName(Array.from(typingUsers)[0])} друкує...`
: 'Декілька учасників друкують...'}
</span>
</div>
)}
{/* Input area */}
<ChatInput
onSend={handleSendMessage}
onTyping={handleTyping}
disabled={status !== 'online'}
placeholder={
status === 'online'
? 'Напишіть повідомлення...'
: status === 'unauthenticated'
? 'Увійдіть для надсилання повідомлень'
: 'Очікування підключення...'
}
/>
</div>
)
}

View File

@@ -1,36 +1,85 @@
'use client';
import { MatrixChatRoom } from '@/components/chat/MatrixChatRoom';
import { useRouter } from 'next/navigation';
import { CityChatPanel, CityChatMode } from './chat/CityChatPanel';
import { useMatrixChat } from '@/hooks/useMatrixChat';
import type { ChatMessage as MatrixChatMessage } from '@/lib/matrix-client';
type CityChatWidgetProps = {
interface CityChatWidgetProps {
roomSlug: string;
mode?: CityChatMode;
showHeader?: boolean;
hideTitle?: boolean;
className?: string;
// Legacy props support
title?: string;
compact?: boolean;
};
}
/**
* Обгортка для MatrixChatRoom, яка використовується на сторінці громадянина та MicroDAO.
* Показує inline Matrix-чат.
* Віджет чату для City/MicroDAO.
* Використовує useMatrixChat для логіки та CityChatPanel для відображення.
*/
export function CityChatWidget({ roomSlug, title, compact }: CityChatWidgetProps) {
export function CityChatWidget({
roomSlug,
mode = 'embedded',
showHeader = true,
hideTitle = false,
className,
title,
compact
}: CityChatWidgetProps) {
const router = useRouter();
const {
messages,
status,
error,
roomName,
onlineCount,
typingUsers,
sendMessage,
handleTyping,
retry
} = useMatrixChat(roomSlug);
// Handle legacy props
const effectiveMode = compact ? 'embedded' : mode;
// Map Matrix messages to ChatMessage format expected by UI
const mappedMessages = messages.map((msg: MatrixChatMessage) => ({
id: msg.id,
room_id: '',
author_user_id: msg.isUser ? 'current_user' : msg.senderId,
author_agent_id: null,
body: msg.text,
created_at: msg.timestamp.toISOString(),
isUser: msg.isUser
}));
if (!roomSlug) {
return (
<div className="text-sm text-white/60">
<div className="text-sm text-white/60 p-4 border border-white/10 rounded-xl bg-slate-900/50">
Кімната чату не налаштована.
</div>
);
}
return (
<div className={`border border-white/10 rounded-2xl overflow-hidden bg-slate-900/50 flex flex-col ${
compact ? 'min-h-[300px] max-h-[400px]' : 'min-h-[400px] max-h-[600px]'
}`}>
{title && (
<div className="bg-white/5 px-4 py-2 border-b border-white/10 text-sm font-medium text-white/80">
{title}
</div>
)}
<MatrixChatRoom roomSlug={roomSlug} />
</div>
<CityChatPanel
mode={effectiveMode}
roomName={title || roomName || 'Chat'}
status={status}
messages={mappedMessages}
onlineCount={onlineCount}
typingUsers={typingUsers}
error={error}
showHeader={showHeader}
hideTitle={hideTitle}
onSend={sendMessage}
onTyping={handleTyping}
onRetry={retry}
onLogin={() => router.push('/login')}
className={className}
/>
);
}

View File

@@ -0,0 +1,244 @@
'use client';
import { useRef, useEffect } from 'react';
import { MessageSquare, Wifi, WifiOff, Loader2, RefreshCw, AlertCircle, Users, LogIn } from 'lucide-react';
import { ChatMessage } from '@/components/chat/ChatMessage';
import { ChatInput } from '@/components/chat/ChatInput';
import { cn } from '@/lib/utils';
import type { ChatMessage as ChatMessageType } from '@/lib/websocket';
export type CityChatMode = 'embedded' | 'full';
export type ConnectionStatus = 'loading' | 'connecting' | 'online' | 'error' | 'unauthenticated';
export interface CityChatPanelProps {
mode?: CityChatMode;
roomName: string;
status: ConnectionStatus;
messages: ChatMessageType[];
onlineCount: number;
typingUsers: Set<string>;
error?: string | null;
showHeader?: boolean;
hideTitle?: boolean;
// Actions
onSend: (message: string) => void;
onTyping?: () => void;
onRetry?: () => void;
onLogin?: () => void;
// Custom classes
className?: string;
}
export function CityChatPanel({
mode = 'embedded',
roomName,
status,
messages,
onlineCount,
typingUsers,
error,
showHeader = true,
hideTitle = false,
onSend,
onTyping,
onRetry,
onLogin,
className
}: CityChatPanelProps) {
const messagesEndRef = useRef<HTMLDivElement>(null);
// Scroll to bottom on new messages
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
// Helper to format user name from Matrix ID (if needed for typing indicator)
function formatUserName(userId: string): string {
return userId
.split(':')[0]
.replace('@daarion_', 'User ')
.replace('@', '');
}
const isEmbedded = mode === 'embedded';
const isFull = mode === 'full';
return (
<div className={cn(
'flex flex-col bg-slate-900/50 overflow-hidden',
// Embedded: tall on mobile (60vh), constrained on desktop
isEmbedded && 'border border-white/10 rounded-2xl min-h-[60vh] md:min-h-[400px] md:max-h-[600px]',
// Full: always full height
isFull && 'h-full min-h-[60vh] w-full',
className
)}>
{/* Header */}
{showHeader && (
<div className="px-4 py-3 border-b border-white/10 flex items-center justify-between bg-white/5 backdrop-blur-sm h-[60px]">
<div className="flex items-center gap-3 overflow-hidden">
{!hideTitle && (
<div className="w-8 h-8 rounded-full bg-cyan-500/20 flex items-center justify-center flex-shrink-0">
<MessageSquare className="w-4 h-4 text-cyan-400" />
</div>
)}
<div className="flex flex-col overflow-hidden">
{!hideTitle && (
<span className="text-sm font-medium text-white leading-none truncate">
{roomName}
</span>
)}
<div className={cn("flex items-center gap-2", !hideTitle && "mt-1")}>
<span className={cn(
'text-xs flex items-center gap-1 flex-shrink-0',
status === 'online' ? 'text-emerald-400' : 'text-slate-500'
)}>
{status === 'online' ? (
<>
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500"></span>
Online
</>
) : (
<>
<span className="w-1.5 h-1.5 rounded-full bg-slate-500"></span>
Offline
</>
)}
</span>
{status === 'online' && (
<>
<span className="text-slate-600 text-[10px]"></span>
<span className="text-xs text-slate-400 flex items-center gap-1 flex-shrink-0">
<Users className="w-3 h-3" />
{onlineCount}
</span>
</>
)}
</div>
</div>
</div>
{/* Status Badge / Login CTA */}
<div className="flex items-center gap-2 flex-shrink-0 ml-2">
{status === 'unauthenticated' && (
<button
onClick={onLogin}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-blue-600/20 hover:bg-blue-600/30 text-blue-400 text-xs font-medium transition-colors"
>
<LogIn className="w-3 h-3" />
Увійти
</button>
)}
{status === 'loading' && (
<div className="flex items-center gap-1.5 px-2 py-1 text-xs text-slate-400">
<Loader2 className="w-3 h-3 animate-spin" />
</div>
)}
{status === 'connecting' && (
<div className="flex items-center gap-1.5 px-2 py-1 text-xs text-amber-400">
<Loader2 className="w-3 h-3 animate-spin" />
</div>
)}
{status === 'error' && (
<div className="flex items-center gap-1.5 px-2 py-1 text-xs text-red-400" title={error || 'Error'}>
<WifiOff className="w-3 h-3" />
</div>
)}
</div>
</div>
)}
{/* Error Message Bar */}
{(status === 'error') && (
<div className="px-4 py-2 bg-red-500/10 border-b border-red-500/20 flex items-center justify-between">
<span className="text-xs text-red-400 truncate mr-2">{error || 'Помилка зʼєднання'}</span>
{onRetry && (
<button onClick={onRetry} className="text-xs text-red-400 underline hover:text-red-300 flex-shrink-0">
Retry
</button>
)}
</div>
)}
{/* Messages Area */}
<div className="flex-1 overflow-y-auto py-4 space-y-1 custom-scrollbar">
{messages.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-center px-6 py-8">
<div className="w-12 h-12 mb-3 rounded-full bg-slate-800/50 flex items-center justify-center">
<MessageSquare className="w-6 h-6 text-slate-600" />
</div>
<p className="text-sm text-slate-400">
{status === 'online'
? 'Тут поки що тихо...'
: status === 'unauthenticated'
? 'Потрібна авторизація'
: 'Завантаження чату...'
}
</p>
{status === 'unauthenticated' && (
<p className="text-xs text-slate-500 mt-1">Увійдіть, щоб бачити історію повідомлень</p>
)}
</div>
) : (
<>
{messages.map((message) => (
<ChatMessage
key={message.id}
message={message}
isOwn={message.author_user_id === 'current_user' || (message as any).isUser} // Fallback check
/>
))}
<div ref={messagesEndRef} />
</>
)}
</div>
{/* Typing Indicator */}
{typingUsers.size > 0 && (
<div className="px-4 py-1.5 text-xs text-cyan-400/80 animate-pulse flex items-center gap-2 bg-slate-900/30">
<div className="flex gap-1">
<span className="w-1 h-1 bg-cyan-400 rounded-full animate-bounce" style={{ animationDelay: '0ms' }} />
<span className="w-1 h-1 bg-cyan-400 rounded-full animate-bounce" style={{ animationDelay: '150ms' }} />
<span className="w-1 h-1 bg-cyan-400 rounded-full animate-bounce" style={{ animationDelay: '300ms' }} />
</div>
<span>
{typingUsers.size === 1
? `${formatUserName(Array.from(typingUsers)[0])} друкує...`
: 'Хтось друкує...'}
</span>
</div>
)}
{/* Input Area */}
{status === 'online' ? (
<ChatInput
onSend={onSend}
onTyping={onTyping}
disabled={false}
placeholder="Напишіть повідомлення..."
/>
) : (
<div className="p-4 border-t border-white/10 bg-slate-900/30">
{status === 'unauthenticated' ? (
<button
onClick={onLogin}
className="w-full py-3 px-4 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-xl transition-colors flex items-center justify-center gap-2"
>
<LogIn className="w-4 h-4" />
Увійти щоб почати спілкування
</button>
) : (
<div className="text-center text-xs text-slate-500 py-2">
{status === 'loading' || status === 'connecting' ? 'Підключення...' : 'Чат недоступний'}
</div>
)}
</div>
)}
</div>
);
}

View File

@@ -1,14 +1,18 @@
"use client";
import Link from "next/link";
import { MessageCircle, Home, Users, FlaskConical, Shield, Gavel, Hash, Users2 } from "lucide-react";
import { MessageCircle, Home, Users, FlaskConical, Shield, Gavel, Hash, Users2, Bot, PlusCircle } from "lucide-react";
import { CityRoomSummary } from "@/lib/types/microdao";
import { CityChatWidget } from "@/components/city/CityChatWidget";
import { Button } from "@/components/ui/button";
import { useState } from "react";
interface MicrodaoRoomsSectionProps {
rooms: CityRoomSummary[];
primaryRoomSlug?: string | null;
showAllChats?: boolean;
canManage?: boolean;
onEnsureOrchestratorRoom?: () => Promise<void>;
}
const ROLE_META: Record<string, { label: string; chipClass: string; icon: React.ReactNode }> = {
@@ -42,13 +46,34 @@ const ROLE_META: Record<string, { label: string; chipClass: string; icon: React.
chipClass: "bg-amber-500/10 text-amber-300 border-amber-500/30",
icon: <Gavel className="w-3.5 h-3.5" />,
},
orchestrator_team: {
label: "Orchestrator Team",
chipClass: "bg-fuchsia-500/10 text-fuchsia-300 border-fuchsia-500/30",
icon: <Bot className="w-3.5 h-3.5" />,
},
};
export function MicrodaoRoomsSection({
rooms,
primaryRoomSlug,
showAllChats = false
showAllChats = false,
canManage = false,
onEnsureOrchestratorRoom
}: MicrodaoRoomsSectionProps) {
const [isCreatingTeam, setIsCreatingTeam] = useState(false);
const handleCreateTeam = async () => {
if (!onEnsureOrchestratorRoom) return;
setIsCreatingTeam(true);
try {
await onEnsureOrchestratorRoom();
} catch (e) {
console.error("Failed to create team room", e);
} finally {
setIsCreatingTeam(false);
}
};
if (!rooms || rooms.length === 0) {
return (
<section className="bg-slate-800/30 border border-slate-700/50 rounded-xl p-6 space-y-4">
@@ -63,14 +88,21 @@ export function MicrodaoRoomsSection({
);
}
// Find primary room
const primary = rooms.find(r => r.slug === primaryRoomSlug)
?? rooms.find(r => r.room_role === 'primary')
?? rooms[0];
// Find special rooms
const teamRoom = rooms.find(r => r.room_role === 'orchestrator_team');
const others = rooms.filter(r => r.id !== primary.id);
// Filter out team room from general list if we show it separately
const generalRooms = rooms.filter(r => r.room_role !== 'orchestrator_team');
// Group by role for mini-map
// Find primary room
const primary = generalRooms.find(r => r.slug === primaryRoomSlug)
?? generalRooms.find(r => r.room_role === 'primary')
?? generalRooms[0];
// Others (excluding primary and team room)
const others = generalRooms.filter(r => r.id !== primary?.id);
// Group by role for mini-map (include all rooms for stats)
const byRole = rooms.reduce((acc, r) => {
const role = r.room_role || 'other';
if (!acc[role]) acc[role] = [];
@@ -79,112 +111,163 @@ export function MicrodaoRoomsSection({
}, {} as Record<string, CityRoomSummary[]>);
// Get meta for primary room
const primaryMeta = primary.room_role ? ROLE_META[primary.room_role] : undefined;
const primaryMeta = primary?.room_role ? ROLE_META[primary.room_role] : undefined;
return (
<section className="bg-slate-800/30 border border-slate-700/50 rounded-xl p-6 space-y-6">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<h2 className="text-lg font-semibold text-slate-100 flex items-center gap-2">
<MessageCircle className="w-5 h-5 text-cyan-400" />
Кімнати MicroDAO
<span className="text-sm font-normal text-slate-500">({rooms.length})</span>
</h2>
{/* Mini-map */}
<div className="flex flex-wrap gap-2">
{Object.entries(byRole).map(([role, list]) => {
const meta = ROLE_META[role];
return (
<div
key={role}
className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full border text-[11px] ${
meta ? meta.chipClass : "bg-slate-700/30 text-slate-400 border-slate-700/50"
}`}
>
{meta?.icon || <Hash className="w-3 h-3" />}
<span>{meta?.label ?? (role === 'other' ? 'Other' : role)}</span>
<span className="opacity-60">({list.length})</span>
</div>
);
})}
</div>
</div>
{/* Primary room with inline chat */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className={`p-2 rounded-lg border ${primaryMeta ? primaryMeta.chipClass : "text-cyan-400 bg-cyan-500/10 border-cyan-500/30"}`}>
{primaryMeta?.icon || <Home className="w-4 h-4" />}
</div>
<div>
<div className="text-base font-medium text-slate-100 flex items-center gap-2">
{primary.name}
<span className="text-[10px] uppercase tracking-wider text-emerald-400 bg-emerald-500/10 px-1.5 py-0.5 rounded border border-emerald-500/20">
Primary
</span>
</div>
<div className="text-xs text-slate-500">
{primaryMeta?.label || primary.room_role || 'Main Room'}
</div>
</div>
<section className="space-y-6">
{/* Orchestrator Team Chat Section */}
{(teamRoom || canManage) && (
<div className="bg-fuchsia-900/10 border border-fuchsia-500/20 rounded-xl p-4 space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-base font-semibold text-fuchsia-200 flex items-center gap-2">
<Bot className="w-4 h-4 text-fuchsia-400" />
Orchestrator Team Chat
</h3>
{teamRoom && (
<span className="text-[10px] uppercase tracking-wider text-fuchsia-300 bg-fuchsia-500/10 px-2 py-0.5 rounded-full border border-fuchsia-500/20">
Team Room
</span>
)}
</div>
<Link
href={`/city/${primary.slug}`}
className="text-xs text-cyan-400 hover:text-cyan-300 transition-colors px-3 py-1.5 rounded-lg hover:bg-cyan-950/30 border border-transparent hover:border-cyan-500/20"
>
Відкрити окремо
</Link>
{teamRoom ? (
<CityChatWidget roomSlug={teamRoom.slug} hideTitle className="border-fuchsia-500/20" />
) : (
<div className="flex flex-col items-center justify-center py-8 text-center space-y-3 bg-fuchsia-950/30 rounded-lg border border-dashed border-fuchsia-500/30">
<Bot className="w-8 h-8 text-fuchsia-500/50" />
<div>
<p className="text-fuchsia-200 font-medium">Командний чат оркестратора</p>
<p className="text-sm text-fuchsia-400/70 max-w-md mx-auto mt-1">
Створіть закриту кімнату для команди агентів оркестратора (CrewAI integration).
</p>
</div>
<Button
onClick={handleCreateTeam}
disabled={isCreatingTeam}
variant="outline"
size="sm"
className="bg-fuchsia-600/20 border-fuchsia-500/50 hover:bg-fuchsia-600/30 text-fuchsia-200"
>
{isCreatingTeam ? "Створення..." : (
<>
<PlusCircle className="w-4 h-4 mr-2" />
Створити Orchestrator Team Chat
</>
)}
</Button>
</div>
)}
</div>
)}
<CityChatWidget roomSlug={primary.slug} />
</div>
{/* General Rooms Section */}
<div className="bg-slate-800/30 border border-slate-700/50 rounded-xl p-6 space-y-6">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<h2 className="text-lg font-semibold text-slate-100 flex items-center gap-2">
<MessageCircle className="w-5 h-5 text-cyan-400" />
Кімнати MicroDAO
<span className="text-sm font-normal text-slate-500">({rooms.length})</span>
</h2>
{/* Other rooms */}
{others.length > 0 && (
<div className="space-y-3 pt-2">
<div className="text-sm text-slate-400 font-medium px-1">Інші кімнати</div>
<div className="grid gap-3 md:grid-cols-2">
{others.map(room => {
const meta = room.room_role ? ROLE_META[room.room_role] : undefined;
{/* Mini-map */}
<div className="flex flex-wrap gap-2">
{Object.entries(byRole).map(([role, list]) => {
const meta = ROLE_META[role];
return (
<div
key={room.id}
className="bg-slate-900/50 border border-slate-700/30 rounded-xl p-4 space-y-3 hover:border-slate-600/50 transition-colors"
key={role}
className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full border text-[11px] ${
meta ? meta.chipClass : "bg-slate-700/30 text-slate-400 border-slate-700/50"
}`}
>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-3">
<div className={`p-1.5 rounded-lg border ${meta ? meta.chipClass : "text-slate-400 bg-slate-700/30 border-slate-700/50"}`}>
{meta?.icon || <Hash className="w-3.5 h-3.5" />}
</div>
<div>
<div className="text-sm font-medium text-slate-200">{room.name}</div>
{meta && (
<div className="text-[11px] text-slate-500">
{meta.label}
</div>
)}
</div>
</div>
<Link
href={`/city/${room.slug}`}
className="text-xs text-slate-400 hover:text-cyan-400 transition-colors px-2 py-1"
>
Увійти
</Link>
</div>
{showAllChats && (
<div className="mt-2">
<CityChatWidget roomSlug={room.slug} compact />
</div>
)}
{meta?.icon || <Hash className="w-3 h-3" />}
<span>{meta?.label ?? (role === 'other' ? 'Other' : role)}</span>
<span className="opacity-60">({list.length})</span>
</div>
);
})}
</div>
</div>
)}
{/* Primary room with inline chat (if exists) */}
{primary && (
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className={`p-2 rounded-lg border ${primaryMeta ? primaryMeta.chipClass : "text-cyan-400 bg-cyan-500/10 border-cyan-500/30"}`}>
{primaryMeta?.icon || <Home className="w-4 h-4" />}
</div>
<div>
<div className="text-base font-medium text-slate-100 flex items-center gap-2">
{primary.name}
<span className="text-[10px] uppercase tracking-wider text-emerald-400 bg-emerald-500/10 px-1.5 py-0.5 rounded border border-emerald-500/20">
Primary
</span>
</div>
<div className="text-xs text-slate-500">
{primaryMeta?.label || primary.room_role || 'Main Room'}
</div>
</div>
</div>
<Link
href={`/city/${primary.slug}`}
className="text-xs text-cyan-400 hover:text-cyan-300 transition-colors px-3 py-1.5 rounded-lg hover:bg-cyan-950/30 border border-transparent hover:border-cyan-500/20"
>
Відкрити окремо
</Link>
</div>
<CityChatWidget roomSlug={primary.slug} hideTitle />
</div>
)}
{/* Other rooms */}
{others.length > 0 && (
<div className="space-y-3 pt-2">
<div className="text-sm text-slate-400 font-medium px-1">Інші кімнати</div>
<div className="grid gap-3 md:grid-cols-2">
{others.map(room => {
const meta = room.room_role ? ROLE_META[room.room_role] : undefined;
return (
<div
key={room.id}
className="bg-slate-900/50 border border-slate-700/30 rounded-xl p-4 space-y-3 hover:border-slate-600/50 transition-colors"
>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-3">
<div className={`p-1.5 rounded-lg border ${meta ? meta.chipClass : "text-slate-400 bg-slate-700/30 border-slate-700/50"}`}>
{meta?.icon || <Hash className="w-3.5 h-3.5" />}
</div>
<div>
<div className="text-sm font-medium text-slate-200">{room.name}</div>
{meta && (
<div className="text-[11px] text-slate-500">
{meta.label}
</div>
)}
</div>
</div>
<Link
href={`/city/${room.slug}`}
className="text-xs text-slate-400 hover:text-cyan-400 transition-colors px-2 py-1"
>
Увійти
</Link>
</div>
{showAllChats && (
<div className="mt-2">
<CityChatWidget roomSlug={room.slug} compact />
</div>
)}
</div>
);
})}
</div>
</div>
)}
</div>
</section>
);
}

View File

@@ -0,0 +1,216 @@
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { MatrixRestClient, createMatrixClient, ChatMessage as MatrixChatMessage, PresenceEvent } from '@/lib/matrix-client';
import { useAuth } from '@/context/AuthContext';
import { getAccessToken } from '@/lib/auth';
import { usePresenceHeartbeat } from '@/hooks/usePresenceHeartbeat';
export type ConnectionStatus = 'loading' | 'connecting' | 'online' | 'error' | 'unauthenticated';
interface BootstrapData {
matrix_hs_url: string;
matrix_user_id: string;
matrix_access_token: string;
matrix_device_id: string;
matrix_room_id: string;
matrix_room_alias: string;
room: {
id: string;
slug: string;
name: string;
description?: string;
};
}
export function useMatrixChat(roomSlug: string) {
const { user } = useAuth();
const token = getAccessToken();
const [messages, setMessages] = useState<MatrixChatMessage[]>([]);
const [status, setStatus] = useState<ConnectionStatus>('loading');
const [error, setError] = useState<string | null>(null);
const [bootstrap, setBootstrap] = useState<BootstrapData | null>(null);
const matrixClient = useRef<MatrixRestClient | null>(null);
// Presence & Typing state
const [onlineUsers, setOnlineUsers] = useState<Map<string, 'online' | 'offline' | 'unavailable'>>(new Map());
const [typingUsers, setTypingUsers] = useState<Set<string>>(new Set());
const typingTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// Presence heartbeat
usePresenceHeartbeat({
matrixUserId: bootstrap?.matrix_user_id ?? null,
accessToken: bootstrap?.matrix_access_token ?? null,
intervalMs: 30000,
awayAfterMs: 5 * 60 * 1000,
enabled: status === 'online',
});
const initializeMatrix = useCallback(async () => {
if (!token) {
setStatus('unauthenticated');
return;
}
setStatus('loading');
setError(null);
try {
// 1. Get bootstrap data
const res = await fetch(`/api/city/chat/bootstrap?room_slug=${roomSlug}`, {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.detail || 'Failed to get chat bootstrap');
}
const data: BootstrapData = await res.json();
setBootstrap(data);
// 2. Create Matrix client
setStatus('connecting');
const client = createMatrixClient(data);
matrixClient.current = client;
// 3. Join room
try {
await client.joinRoom(data.matrix_room_id);
} catch (e) {
console.log('Join room result:', e);
}
// 4. Get initial messages
const messagesRes = await client.getMessages(data.matrix_room_id, { limit: 50 });
const initialMessages = messagesRes.chunk
.filter((e: any) => e.type === 'm.room.message' && e.content?.body)
.map((e: any) => client.mapToChatMessage(e))
.reverse();
setMessages(initialMessages);
// 5. Set up handlers
client.onPresence = (event: PresenceEvent) => {
if (!event.sender || !event.content?.presence) return;
setOnlineUsers(prev => {
const next = new Map(prev);
next.set(event.sender, event.content.presence);
return next;
});
};
client.onTyping = (roomId: string, userIds: string[]) => {
if (roomId !== data.matrix_room_id) return;
const others = userIds.filter(id => id !== data.matrix_user_id);
setTypingUsers(new Set(others));
};
// 6. Start sync
await client.initialSync();
client.startSync((newMessage: MatrixChatMessage) => {
setMessages(prev => {
if (prev.some(m => m.id === newMessage.id)) {
return prev;
}
return [...prev, newMessage];
});
});
setStatus('online');
} catch (err) {
console.error('Matrix initialization error:', err);
setError(err instanceof Error ? err.message : 'Unknown error');
setStatus('error');
}
}, [token, roomSlug]);
useEffect(() => {
initializeMatrix();
return () => {
if (matrixClient.current) {
matrixClient.current.onPresence = undefined;
matrixClient.current.onTyping = undefined;
matrixClient.current.stopSync();
}
if (typingTimeoutRef.current) {
clearTimeout(typingTimeoutRef.current);
}
};
}, [initializeMatrix]);
const onlineCount = useMemo(() => {
let count = 0;
onlineUsers.forEach((status, userId) => {
if (status === 'online' || status === 'unavailable') {
if (userId !== bootstrap?.matrix_user_id) {
count++;
}
}
});
return count;
}, [onlineUsers, bootstrap]);
const handleTyping = useCallback(() => {
if (!matrixClient.current || !bootstrap) return;
matrixClient.current.sendTyping(bootstrap.matrix_room_id, true);
if (typingTimeoutRef.current) {
clearTimeout(typingTimeoutRef.current);
}
typingTimeoutRef.current = setTimeout(() => {
if (matrixClient.current && bootstrap) {
matrixClient.current.sendTyping(bootstrap.matrix_room_id, false);
}
}, 3000);
}, [bootstrap]);
const sendMessage = async (body: string) => {
if (!matrixClient.current || !bootstrap) return;
try {
matrixClient.current.sendTyping(bootstrap.matrix_room_id, false);
if (typingTimeoutRef.current) {
clearTimeout(typingTimeoutRef.current);
}
const tempId = `temp_${Date.now()}`;
const tempMessage: MatrixChatMessage = {
id: tempId,
senderId: bootstrap.matrix_user_id,
senderName: 'You',
text: body,
timestamp: new Date(),
isUser: true
};
setMessages(prev => [...prev, tempMessage]);
const result = await matrixClient.current.sendMessage(bootstrap.matrix_room_id, body);
setMessages(prev => prev.map(m =>
m.id === tempId ? { ...m, id: result.event_id } : m
));
} catch (err) {
console.error('Failed to send message:', err);
setMessages(prev => prev.filter(m => !m.id.startsWith('temp_')));
throw err;
}
};
return {
messages,
status,
error,
roomName: bootstrap?.room.name,
onlineCount,
typingUsers,
sendMessage,
handleTyping,
retry: initializeMatrix
};
}

View File

@@ -8,9 +8,10 @@
const getApiBase = () => {
// Server-side: use internal Docker network URL
if (typeof window === 'undefined') {
return process.env.INTERNAL_API_URL || 'http://127.0.0.1'
// CITY_API_BASE_URL should point to city-service (e.g., http://daarion-city-service:7001)
return process.env.INTERNAL_API_URL || process.env.CITY_API_BASE_URL || 'http://daarion-city-service:7001'
}
// Client-side: use relative URLs (same origin)
// Client-side: use relative URLs (same origin) or explicit public URL
return process.env.NEXT_PUBLIC_API_BASE_URL || ''
}

View File

@@ -2,7 +2,7 @@
* MicroDAO API Client (Task 029)
*/
import { MicrodaoOption } from "@/lib/types/microdao";
import { MicrodaoOption, CityRoomSummary } from "@/lib/types/microdao";
// =============================================================================
// Types
@@ -100,3 +100,19 @@ export async function removeAgentFromMicrodao(
throw new Error(error.detail || error.error || "Failed to remove MicroDAO membership");
}
}
/**
* Ensure Orchestrator Team Room exists
*/
export async function ensureOrchestratorRoom(slug: string): Promise<CityRoomSummary> {
const res = await fetch(`/api/microdao/${encodeURIComponent(slug)}/rooms/orchestrator-team`, {
method: "POST",
});
if (!res.ok) {
const error = await res.json().catch(() => ({}));
throw new Error(error.detail || error.error || "Failed to ensure orchestrator room");
}
return res.json();
}

View File

@@ -42,6 +42,16 @@ export interface MicrodaoBadge {
import { AgentMicrodaoMembership } from './microdao';
export type { AgentMicrodaoMembership };
// =============================================================================
// CrewAI Info (TASK 044)
// =============================================================================
export interface AgentCrewInfo {
has_crew_team: boolean;
crew_team_key?: string | null;
matrix_room_id?: string | null;
}
// =============================================================================
// Agent Summary (unified for Agent Console & internal use)
// =============================================================================
@@ -78,6 +88,9 @@ export interface AgentSummary {
// Skills
public_skills: string[];
// CrewAI
crew_info?: AgentCrewInfo | null;
}
// =============================================================================