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:
@@ -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">
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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`, {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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();
|
||||
}}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
244
apps/web/src/components/city/chat/CityChatPanel.tsx
Normal file
244
apps/web/src/components/city/chat/CityChatPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
216
apps/web/src/hooks/useMatrixChat.ts
Normal file
216
apps/web/src/hooks/useMatrixChat.ts
Normal 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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 || ''
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
|
||||
Reference in New Issue
Block a user