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

@@ -58,8 +58,13 @@ function App() {
<Route path="/secondme" element={<SecondMePage />} />
<Route path="/space" element={<SpaceDashboard />} />
<Route path="/messenger" element={<MessengerPage />} />
{/* Task 039: Agent Console v2 */}
<Route path="/agents" element={<AgentHubPage />} />
<Route path="/agents/:agentId" element={<AgentCabinet />} />
{/* Legacy Aliases */}
<Route path="/agent-hub" element={<AgentHubPage />} />
<Route path="/agent/:agentId" element={<AgentCabinet />} />
<Route path="/microdao" element={<MicrodaoListPage />} />
<Route path="/microdao/:slug" element={<MicrodaoConsolePage />} />
<Route path="/dao" element={<DaoListPage />} />
@@ -81,7 +86,6 @@ function App() {
<Route path="/microdao/energy-union" element={<EnergyUnionCabinetPage />} />
<Route path="/microdao/yaromir" element={<YaromirCabinetPage />} />
<Route path="/dagi-monitor" element={<DagiMonitorPage />} />
<Route path="/agent/:agentId" element={<AgentCabinetPage />} />
<Route path="/chat-demo" element={<ChatDemoPage />} />
<Route path="/network" element={<NetworkPageSimple />} />
<Route path="/connect-node" element={<ConnectNodePage />} />

View File

@@ -5,6 +5,9 @@
* API: http://localhost:7014
*/
import { apiGet, apiPut, apiPost, apiPatch, apiDelete } from './client';
import type { AgentSummary, AgentDetailDashboard, AgentVisibilityPayload } from '../types/agent-cabinet';
// ============================================================================
// Types (matching backend models.py)
// ============================================================================
@@ -197,7 +200,38 @@ async function agentsRequest<T>(
}
// ============================================================================
// Agent CRUD
// Task 039: Unified Agent API (City Service)
// ============================================================================
/**
* Get list of agents with filtering (Unified API)
*/
export async function getAgentList(params: Record<string, any> = {}): Promise<{ items: AgentSummary[], total: number }> {
const searchParams = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
searchParams.append(key, String(value));
}
});
return apiGet<{ items: AgentSummary[], total: number }>(`/city/public/agents?${searchParams.toString()}`);
}
/**
* Get Agent Cabinet Dashboard (Unified API)
*/
export async function getAgentDashboard(agentId: string): Promise<AgentDetailDashboard> {
return apiGet<AgentDetailDashboard>(`/city/city/agents/${encodeURIComponent(agentId)}/dashboard`);
}
/**
* Update Agent Visibility (Unified API)
*/
export async function updateAgentVisibility(agentId: string, payload: AgentVisibilityPayload): Promise<any> {
return apiPut(`/city/city/agents/${encodeURIComponent(agentId)}/visibility`, payload);
}
// ============================================================================
// Legacy Agent CRUD (Agents Service)
// ============================================================================
/**

View File

@@ -12,6 +12,12 @@ export interface CityRoom {
last_event?: string;
created_at: string;
updated_at: string;
logo_url?: string | null;
banner_url?: string | null;
microdao_id?: string;
microdao_name?: string;
microdao_slug?: string;
microdao_logo_url?: string;
}
export interface CityRoomMessage {

View File

@@ -93,6 +93,14 @@ export async function apiPost<T>(endpoint: string, data?: unknown): Promise<T> {
return response.json();
}
export async function apiPut<T>(endpoint: string, data: unknown): Promise<T> {
const response = await fetchApi(endpoint, {
method: 'PUT',
body: JSON.stringify(data),
});
return response.json();
}
export async function apiPatch<T>(endpoint: string, data: unknown): Promise<T> {
const response = await fetchApi(endpoint, {
method: 'PATCH',

View File

@@ -24,6 +24,8 @@ export interface MicrodaoRead extends MicrodaoBase {
updated_at: string;
member_count?: number;
agent_count?: number;
logo_url?: string | null;
banner_url?: string | null;
}
export interface MicrodaoCreate extends MicrodaoBase {}
@@ -240,3 +242,48 @@ export async function checkMicrodaoServiceHealth(): Promise<{
return microdaoRequest('/health');
}
// ============================================================================
// Assets & Branding
// ============================================================================
export interface AssetUploadResponse {
original_url: string;
processed_url: string;
thumb_url: string;
}
export async function uploadAsset(file: File, type: string): Promise<AssetUploadResponse> {
const formData = new FormData();
formData.append('file', file);
formData.append('type', type);
const sessionToken = localStorage.getItem('daarion_session_token');
const headers: HeadersInit = {};
if (sessionToken) {
headers['Authorization'] = `Bearer ${sessionToken}`;
}
const response = await fetch(`${MICRODAO_API_URL}/assets/upload`, {
method: 'POST',
body: formData,
headers,
});
if (!response.ok) {
throw new Error('Failed to upload asset');
}
return response.json();
}
export async function updateMicrodaoBranding(
slug: string,
logoUrl?: string | null,
bannerUrl?: string | null
): Promise<MicrodaoRead> {
return microdaoRequest<MicrodaoRead>(`/microdao/${encodeURIComponent(slug)}/branding`, {
method: 'PATCH',
body: JSON.stringify({ logo_url: logoUrl, banner_url: bannerUrl })
});
}

46
src/api/microdaoWizard.ts Normal file
View File

@@ -0,0 +1,46 @@
import { apiPost } from './client';
export interface CreateMicroDaoPayload {
agent_id: string;
name: string;
slug: string;
description?: string;
visibility: 'public' | 'confidential';
district?: string;
create_rooms: {
primary_lobby: boolean;
governance: boolean;
crew_team: boolean;
};
}
export interface AttachAgentPayload {
agent_id: string;
role: 'orchestrator' | 'member';
}
/**
* Create MicroDAO from Agent (Wizard)
*/
export async function createMicroDaoFromAgent(payload: CreateMicroDaoPayload): Promise<any> {
const { agent_id, ...data } = payload;
// Map frontend payload to backend expectations
const backendPayload = {
name: data.name,
slug: data.slug,
description: data.description,
is_public: data.visibility === 'public',
make_platform: !!data.district, // Simplified logic for MVP
create_rooms: data.create_rooms
};
return apiPost(`/city/city/agents/${encodeURIComponent(agent_id)}/microdao`, backendPayload);
}
/**
* Attach Agent to existing MicroDAO
*/
export async function attachAgentToMicroDao(slug: string, payload: AttachAgentPayload): Promise<any> {
return apiPost(`/city/city/microdao/${encodeURIComponent(slug)}/attach-agent`, payload);
}

View File

@@ -1,300 +1,257 @@
/**
* AgentCabinet Component
* Full agent view with tabs: Metrics, Context, Settings
*/
import { useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useAgent } from './hooks/useAgent';
import { useAgentContext } from './hooks/useAgentContext';
import { AgentMetricsPanel } from './AgentMetricsPanel';
import { AgentSettingsPanel } from './AgentSettingsPanel';
import { AgentEventsPanel } from './AgentEventsPanel';
type TabType = 'metrics' | 'context' | 'settings' | 'events';
const STATUS_COLORS = {
active: 'bg-green-500',
idle: 'bg-yellow-500',
offline: 'bg-gray-400',
error: 'bg-red-500',
};
const STATUS_LABELS = {
active: 'Активний',
idle: 'Очікує',
offline: 'Офлайн',
error: 'Помилка',
};
import { useAgentDashboard } from './hooks/useAgentDashboard';
import { VisibilityCard } from './components/VisibilityCard';
import { AgentChatWidget } from './components/AgentChatWidget';
import { MicroDaoWizard } from './components/MicroDaoWizard';
export function AgentCabinet() {
const { agentId } = useParams<{ agentId: string }>();
const navigate = useNavigate();
const [activeTab, setActiveTab] = useState<TabType>('metrics');
const { agent, loading, error, refetch } = useAgent(agentId!);
const { context, loading: contextLoading } = useAgentContext(agentId!);
const { dashboard, loading, error, refetch } = useAgentDashboard(agentId!);
const [isWizardOpen, setIsWizardOpen] = useState(false);
if (loading) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4" />
<div className="text-gray-600">Завантаження агента...</div>
<div className="text-gray-600">Loading Agent Cabinet...</div>
</div>
</div>
);
}
if (error || !agent) {
if (error || !dashboard) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="bg-red-50 border border-red-200 rounded-lg p-8 text-center max-w-md">
<div className="text-6xl mb-4"></div>
<h2 className="text-xl font-semibold text-red-900 mb-2">
Агент не знайдено
</h2>
<p className="text-red-600 mb-4">
{error?.message || 'Агент не існує або недоступний'}
</p>
<h2 className="text-xl font-semibold text-red-900 mb-2">Agent Not Found</h2>
<button
onClick={() => navigate('/agent-hub')}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
Повернутись до Agent Hub
Back to Agent Hub
</button>
</div>
</div>
);
}
const { profile, node, primary_city_room, system_prompts, public_profile, microdao_memberships } = dashboard;
return (
<div className="min-h-screen bg-gray-50">
<div className="min-h-screen bg-gray-50 pb-12">
{/* Header */}
<div className="bg-white border-b border-gray-200">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
{/* Back button */}
<button
onClick={() => navigate('/agent-hub')}
className="text-blue-600 hover:text-blue-700 mb-4 flex items-center gap-2"
>
Назад до Agent Hub
Back to Agent Hub
</button>
{/* Agent header */}
<div className="flex items-center gap-6">
{/* Avatar */}
<div className="w-24 h-24 rounded-full bg-gradient-to-br from-blue-400 to-purple-500 flex items-center justify-center text-white text-4xl font-bold">
{agent.name.charAt(0).toUpperCase()}
</div>
{/* Info */}
<div className="flex-1">
<div className="flex items-center gap-4 mb-2">
<h1 className="text-3xl font-bold text-gray-900">
{agent.name}
</h1>
{/* Status */}
<div className="flex items-center gap-2">
<div className={`w-3 h-3 rounded-full ${STATUS_COLORS[agent.status]}`} />
<span className="text-sm text-gray-600">
{STATUS_LABELS[agent.status]}
</span>
</div>
</div>
{/* Description */}
{agent.description && (
<p className="text-gray-600 mb-3">{agent.description}</p>
<div className="flex items-start gap-6">
<div className="w-24 h-24 rounded-lg bg-gradient-to-br from-blue-500 to-indigo-600 flex items-center justify-center text-white text-4xl font-bold shadow-lg">
{profile.avatar_url ? (
<img src={profile.avatar_url} alt={profile.display_name} className="w-full h-full object-cover rounded-lg" />
) : (
profile.display_name.charAt(0).toUpperCase()
)}
</div>
{/* Meta */}
<div className="flex items-center gap-4 text-sm text-gray-500">
<div className="flex items-center gap-2">
<span>🤖</span>
<span className="font-mono bg-gray-100 px-2 py-1 rounded">
{agent.model}
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<h1 className="text-3xl font-bold text-gray-900">{profile.display_name}</h1>
<span className={`px-2 py-1 rounded text-xs font-medium uppercase tracking-wide ${
profile.status === 'online' ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'
}`}>
{profile.status}
</span>
{profile.is_public && (
<span className="px-2 py-1 rounded text-xs font-medium uppercase tracking-wide bg-purple-100 text-purple-800">
Public Citizen
</span>
)}
</div>
<div className="flex flex-wrap gap-4 text-sm text-gray-600">
<div className="flex items-center gap-1">
<span>🤖</span>
<span className="font-medium">{profile.kind}</span>
</div>
<div className="flex items-center gap-2">
<span>🏢</span>
<span className="font-mono">{agent.microdao_id}</span>
</div>
<div className="flex items-center gap-2">
<span>🔧</span>
<span>{agent.tools.length} інструментів</span>
</div>
{node && (
<div className="flex items-center gap-1">
<span>🖥</span>
<span className="font-medium">{node.name}</span>
<span className="text-xs text-gray-400">({node.status})</span>
</div>
)}
{profile.primary_microdao_name && (
<div className="flex items-center gap-1">
<span>🏢</span>
<span className="font-medium">{profile.primary_microdao_name}</span>
</div>
)}
</div>
</div>
{/* Actions */}
<div className="flex flex-col gap-2">
<button
<div className="flex gap-2">
<button
onClick={refetch}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
className="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 text-gray-700"
>
🔄 Оновити
</button>
<button
onClick={() => navigate(`/messenger?agent=${agent.id}`)}
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors"
>
💬 Чат
Refresh
</button>
</div>
</div>
</div>
{/* Tabs */}
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex gap-1 border-b border-gray-200">
<button
onClick={() => setActiveTab('metrics')}
className={`
px-6 py-3 font-medium transition-colors
${activeTab === 'metrics'
? 'text-blue-600 border-b-2 border-blue-600'
: 'text-gray-600 hover:text-gray-900'
}
`}
>
📊 Метрики
</button>
<button
onClick={() => setActiveTab('context')}
className={`
px-6 py-3 font-medium transition-colors
${activeTab === 'context'
? 'text-blue-600 border-b-2 border-blue-600'
: 'text-gray-600 hover:text-gray-900'
}
`}
>
🧠 Контекст
</button>
<button
onClick={() => setActiveTab('settings')}
className={`
px-6 py-3 font-medium transition-colors
${activeTab === 'settings'
? 'text-blue-600 border-b-2 border-blue-600'
: 'text-gray-600 hover:text-gray-900'
}
`}
>
Налаштування
</button>
<button
onClick={() => setActiveTab('events')}
className={`
px-6 py-3 font-medium transition-colors
${activeTab === 'events'
? 'text-blue-600 border-b-2 border-blue-600'
: 'text-gray-600 hover:text-gray-900'
}
`}
>
📜 Події
</button>
</div>
</div>
</div>
{/* Content */}
{/* Main Content Grid */}
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{activeTab === 'metrics' && <AgentMetricsPanel agentId={agent.id} />}
{activeTab === 'context' && (
<div className="space-y-6">
<h3 className="text-lg font-semibold text-gray-900">🧠 Контекст агента</h3>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Left Column: Identity & System */}
<div className="lg:col-span-2 space-y-8">
{contextLoading ? (
<div className="bg-white border border-gray-200 rounded-lg p-8 text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto mb-4" />
<div className="text-gray-600">Завантаження контексту...</div>
</div>
) : context ? (
{/* DAIS / System Prompts Block */}
<div className="bg-white border border-gray-200 rounded-lg p-6 shadow-sm">
<h3 className="text-lg font-semibold text-gray-900 mb-4">🧠 System Prompts (DAIS)</h3>
<div className="space-y-4">
{/* Short-term memory */}
<div className="bg-white border border-gray-200 rounded-lg p-6">
<h4 className="font-semibold text-gray-900 mb-3">
Короткострокова пам'ять ({context.short_term.length})
</h4>
{context.short_term.length > 0 ? (
<div className="space-y-2">
{context.short_term.map((item) => (
<div key={item.id} className="p-3 bg-blue-50 rounded text-sm">
<div className="text-gray-900">{item.content}</div>
<div className="text-xs text-gray-500 mt-1">
{new Date(item.timestamp).toLocaleString('uk-UA')}
</div>
</div>
))}
{['core', 'safety', 'governance', 'tools'].map((kind) => {
const prompt = system_prompts[kind as keyof typeof system_prompts];
return (
<div key={kind} className="border border-gray-100 rounded-lg p-4 bg-gray-50">
<div className="flex justify-between items-center mb-2">
<span className="font-medium text-gray-700 uppercase text-xs tracking-wider">{kind} Prompt</span>
{prompt && (
<span className="text-xs text-gray-500">v{prompt.version} {new Date(prompt.created_at).toLocaleDateString()}</span>
)}
</div>
<div className="text-sm text-gray-800 font-mono whitespace-pre-wrap max-h-32 overflow-y-auto">
{prompt?.content || <span className="text-gray-400 italic">No prompt defined</span>}
</div>
</div>
) : (
<div className="text-gray-500 text-sm">Немає записів</div>
)}
</div>
{/* Mid-term memory */}
<div className="bg-white border border-gray-200 rounded-lg p-6">
<h4 className="font-semibold text-gray-900 mb-3">
Середньострокова пам'ять ({context.mid_term.length})
</h4>
{context.mid_term.length > 0 ? (
<div className="space-y-2">
{context.mid_term.map((item) => (
<div key={item.id} className="p-3 bg-purple-50 rounded text-sm">
<div className="text-gray-900">{item.content}</div>
<div className="text-xs text-gray-500 mt-1">
{new Date(item.timestamp).toLocaleString('uk-UA')}
</div>
</div>
))}
</div>
) : (
<div className="text-gray-500 text-sm">Немає записів</div>
)}
</div>
{/* Knowledge items */}
<div className="bg-white border border-gray-200 rounded-lg p-6">
<h4 className="font-semibold text-gray-900 mb-3">
База знань ({context.knowledge_items.length})
</h4>
{context.knowledge_items.length > 0 ? (
<div className="space-y-2">
{context.knowledge_items.map((item) => (
<div key={item.id} className="p-3 bg-green-50 rounded text-sm">
<div className="text-gray-900">{item.content}</div>
<div className="text-xs text-gray-500 mt-1">
{new Date(item.timestamp).toLocaleString('uk-UA')}
</div>
</div>
))}
</div>
) : (
<div className="text-gray-500 text-sm">Немає записів</div>
)}
</div>
);
})}
</div>
) : (
<div className="bg-gray-50 border border-gray-200 rounded-lg p-8 text-center">
<div className="text-gray-500">Контекст недоступний</div>
</div>
{/* Agent Chat Block */}
<div>
<h3 className="text-lg font-semibold text-gray-900 mb-4">💬 Agent Chat</h3>
{primary_city_room ? (
<AgentChatWidget roomId={primary_city_room.id} roomName={primary_city_room.name} />
) : (
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-6 text-center">
<div className="text-yellow-800 font-medium mb-2">No Primary Chat Room</div>
<p className="text-sm text-yellow-600 mb-4">
This agent is not assigned to a primary city room.
</p>
<button className="text-blue-600 hover:underline text-sm">
Configure Rooms in MicroDAO
</button>
</div>
)}
</div>
</div>
{/* Right Column: Visibility & Roles */}
<div className="space-y-8">
{/* Visibility Card */}
<VisibilityCard
agentId={profile.agent_id}
publicProfile={public_profile}
onUpdate={refetch}
/>
{/* MicroDAO Memberships */}
<div className="bg-white border border-gray-200 rounded-lg p-6 shadow-sm">
<h3 className="text-lg font-semibold text-gray-900 mb-4">🏢 MicroDAO Memberships</h3>
{microdao_memberships.length > 0 ? (
<div className="space-y-3">
{microdao_memberships.map((m) => (
<div key={m.microdao_id} className="flex items-center justify-between p-3 border border-gray-100 rounded bg-gray-50 hover:bg-white transition-colors">
<div className="flex items-center gap-3">
{m.logo_url ? (
<img src={m.logo_url} alt={m.microdao_name} className="w-8 h-8 rounded-full object-cover bg-gray-200" />
) : (
<div className="w-8 h-8 rounded-full bg-blue-100 flex items-center justify-center text-blue-600 text-xs font-bold">
{m.microdao_name?.charAt(0).toUpperCase()}
</div>
)}
<div>
<div className="font-medium text-gray-900">{m.microdao_name || 'Unknown DAO'}</div>
<div className="text-xs text-gray-500">/{m.microdao_slug}</div>
</div>
</div>
<div className="text-right">
<div className={`text-xs font-medium px-2 py-1 rounded ${m.role === 'orchestrator' ? 'bg-purple-100 text-purple-800' : 'bg-blue-100 text-blue-800'}`}>
{m.role || 'member'}
</div>
{m.is_core && <div className="text-[10px] text-gray-500 mt-1">Core Member</div>}
</div>
</div>
))}
</div>
) : (
<div className="text-gray-500 text-sm italic">Not a member of any MicroDAO</div>
)}
<div className="mt-4 pt-4 border-t border-gray-100">
<button
onClick={() => setIsWizardOpen(true)}
className="w-full py-2 border-2 border-dashed border-gray-300 rounded-lg text-gray-500 hover:border-gray-400 hover:text-gray-700 transition-colors text-sm font-medium"
>
+ Create MicroDAO (Orchestrator)
</button>
</div>
</div>
{/* Node Info */}
{node && (
<div className="bg-white border border-gray-200 rounded-lg p-6 shadow-sm">
<h3 className="text-lg font-semibold text-gray-900 mb-4">🖥 Runtime Node</h3>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-gray-500">Node Name</span>
<span className="font-medium">{node.name}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500">ID</span>
<span className="font-mono text-xs">{node.node_id}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500">Environment</span>
<span>{node.environment || 'N/A'}</span>
</div>
{node.guardian_agent && (
<div className="pt-2 mt-2 border-t border-gray-100">
<span className="text-xs text-gray-500 block mb-1">Guardian Agent</span>
<span className="text-blue-600">{node.guardian_agent.name}</span>
</div>
)}
</div>
</div>
)}
</div>
)}
{activeTab === 'settings' && (
<AgentSettingsPanel agent={agent} onUpdate={refetch} />
)}
{activeTab === 'events' && (
<AgentEventsPanel agentId={agent.id} />
)}
</div>
</div>
<MicroDaoWizard
isOpen={isWizardOpen}
onClose={() => setIsWizardOpen(false)}
agentId={profile.agent_id}
onSuccess={refetch}
/>
</div>
);
}

View File

@@ -3,47 +3,26 @@
* Single agent card for gallery view
*/
import { useNavigate } from 'react-router-dom';
import type { AgentListItem } from '@/api/agents';
import type { AgentSummary } from '@/types/agent-cabinet';
interface AgentCardProps {
agent: AgentListItem;
agent: AgentSummary;
}
const STATUS_COLORS = {
const STATUS_COLORS: Record<string, string> = {
online: 'bg-green-500',
offline: 'bg-gray-400',
busy: 'bg-yellow-500',
active: 'bg-green-500',
idle: 'bg-yellow-500',
offline: 'bg-gray-400',
error: 'bg-red-500',
};
const STATUS_LABELS = {
active: 'Активний',
idle: 'Очікує',
offline: 'Офлайн',
error: 'Помилка',
};
const KIND_COLORS = {
assistant: 'bg-blue-100 text-blue-800',
node: 'bg-purple-100 text-purple-800',
system: 'bg-gray-100 text-gray-800',
guardian: 'bg-green-100 text-green-800',
analyst: 'bg-orange-100 text-orange-800',
};
const KIND_LABELS = {
assistant: 'Асистент',
node: 'Нода',
system: 'Система',
guardian: 'Захисник',
analyst: 'Аналітик',
};
export function AgentCard({ agent }: AgentCardProps) {
const navigate = useNavigate();
const handleClick = () => {
navigate(`/agent/${agent.id}`);
navigate(`/agents/${agent.id}`);
};
return (
@@ -57,59 +36,72 @@ export function AgentCard({ agent }: AgentCardProps) {
"
>
{/* Status indicator */}
<div className="absolute top-4 right-4">
<div className="flex items-center gap-2">
<div className={`w-3 h-3 rounded-full ${STATUS_COLORS[agent.status]}`} />
<span className="text-xs text-gray-500">
{STATUS_LABELS[agent.status]}
<div className="absolute top-4 right-4 flex gap-2">
{agent.is_public && (
<span className="text-[10px] font-bold uppercase tracking-wide bg-purple-100 text-purple-800 px-2 py-0.5 rounded-full">
Public
</span>
)}
<div className="flex items-center gap-2">
<div className={`w-3 h-3 rounded-full ${STATUS_COLORS[agent.status] || 'bg-gray-300'}`} />
</div>
</div>
{/* Avatar */}
<div className="flex items-center gap-4 mb-4">
<div className="w-16 h-16 rounded-full bg-gradient-to-br from-blue-400 to-purple-500 flex items-center justify-center text-white text-2xl font-bold">
{agent.name.charAt(0).toUpperCase()}
<div className="w-16 h-16 rounded-lg bg-gradient-to-br from-blue-400 to-purple-500 flex items-center justify-center text-white text-2xl font-bold shadow-sm">
{agent.avatar_url ? (
<img src={agent.avatar_url} alt={agent.display_name} className="w-full h-full object-cover rounded-lg" />
) : (
agent.display_name.charAt(0).toUpperCase()
)}
</div>
<div className="flex-1">
<h3 className="text-xl font-semibold text-gray-900">
{agent.name}
<h3 className="text-lg font-bold text-gray-900 truncate pr-8">
{agent.display_name}
</h3>
{/* Kind badge */}
<span className={`
inline-block mt-1 px-2 py-1 rounded-full text-xs font-medium
${KIND_COLORS[agent.kind]}
`}>
{KIND_LABELS[agent.kind]}
<span className="inline-block mt-1 px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-600 uppercase tracking-wide">
{agent.kind}
</span>
</div>
</div>
{/* Description */}
{agent.description && (
<p className="text-sm text-gray-600 mb-4 line-clamp-2">
{agent.description}
</p>
)}
{/* Tagline/Description */}
<p className="text-sm text-gray-600 mb-4 line-clamp-2 h-10">
{agent.tagline || agent.title || 'No description provided.'}
</p>
{/* Model */}
<div className="flex items-center justify-between text-sm">
<div className="flex items-center gap-2">
<span className="text-gray-500">Модель:</span>
<span className="font-mono text-gray-900 bg-gray-100 px-2 py-1 rounded">
{agent.model}
</span>
</div>
{/* Meta Info */}
<div className="space-y-2 text-xs text-gray-500">
{agent.node_label && (
<div className="flex items-center justify-between">
<div className="flex items-center gap-1">
<span>🖥</span>
<span>Node</span>
</div>
<span className="font-medium text-gray-700">{agent.node_label}</span>
</div>
)}
{agent.primary_microdao_name && (
<div className="flex items-center justify-between">
<div className="flex items-center gap-1">
<span>🏢</span>
<span>MicroDAO</span>
</div>
<span className="font-medium text-gray-700 truncate max-w-[120px]">
{agent.primary_microdao_name}
</span>
</div>
)}
</div>
{/* Last active */}
{agent.last_active_at && (
<div className="mt-4 pt-4 border-t border-gray-100 text-xs text-gray-500">
Остання активність: {new Date(agent.last_active_at).toLocaleString('uk-UA')}
</div>
)}
{/* Footer */}
<div className="mt-4 pt-4 border-t border-gray-100 flex justify-between items-center text-xs text-blue-600 font-medium">
<span>Open Cabinet </span>
</div>
</div>
);
}

View File

@@ -3,12 +3,12 @@
* Grid view of agent cards
*/
import { AgentCard } from './AgentCard';
import type { AgentListItem } from '@/api/agents';
import type { AgentSummary } from '@/types/agent-cabinet';
interface AgentGalleryProps {
agents: AgentListItem[];
agents: AgentSummary[];
loading: boolean;
error: Error | null;
error: string | null;
}
export function AgentGallery({ agents, loading, error }: AgentGalleryProps) {
@@ -40,8 +40,8 @@ export function AgentGallery({ agents, loading, error }: AgentGalleryProps) {
if (error) {
return (
<div className="bg-red-50 border border-red-200 rounded-lg p-8 text-center">
<div className="text-red-600 mb-2"> Помилка завантаження агентів</div>
<div className="text-sm text-red-500">{error.message}</div>
<div className="text-red-600 mb-2"> Failed to load agents</div>
<div className="text-sm text-red-500">{error}</div>
</div>
);
}
@@ -52,10 +52,10 @@ export function AgentGallery({ agents, loading, error }: AgentGalleryProps) {
<div className="bg-gray-50 border border-gray-200 rounded-lg p-12 text-center">
<div className="text-6xl mb-4">🤖</div>
<h3 className="text-xl font-semibold text-gray-900 mb-2">
Немає агентів
No Agents Found
</h3>
<p className="text-gray-600">
Агенти з'являться тут після їх створення
Try adjusting your filters or create a new agent.
</p>
</div>
);

View File

@@ -2,33 +2,52 @@
* AgentHubPage Component
* Main page for Agent Hub — browse all agents
*/
import { useState } from 'react';
import { useAgents } from './hooks/useAgents';
import { useState, useEffect } from 'react';
import { useAgentsV2 } from './hooks/useAgentsV2';
import { AgentGallery } from './AgentGallery';
import { AgentCreateDialog } from './AgentCreateDialog';
import { useActor } from '@/store/authStore';
import { fetchCityNodes, fetchCityMicroDAOs } from '@/api/city';
export function AgentHubPage() {
const actor = useActor();
const [filters, setFilters] = useState({
node_id: '',
microdao_id: '',
kind: '',
is_public: undefined as boolean | undefined
});
const [searchQuery, setSearchQuery] = useState('');
const [selectedMicroDao, setSelectedMicroDao] = useState<string | undefined>(undefined);
const [createDialogOpen, setCreateDialogOpen] = useState(false);
const { agents, loading, error, refetch } = useAgents(selectedMicroDao);
// Data for filters
const [nodes, setNodes] = useState<{node_id: string, name: string}[]>([]);
const [microdaos, setMicrodaos] = useState<{id: string, name: string}[]>([]);
const { agents, loading, error, refetch } = useAgentsV2(filters);
useEffect(() => {
// Load filter options
fetchCityNodes().then(res => setNodes(res.items.map(n => ({ node_id: n.node_id, name: n.name }))));
fetchCityMicroDAOs().then(res => setMicrodaos(res.items.map(m => ({ id: m.id, name: m.name }))));
}, []);
const handleCreateSuccess = () => {
refetch();
};
// Filter agents by search query
// Filter agents by search query (client-side search for now)
const filteredAgents = agents.filter((agent) => {
const matchesSearch =
agent.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
agent.description?.toLowerCase().includes(searchQuery.toLowerCase());
return matchesSearch;
const q = searchQuery.toLowerCase();
return (
agent.display_name.toLowerCase().includes(q) ||
agent.title?.toLowerCase().includes(q) ||
agent.tagline?.toLowerCase().includes(q)
);
});
const handleFilterChange = (key: string, value: any) => {
setFilters(prev => ({ ...prev, [key]: value || undefined }));
};
return (
<div className="min-h-screen bg-gray-50">
{/* Header */}
@@ -37,59 +56,82 @@ export function AgentHubPage() {
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-3xl font-bold text-gray-900">
🤖 Agent Hub
🤖 Agent Console
</h1>
<p className="text-gray-600 mt-1">
Керуйте агентами вашого MicroDAO
Manage and monitor all agents in the city
</p>
</div>
<div className="flex gap-2">
<button
onClick={() => setCreateDialogOpen(true)}
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors"
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors flex items-center gap-2"
>
Створити агента
<span></span> Create Agent
</button>
<button
onClick={refetch}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors flex items-center gap-2"
>
🔄 Оновити
<span>🔄</span> Refresh
</button>
</div>
</div>
{/* Search and filters */}
<div className="flex flex-col sm:flex-row gap-4">
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
{/* Search */}
<div className="flex-1">
<div className="md:col-span-1">
<input
type="text"
placeholder="Пошук агентів..."
placeholder="Search agents..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="
w-full px-4 py-2 border border-gray-300 rounded-lg
focus:outline-none focus:ring-2 focus:ring-blue-500
"
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
{/* MicroDAO filter */}
<div className="sm:w-64">
{/* Node Filter */}
<div>
<select
value={selectedMicroDao || ''}
onChange={(e) => setSelectedMicroDao(e.target.value || undefined)}
className="
w-full px-4 py-2 border border-gray-300 rounded-lg
focus:outline-none focus:ring-2 focus:ring-blue-500
"
value={filters.node_id || ''}
onChange={(e) => handleFilterChange('node_id', e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="">Всі MicroDAO</option>
<option value="microdao:daarion">DAARION</option>
<option value="microdao:7">MicroDAO #7</option>
<option value="microdao:greenfood">GreenFood</option>
<option value="">All Nodes</option>
{nodes.map(node => (
<option key={node.node_id} value={node.node_id}>{node.name}</option>
))}
</select>
</div>
{/* MicroDAO Filter */}
<div>
<select
value={filters.microdao_id || ''}
onChange={(e) => handleFilterChange('microdao_id', e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="">All MicroDAOs</option>
{microdaos.map(dao => (
<option key={dao.id} value={dao.id}>{dao.name}</option>
))}
</select>
</div>
{/* Kind Filter */}
<div>
<select
value={filters.kind || ''}
onChange={(e) => handleFilterChange('kind', e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="">All Kinds</option>
<option value="assistant">Assistant</option>
<option value="node_guardian">Node Guardian</option>
<option value="node_steward">Node Steward</option>
<option value="system">System</option>
</select>
</div>
</div>
@@ -100,21 +142,21 @@ export function AgentHubPage() {
<div className="text-2xl font-bold text-blue-900">
{filteredAgents.length}
</div>
<div className="text-sm text-blue-600">Знайдено агентів</div>
<div className="text-sm text-blue-600">Total Agents</div>
</div>
<div className="bg-green-50 rounded-lg p-4">
<div className="text-2xl font-bold text-green-900">
{filteredAgents.filter(a => a.status === 'active').length}
{filteredAgents.filter(a => a.status === 'online' || a.status === 'active').length}
</div>
<div className="text-sm text-green-600">Активних</div>
<div className="text-sm text-green-600">Online / Active</div>
</div>
<div className="bg-purple-50 rounded-lg p-4">
<div className="text-2xl font-bold text-purple-900">
{actor?.microdao_ids.length || 0}
{filteredAgents.filter(a => a.is_public).length}
</div>
<div className="text-sm text-purple-600">Ваших MicroDAO</div>
<div className="text-sm text-purple-600">Public Citizens</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,167 @@
import { useState, useEffect, useRef } from 'react';
import {
getCityRoom,
sendCityRoomMessage,
joinCityRoom,
leaveCityRoom,
type CityRoomDetail,
type CityRoomMessage
} from '@/api/cityRooms';
import { WebSocketClient } from '@/lib/ws';
interface AgentChatWidgetProps {
roomId: string;
roomName?: string;
}
export function AgentChatWidget({ roomId, roomName }: AgentChatWidgetProps) {
const [room, setRoom] = useState<CityRoomDetail | null>(null);
const [messages, setMessages] = useState<CityRoomMessage[]>([]);
const [inputText, setInputText] = useState('');
const [isLoading, setIsLoading] = useState(true);
const messagesEndRef = useRef<HTMLDivElement>(null);
const wsRef = useRef<WebSocketClient | null>(null);
useEffect(() => {
if (!roomId) return;
loadRoom();
joinRoom();
// WebSocket connection
const wsUrl = `${import.meta.env.VITE_WS_URL || 'ws://localhost:8000'}/ws/city/rooms/${roomId}`;
const ws = new WebSocketClient({ url: wsUrl });
ws.onMessage((data: any) => {
if (data.event === 'city.room.message') {
setMessages(prev => [...prev, data.message]);
}
});
ws.connect();
wsRef.current = ws;
return () => {
leaveRoom();
ws.disconnect();
};
}, [roomId]);
useEffect(() => {
scrollToBottom();
}, [messages]);
const loadRoom = async () => {
setIsLoading(true);
try {
const data = await getCityRoom(roomId);
setRoom(data);
setMessages(data.messages);
} catch (error) {
console.error('Failed to load room:', error);
} finally {
setIsLoading(false);
}
};
const joinRoom = async () => {
try {
await joinCityRoom(roomId);
} catch (error) {
console.error('Failed to join room:', error);
}
};
const leaveRoom = async () => {
try {
await leaveCityRoom(roomId);
} catch (error) {
console.error('Failed to leave room:', error);
}
};
const handleSendMessage = async () => {
if (!inputText.trim()) return;
try {
await sendCityRoomMessage(roomId, { text: inputText });
setInputText('');
} catch (error) {
console.error('Failed to send message:', error);
}
};
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSendMessage();
}
};
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
};
if (isLoading) {
return <div className="text-center py-8 text-gray-500">Connecting to chat...</div>;
}
return (
<div className="flex flex-col h-[500px] bg-white border border-gray-200 rounded-lg shadow-sm">
{/* Header */}
<div className="px-4 py-3 border-b bg-gray-50 rounded-t-lg flex justify-between items-center">
<h3 className="font-semibold text-gray-900">{room?.name || roomName || 'Agent Chat'}</h3>
<div className="text-xs text-gray-500 flex items-center gap-1">
<span className="w-2 h-2 bg-green-500 rounded-full"></span>
Live
</div>
</div>
{/* Messages */}
<div className="flex-1 overflow-y-auto p-4 space-y-3 bg-white">
{messages.length === 0 ? (
<div className="text-center text-gray-400 text-sm py-8">
No messages yet. Start the conversation!
</div>
) : (
messages.map((message) => (
<div key={message.id} className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<span className="font-medium text-sm text-gray-900">{message.username}</span>
<span className="text-xs text-gray-400">
{new Date(message.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</span>
</div>
<div className="bg-gray-50 p-2 rounded-lg text-gray-800 text-sm">
{message.text}
</div>
</div>
))
)}
<div ref={messagesEndRef} />
</div>
{/* Input */}
<div className="p-3 border-t bg-gray-50 rounded-b-lg">
<div className="flex gap-2">
<input
type="text"
value={inputText}
onChange={(e) => setInputText(e.target.value)}
onKeyPress={handleKeyPress}
placeholder="Type a message..."
className="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-1 focus:ring-blue-500 text-sm"
/>
<button
onClick={handleSendMessage}
disabled={!inputText.trim()}
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:bg-blue-300 text-sm font-medium"
>
Send
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,286 @@
import { useState } from 'react';
import { createMicroDaoFromAgent } from '@/api/microdaoWizard';
interface MicroDaoWizardProps {
isOpen: boolean;
onClose: () => void;
agentId: string;
onSuccess: () => void;
}
export function MicroDaoWizard({ isOpen, onClose, agentId, onSuccess }: MicroDaoWizardProps) {
const [step, setStep] = useState(1);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [formData, setFormData] = useState({
name: '',
slug: '',
description: '',
visibility: 'public' as 'public' | 'confidential',
district: '',
create_rooms: {
primary_lobby: true,
governance: true,
crew_team: false
}
});
if (!isOpen) return null;
const handleNext = () => setStep(s => s + 1);
const handleBack = () => setStep(s => s - 1);
const handleSubmit = async () => {
setLoading(true);
setError(null);
try {
await createMicroDaoFromAgent({
agent_id: agentId,
...formData
});
onSuccess();
onClose();
} catch (err: any) {
console.error(err);
setError(err.message || 'Failed to create MicroDAO');
} finally {
setLoading(false);
}
};
// Auto-generate slug from name
const handleNameChange = (name: string) => {
const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
setFormData(prev => ({ ...prev, name, slug: prev.slug ? prev.slug : slug }));
};
return (
<div className="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4">
<div className="bg-white rounded-xl max-w-lg w-full shadow-2xl overflow-hidden flex flex-col max-h-[90vh]">
{/* Header */}
<div className="px-6 py-4 border-b border-gray-100 flex justify-between items-center bg-gray-50">
<h2 className="text-lg font-bold text-gray-900">Create MicroDAO</h2>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600"></button>
</div>
{/* Body */}
<div className="p-6 overflow-y-auto flex-1">
{/* Steps Progress */}
<div className="flex items-center mb-8 text-sm">
{[1, 2, 3].map(i => (
<div key={i} className="flex items-center">
<div className={`w-8 h-8 rounded-full flex items-center justify-center font-bold ${
step === i ? 'bg-blue-600 text-white' :
step > i ? 'bg-green-500 text-white' : 'bg-gray-200 text-gray-500'
}`}>
{step > i ? '✓' : i}
</div>
{i < 3 && <div className={`w-12 h-1 ${step > i ? 'bg-green-500' : 'bg-gray-200'} mx-2`} />}
</div>
))}
</div>
{error && (
<div className="mb-4 p-3 bg-red-50 text-red-700 text-sm rounded-lg border border-red-200">
{error}
</div>
)}
{/* Step 1: Basic Info */}
{step === 1 && (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">MicroDAO Name</label>
<input
type="text"
value={formData.name}
onChange={(e) => handleNameChange(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none"
placeholder="e.g. Solar Punks"
autoFocus
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Unique Slug</label>
<div className="flex">
<span className="inline-flex items-center px-3 rounded-l-lg border border-r-0 border-gray-300 bg-gray-50 text-gray-500 text-sm">
/microdao/
</span>
<input
type="text"
value={formData.slug}
onChange={(e) => setFormData({...formData, slug: e.target.value})}
className="flex-1 min-w-0 px-3 py-2 border border-gray-300 rounded-r-lg focus:ring-2 focus:ring-blue-500 focus:outline-none"
placeholder="solar-punks"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Description</label>
<textarea
value={formData.description}
onChange={(e) => setFormData({...formData, description: e.target.value})}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none"
rows={3}
placeholder="What is this community about?"
/>
</div>
</div>
)}
{/* Step 2: Visibility */}
{step === 2 && (
<div className="space-y-6">
<div>
<label className="block text-sm font-medium text-gray-700 mb-3">Visibility</label>
<div className="grid grid-cols-2 gap-4">
<button
onClick={() => setFormData({...formData, visibility: 'public'})}
className={`p-4 border rounded-xl text-left transition-all ${
formData.visibility === 'public'
? 'border-blue-500 bg-blue-50 ring-1 ring-blue-500'
: 'border-gray-200 hover:border-gray-300'
}`}
>
<div className="font-bold text-gray-900 mb-1">Public</div>
<div className="text-xs text-gray-500">Visible to everyone in the City Directory.</div>
</button>
<button
onClick={() => setFormData({...formData, visibility: 'confidential'})}
className={`p-4 border rounded-xl text-left transition-all ${
formData.visibility === 'confidential'
? 'border-purple-500 bg-purple-50 ring-1 ring-purple-500'
: 'border-gray-200 hover:border-gray-300'
}`}
>
<div className="font-bold text-gray-900 mb-1">Confidential</div>
<div className="text-xs text-gray-500">Hidden from directory. Invite only.</div>
</button>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">District / Platform (Optional)</label>
<select
value={formData.district}
onChange={(e) => setFormData({...formData, district: e.target.value})}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none bg-white"
>
<option value="">None (Independent)</option>
<option value="core">Core Infrastructure</option>
<option value="green">Green Economy</option>
<option value="tech">Tech Labs</option>
</select>
<p className="mt-1 text-xs text-gray-500">Assigning a district may require approval.</p>
</div>
</div>
)}
{/* Step 3: Rooms */}
{step === 3 && (
<div className="space-y-4">
<div className="bg-blue-50 p-4 rounded-lg border border-blue-100">
<h3 className="font-bold text-blue-900 mb-2">Communication Channels</h3>
<p className="text-sm text-blue-800 mb-4">
Select which rooms to create automatically. These will be Matrix-powered chat rooms.
</p>
<div className="space-y-3 bg-white p-3 rounded border border-blue-100">
<label className="flex items-center gap-3 p-2 hover:bg-gray-50 rounded cursor-pointer">
<input
type="checkbox"
checked={formData.create_rooms.primary_lobby}
onChange={(e) => setFormData({
...formData,
create_rooms: {...formData.create_rooms, primary_lobby: e.target.checked}
})}
className="w-5 h-5 text-blue-600 rounded focus:ring-blue-500"
/>
<div>
<div className="font-medium text-gray-900">Primary Lobby</div>
<div className="text-xs text-gray-500">Public general chat for the community</div>
</div>
</label>
<label className="flex items-center gap-3 p-2 hover:bg-gray-50 rounded cursor-pointer">
<input
type="checkbox"
checked={formData.create_rooms.governance}
onChange={(e) => setFormData({
...formData,
create_rooms: {...formData.create_rooms, governance: e.target.checked}
})}
className="w-5 h-5 text-blue-600 rounded focus:ring-blue-500"
/>
<div>
<div className="font-medium text-gray-900">Governance Hall</div>
<div className="text-xs text-gray-500">For voting and formal proposals</div>
</div>
</label>
<label className="flex items-center gap-3 p-2 hover:bg-gray-50 rounded cursor-pointer">
<input
type="checkbox"
checked={formData.create_rooms.crew_team}
onChange={(e) => setFormData({
...formData,
create_rooms: {...formData.create_rooms, crew_team: e.target.checked}
})}
className="w-5 h-5 text-blue-600 rounded focus:ring-blue-500"
/>
<div>
<div className="font-medium text-gray-900">Crew Team (Internal)</div>
<div className="text-xs text-gray-500">Private workspace for agents and core team</div>
</div>
</label>
</div>
</div>
</div>
)}
</div>
{/* Footer */}
<div className="px-6 py-4 border-t border-gray-100 bg-gray-50 flex justify-between">
{step > 1 ? (
<button
onClick={handleBack}
className="px-4 py-2 text-gray-600 font-medium hover:text-gray-900"
>
Back
</button>
) : (
<div />
)}
{step < 3 ? (
<button
onClick={handleNext}
disabled={!formData.name || !formData.slug}
className="px-6 py-2 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 disabled:bg-gray-300 disabled:cursor-not-allowed transition-colors"
>
Next Step
</button>
) : (
<button
onClick={handleSubmit}
disabled={loading}
className="px-6 py-2 bg-green-600 text-white rounded-lg font-medium hover:bg-green-700 disabled:bg-gray-300 disabled:cursor-not-allowed transition-colors flex items-center gap-2"
>
{loading ? (
<>
<span className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
Creating...
</>
) : (
'Create MicroDAO'
)}
</button>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,151 @@
import { useState } from 'react';
import { updateAgentVisibility } from '@/api/agents';
import type { AgentDetailDashboard } from '@/types/agent-cabinet';
interface VisibilityCardProps {
agentId: string;
publicProfile: AgentDetailDashboard['public_profile'];
onUpdate: () => void;
}
export function VisibilityCard({ agentId, publicProfile, onUpdate }: VisibilityCardProps) {
const [isEditing, setIsEditing] = useState(false);
const [formData, setFormData] = useState({
is_public: publicProfile?.is_public || false,
public_slug: publicProfile?.public_slug || '',
public_title: publicProfile?.public_title || '',
public_tagline: publicProfile?.public_tagline || ''
});
const [saving, setSaving] = useState(false);
const handleSave = async () => {
setSaving(true);
try {
await updateAgentVisibility(agentId, {
is_public: formData.is_public,
public_slug: formData.public_slug || null,
public_title: formData.public_title || null,
public_tagline: formData.public_tagline || null
});
setIsEditing(false);
onUpdate();
} catch (error) {
console.error('Failed to update visibility:', error);
alert('Failed to update visibility');
} finally {
setSaving(false);
}
};
const previewUrl = formData.public_slug ? `/citizens/${formData.public_slug}` : '#';
return (
<div className="bg-white border border-gray-200 rounded-lg p-6 shadow-sm">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-semibold text-gray-900">🌐 Visibility & Public Profile</h3>
<button
onClick={() => isEditing ? handleSave() : setIsEditing(true)}
disabled={saving}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
isEditing
? 'bg-blue-600 text-white hover:bg-blue-700 disabled:bg-blue-400'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
{saving ? 'Saving...' : isEditing ? 'Save Changes' : 'Edit Settings'}
</button>
</div>
<div className="space-y-6">
{/* Toggle Public */}
<div className="flex items-center justify-between p-4 bg-gray-50 rounded-lg">
<div>
<div className="font-medium text-gray-900">Public Citizen Profile</div>
<div className="text-sm text-gray-500">Make this agent visible in the City Directory</div>
</div>
<div className="relative inline-block w-12 h-6 transition duration-200 ease-in-out">
<input
type="checkbox"
id="toggle-public"
className="peer absolute w-12 h-6 opacity-0 z-10 cursor-pointer"
checked={formData.is_public}
disabled={!isEditing}
onChange={(e) => setFormData({ ...formData, is_public: e.target.checked })}
/>
<label
htmlFor="toggle-public"
className={`block h-6 rounded-full cursor-pointer transition-colors ${
formData.is_public ? 'bg-green-500' : 'bg-gray-300'
}`}
></label>
<div
className={`absolute left-1 top-1 bg-white w-4 h-4 rounded-full transition-transform ${
formData.is_public ? 'translate-x-6' : 'translate-x-0'
}`}
></div>
</div>
</div>
{/* Fields */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Public Slug</label>
<div className="flex">
<span className="inline-flex items-center px-3 rounded-l-md border border-r-0 border-gray-300 bg-gray-50 text-gray-500 text-sm">
/citizens/
</span>
<input
type="text"
value={formData.public_slug}
onChange={(e) => setFormData({ ...formData, public_slug: e.target.value })}
disabled={!isEditing}
placeholder="agent-name"
className="flex-1 min-w-0 block w-full px-3 py-2 rounded-none rounded-r-md border border-gray-300 focus:ring-blue-500 focus:border-blue-500 sm:text-sm disabled:bg-gray-50"
/>
</div>
<p className="mt-1 text-xs text-gray-500">Unique identifier for public URL</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Public Title</label>
<input
type="text"
value={formData.public_title}
onChange={(e) => setFormData({ ...formData, public_title: e.target.value })}
disabled={!isEditing}
placeholder="e.g. City Guide"
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm disabled:bg-gray-50"
/>
</div>
<div className="md:col-span-2">
<label className="block text-sm font-medium text-gray-700 mb-1">Public Tagline</label>
<input
type="text"
value={formData.public_tagline}
onChange={(e) => setFormData({ ...formData, public_tagline: e.target.value })}
disabled={!isEditing}
placeholder="Short description for the card..."
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm disabled:bg-gray-50"
/>
</div>
</div>
{/* Preview Link */}
{formData.is_public && formData.public_slug && (
<div className="pt-4 border-t border-gray-100 flex justify-end">
<a
href={previewUrl}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:text-blue-800 text-sm font-medium flex items-center gap-1"
>
View Public Profile
</a>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,31 @@
import { useState, useEffect, useCallback } from 'react';
import { getAgentDashboard } from '@/api/agents';
import { AgentDetailDashboard } from '@/types/agent-cabinet';
export function useAgentDashboard(agentId: string) {
const [dashboard, setDashboard] = useState<AgentDetailDashboard | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchDashboard = useCallback(async () => {
if (!agentId) return;
setLoading(true);
try {
const data = await getAgentDashboard(agentId);
setDashboard(data);
setError(null);
} catch (err) {
setError('Failed to load agent dashboard');
console.error(err);
} finally {
setLoading(false);
}
}, [agentId]);
useEffect(() => {
fetchDashboard();
}, [fetchDashboard]);
return { dashboard, loading, error, refetch: fetchDashboard };
}

View File

@@ -0,0 +1,30 @@
import { useState, useEffect, useCallback } from 'react';
import { getAgentList } from '@/api/agents';
import { AgentSummary } from '@/types/agent-cabinet';
export function useAgentsV2(filters: Record<string, any> = {}) {
const [agents, setAgents] = useState<AgentSummary[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchAgents = useCallback(async () => {
setLoading(true);
try {
const data = await getAgentList(filters);
setAgents(data.items);
setError(null);
} catch (err) {
setError('Failed to load agents');
console.error(err);
} finally {
setLoading(false);
}
}, [JSON.stringify(filters)]);
useEffect(() => {
fetchAgents();
}, [fetchAgents]);
return { agents, loading, error, refetch: fetchAgents };
}

View File

@@ -13,6 +13,7 @@ import {
type CityRoomMessage
} from '../../../api/cityRooms';
import { WebSocketClient } from '../../../lib/ws';
import { RoomBrandHeader } from '../../microdao/components/RoomBrandHeader';
export function CityRoomView() {
const { roomId } = useParams<{ roomId: string }>();
@@ -124,30 +125,22 @@ export function CityRoomView() {
return (
<div className="flex flex-col h-screen">
{/* Header */}
<div className="bg-white border-b px-6 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<button
onClick={() => navigate('/city/rooms')}
className="text-gray-600 hover:text-gray-900"
>
Назад
</button>
<div>
<h1 className="text-2xl font-bold">{room.name}</h1>
{room.description && (
<p className="text-sm text-gray-600">{room.description}</p>
)}
</div>
</div>
<div className="flex items-center gap-2">
<span className="flex items-center gap-1 text-sm bg-green-100 text-green-800 px-3 py-1 rounded-full">
<span className="w-2 h-2 bg-green-500 rounded-full animate-pulse"></span>
{room.members_online} онлайн
</span>
</div>
</div>
</div>
<RoomBrandHeader
name={room.name}
description={room.description}
bannerUrl={room.banner_url}
logoUrl={room.logo_url}
microdaoLogoUrl={room.microdao_logo_url}
microdaoName={room.microdao_name}
membersCount={room.members_online}
>
<button
onClick={() => navigate('/city/rooms')}
className="text-white/80 hover:text-white flex items-center gap-2 bg-black/20 px-3 py-1.5 rounded-lg backdrop-blur-sm hover:bg-black/30 transition-all"
>
Назад
</button>
</RoomBrandHeader>
{/* Messages */}
<div className="flex-1 overflow-y-auto p-6 bg-gray-50">

View File

@@ -4,10 +4,12 @@
*/
import { useState, useEffect } from 'react';
import { useParams, useNavigate, Link } from 'react-router-dom';
import { getMicrodao, getMembers, getTreasury, type MicrodaoRead, type MicrodaoMember, type TreasuryItem } from '@/api/microdao';
import { getMicrodao, getMembers, getTreasury, uploadAsset, updateMicrodaoBranding, type MicrodaoRead, type MicrodaoMember, type TreasuryItem } from '@/api/microdao';
import { getAgents, type AgentListItem } from '@/api/agents';
import { MicrodaoHero } from './components/MicrodaoHero';
import { MicrodaoBrandBadge } from './components/MicrodaoBrandBadge';
type TabType = 'overview' | 'members' | 'agents' | 'treasury';
type TabType = 'overview' | 'members' | 'agents' | 'treasury' | 'settings';
export function MicrodaoConsolePage() {
const { slug } = useParams<{ slug: string }>();
@@ -62,6 +64,26 @@ export function MicrodaoConsolePage() {
}
};
const handleUpload = async (file: File | undefined, type: string) => {
if (!file || !microdao) return;
try {
// Optimistic UI update could be done here, but better wait for server
const { processed_url } = await uploadAsset(file, type);
// Update branding
const updated = await updateMicrodaoBranding(
microdao.slug,
type === 'microdao_logo' ? processed_url : undefined,
type === 'microdao_banner' ? processed_url : undefined
);
setMicrodao(prev => prev ? { ...prev, ...updated } : null);
} catch (err) {
console.error('Upload failed:', err);
alert('Upload failed');
}
};
if (loading) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
@@ -95,81 +117,82 @@ export function MicrodaoConsolePage() {
return (
<div className="min-h-screen bg-gray-50">
{/* Header */}
{/* Hero Header */}
<MicrodaoHero
name={microdao.name}
tagline={microdao.description}
logoUrl={microdao.logo_url}
bannerUrl={microdao.banner_url}
>
<button
onClick={() => navigate('/microdao')}
className="px-4 py-2 bg-white/10 backdrop-blur-md border border-white/20 text-white rounded-lg hover:bg-white/20 transition-colors text-sm"
>
Список
</button>
<Link
to={`/dao/${microdao.slug}-governance`}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors flex items-center gap-2 shadow-md text-sm font-medium"
>
<span>🗳</span>
<span>Governance</span>
</Link>
</MicrodaoHero>
{/* Tabs */}
<div className="bg-white border-b border-gray-200">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<button
onClick={() => navigate('/microdao')}
className="text-blue-600 hover:text-blue-700 mb-4 flex items-center gap-2"
>
Назад до списку
</button>
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-gray-900">
{microdao.name}
</h1>
{microdao.description && (
<p className="text-gray-600 mt-1">{microdao.description}</p>
)}
<div className="mt-2 text-sm text-gray-500">
<span className="font-mono">{microdao.slug}</span> · {microdao.external_id}
</div>
</div>
<div>
<Link
to={`/dao/${microdao.slug}-governance`}
className="px-6 py-3 bg-gradient-to-r from-blue-600 to-purple-600 text-white rounded-lg hover:from-blue-700 hover:to-purple-700 transition flex items-center gap-2 shadow-md"
>
<span className="text-xl">🗳</span>
<span className="font-semibold">DAO Governance</span>
</Link>
</div>
</div>
{/* Tabs */}
<div className="flex gap-1 border-b border-gray-200 mt-6">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex gap-1 -mb-px pt-2">
<button
onClick={() => setActiveTab('overview')}
className={`px-6 py-3 font-medium transition-colors ${
className={`px-6 py-3 font-medium transition-colors border-b-2 ${
activeTab === 'overview'
? 'text-blue-600 border-b-2 border-blue-600'
: 'text-gray-600 hover:text-gray-900'
? 'text-blue-600 border-blue-600'
: 'text-gray-600 hover:text-gray-900 border-transparent'
}`}
>
📊 Огляд
</button>
<button
onClick={() => setActiveTab('members')}
className={`px-6 py-3 font-medium transition-colors ${
className={`px-6 py-3 font-medium transition-colors border-b-2 ${
activeTab === 'members'
? 'text-blue-600 border-b-2 border-blue-600'
: 'text-gray-600 hover:text-gray-900'
? 'text-blue-600 border-blue-600'
: 'text-gray-600 hover:text-gray-900 border-transparent'
}`}
>
👥 Учасники ({members.length})
</button>
<button
onClick={() => setActiveTab('agents')}
className={`px-6 py-3 font-medium transition-colors ${
className={`px-6 py-3 font-medium transition-colors border-b-2 ${
activeTab === 'agents'
? 'text-blue-600 border-b-2 border-blue-600'
: 'text-gray-600 hover:text-gray-900'
? 'text-blue-600 border-blue-600'
: 'text-gray-600 hover:text-gray-900 border-transparent'
}`}
>
🤖 Агенти ({agents.length})
</button>
<button
onClick={() => setActiveTab('treasury')}
className={`px-6 py-3 font-medium transition-colors ${
className={`px-6 py-3 font-medium transition-colors border-b-2 ${
activeTab === 'treasury'
? 'text-blue-600 border-b-2 border-blue-600'
: 'text-gray-600 hover:text-gray-900'
? 'text-blue-600 border-blue-600'
: 'text-gray-600 hover:text-gray-900 border-transparent'
}`}
>
💰 Казна
</button>
<button
onClick={() => setActiveTab('settings')}
className={`px-6 py-3 font-medium transition-colors border-b-2 ${
activeTab === 'settings'
? 'text-blue-600 border-blue-600'
: 'text-gray-600 hover:text-gray-900 border-transparent'
}`}
>
Налаштування
</button>
</div>
</div>
</div>
@@ -298,6 +321,52 @@ export function MicrodaoConsolePage() {
</div>
</div>
)}
{/* Settings Tab */}
{activeTab === 'settings' && microdao && (
<div className="bg-white border border-gray-200 rounded-lg p-6 space-y-8">
<div>
<h3 className="text-lg font-semibold mb-6">Брендинг</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
{/* Logo Upload */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Логотип (256x256)</label>
<div className="flex items-center gap-6">
<MicrodaoBrandBadge name={microdao.name} logoUrl={microdao.logo_url} size="xl" />
<div className="flex-1">
<input
type="file"
accept="image/*"
onChange={(e) => handleUpload(e.target.files?.[0], 'microdao_logo')}
className="block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100 cursor-pointer"
/>
<p className="text-xs text-gray-500 mt-2">PNG, JPG, SVG. Макс 5MB.</p>
</div>
</div>
</div>
{/* Banner Upload */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Банер</label>
<div className="relative w-full h-32 bg-gray-100 rounded-lg overflow-hidden mb-3 border border-gray-200 group">
{microdao.banner_url ? (
<img src={microdao.banner_url} className="w-full h-full object-cover" alt="Banner" />
) : (
<div className="flex items-center justify-center h-full text-gray-400">Немає банера</div>
)}
</div>
<input
type="file"
accept="image/*"
onChange={(e) => handleUpload(e.target.files?.[0], 'microdao_banner')}
className="block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100 cursor-pointer"
/>
</div>
</div>
</div>
</div>
)}
</div>
</div>
);

View File

@@ -6,6 +6,7 @@ import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useMicrodaos } from './hooks/useMicrodaos';
import { createMicrodao, type MicrodaoCreate } from '@/api/microdao';
import { MicrodaoBrandBadge } from './components/MicrodaoBrandBadge';
export function MicrodaoListPage() {
const navigate = useNavigate();
@@ -120,24 +121,42 @@ export function MicrodaoListPage() {
<div
key={dao.id}
onClick={() => navigate(`/microdao/${dao.slug}`)}
className="bg-white border border-gray-200 rounded-lg p-6 hover:shadow-lg hover:border-blue-500 transition-all cursor-pointer"
className="bg-white border border-gray-200 rounded-lg overflow-hidden hover:shadow-lg hover:border-blue-500 transition-all cursor-pointer group relative"
>
<h3 className="text-xl font-semibold text-gray-900 mb-2">
{dao.name}
</h3>
{dao.description && (
<p className="text-sm text-gray-600 mb-4 line-clamp-2">
{dao.description}
</p>
{/* Banner Background */}
{dao.banner_url && (
<div
className="absolute inset-0 h-32 bg-cover bg-center opacity-10 group-hover:opacity-20 transition-opacity"
style={{ backgroundImage: `url(${dao.banner_url})` }}
/>
)}
<div className="flex items-center gap-4 text-sm text-gray-500">
<div>
👥 {dao.member_count || 0} учасників
<div className="p-6 relative z-10">
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-3">
<MicrodaoBrandBadge name={dao.name} logoUrl={dao.logo_url} size="md" />
<div>
<h3 className="text-xl font-semibold text-gray-900">
{dao.name}
</h3>
<p className="text-xs text-gray-500">@{dao.slug}</p>
</div>
</div>
</div>
<div>
🤖 {dao.agent_count || 0} агентів
{dao.description && (
<p className="text-sm text-gray-600 mb-4 line-clamp-2">
{dao.description}
</p>
)}
<div className="flex items-center gap-4 text-sm text-gray-500 mt-auto">
<div className="flex items-center gap-1">
<span>👥</span> {dao.member_count || 0}
</div>
<div className="flex items-center gap-1">
<span>🤖</span> {dao.agent_count || 0}
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,47 @@
import React from 'react';
import { Building2 } from 'lucide-react';
interface MicrodaoBrandBadgeProps {
logoUrl?: string | null;
name: string;
size?: 'sm' | 'md' | 'lg' | 'xl';
className?: string;
}
export const MicrodaoBrandBadge: React.FC<MicrodaoBrandBadgeProps> = ({
logoUrl,
name,
size = 'md',
className = ''
}) => {
const sizeClasses = {
sm: 'w-8 h-8',
md: 'w-12 h-12',
lg: 'w-16 h-16',
xl: 'w-24 h-24'
};
const iconSizes = {
sm: 16,
md: 24,
lg: 32,
xl: 48
};
if (logoUrl) {
return (
<img
src={logoUrl}
alt={`${name} logo`}
className={`rounded-full object-cover bg-gray-100 shadow-sm border border-gray-200/50 ${sizeClasses[size]} ${className}`}
/>
);
}
return (
<div className={`rounded-full bg-gradient-to-br from-blue-500/10 to-purple-500/10 flex items-center justify-center border border-gray-200 shadow-sm ${sizeClasses[size]} ${className}`}>
<Building2 size={iconSizes[size]} className="text-gray-400" />
</div>
);
};

View File

@@ -0,0 +1,71 @@
import React from 'react';
import { MicrodaoBrandBadge } from './MicrodaoBrandBadge';
interface MicrodaoHeroProps {
bannerUrl?: string | null;
logoUrl?: string | null;
name: string;
tagline?: string | null;
children?: React.ReactNode; // For action buttons etc.
}
export const MicrodaoHero: React.FC<MicrodaoHeroProps> = ({
bannerUrl,
logoUrl,
name,
tagline,
children
}) => {
return (
<div className="relative w-full h-48 md:h-64 lg:h-80 bg-gray-900 overflow-hidden group">
{/* Background / Banner */}
{bannerUrl ? (
<div
className="absolute inset-0 w-full h-full bg-cover bg-center transition-transform duration-700 group-hover:scale-105"
style={{ backgroundImage: `url(${bannerUrl})` }}
/>
) : (
<div className="absolute inset-0 w-full h-full bg-gradient-to-r from-slate-900 via-purple-900 to-slate-900" />
)}
{/* Overlay */}
<div className="absolute inset-0 bg-black/40 backdrop-blur-[2px]" />
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-transparent to-transparent" />
{/* Content */}
<div className="absolute bottom-0 left-0 w-full p-6 md:p-8">
<div className="container mx-auto flex flex-col md:flex-row items-end md:items-end gap-6">
{/* Logo */}
<div className="relative -mb-2 md:mb-0 shrink-0">
<MicrodaoBrandBadge
logoUrl={logoUrl}
name={name}
size="xl"
className="ring-4 ring-black/20 shadow-2xl"
/>
</div>
{/* Text */}
<div className="flex-1 mb-1">
<h1 className="text-3xl md:text-4xl font-bold text-white drop-shadow-lg tracking-tight">
{name}
</h1>
{tagline && (
<p className="text-gray-200 text-lg mt-1 max-w-2xl drop-shadow-md font-light">
{tagline}
</p>
)}
</div>
{/* Actions */}
{children && (
<div className="flex items-center gap-3 mt-4 md:mt-0">
{children}
</div>
)}
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,90 @@
import React from 'react';
import { MicrodaoBrandBadge } from './MicrodaoBrandBadge';
import { Hash, Users, Info } from 'lucide-react';
interface RoomBrandHeaderProps {
bannerUrl?: string | null;
logoUrl?: string | null; // Room logo
microdaoLogoUrl?: string | null; // Fallback to MicroDAO logo
name: string;
description?: string | null;
microdaoName?: string;
membersCount?: number;
children?: React.ReactNode;
}
export const RoomBrandHeader: React.FC<RoomBrandHeaderProps> = ({
bannerUrl,
logoUrl,
microdaoLogoUrl,
name,
description,
microdaoName,
membersCount,
children
}) => {
// Use room logo if available, else MicroDAO logo
const displayLogo = logoUrl || microdaoLogoUrl;
return (
<div className="relative w-full h-32 md:h-40 bg-gray-800 overflow-hidden rounded-t-xl shrink-0">
{/* Background / Banner */}
{bannerUrl ? (
<div
className="absolute inset-0 w-full h-full bg-cover bg-center"
style={{ backgroundImage: `url(${bannerUrl})` }}
/>
) : (
<div className="absolute inset-0 w-full h-full bg-gradient-to-r from-gray-800 via-gray-700 to-gray-800" />
)}
{/* Overlay */}
<div className="absolute inset-0 bg-black/30" />
<div className="absolute inset-0 bg-gradient-to-t from-black/70 to-transparent" />
{/* Top Actions (Children) */}
{children && (
<div className="absolute top-0 left-0 w-full p-4 z-10">
{children}
</div>
)}
{/* Content */}
<div className="absolute bottom-0 left-0 w-full p-4 md:p-6 flex items-end gap-4">
<div className="shrink-0">
<MicrodaoBrandBadge
logoUrl={displayLogo}
name={name}
size="lg"
className="ring-2 ring-black/10 shadow-lg bg-white"
/>
</div>
<div className="flex-1 min-w-0 mb-0.5">
<div className="flex items-center gap-2 text-gray-300 text-xs uppercase tracking-wider font-medium mb-0.5">
{microdaoName && <span>{microdaoName}</span>}
{membersCount !== undefined && (
<>
<span></span>
<span className="flex items-center gap-1">
<Users size={10} />
{membersCount} online
</span>
</>
)}
</div>
<h2 className="text-xl md:text-2xl font-bold text-white flex items-center gap-2 truncate">
<Hash className="text-gray-400 w-5 h-5 md:w-6 md:h-6 shrink-0" />
{name}
</h2>
{description && (
<p className="text-gray-300 text-sm truncate max-w-xl opacity-90">
{description}
</p>
)}
</div>
</div>
</div>
);
};

View File

@@ -1,114 +1,34 @@
/**
* Connect Node Page - Спрощений UI для підключення ноди
* Для звичайних користувачів (без термінала)
* Connect Node Page - Інструкції з підключення ноди
*/
import React, { useState } from 'react';
import { Download, Copy, CheckCircle, Monitor, Cpu, HardDrive } from 'lucide-react';
import React, { useState, useEffect } from 'react';
import { Download, Copy, CheckCircle, Monitor, Cpu, HardDrive, Terminal, ExternalLink, AlertCircle } from 'lucide-react';
import { Link } from 'react-router-dom';
import ReactMarkdown from 'react-markdown';
import { apiGet } from '../api/client';
export default function ConnectNodePage() {
const [copied, setCopied] = useState(false);
const [selectedOS, setSelectedOS] = useState<'macos' | 'linux' | 'windows'>('macos');
const [instructions, setInstructions] = useState<string>('');
const [loading, setLoading] = useState(true);
const registryUrl = 'http://localhost:9205'; // TODO: змінити на production URL
useEffect(() => {
const fetchInstructions = async () => {
try {
const response = await apiGet<{ content: string }>('/public/nodes/join/instructions');
if (response.content) {
setInstructions(response.content);
}
} catch (error) {
console.error('Failed to fetch instructions:', error);
} finally {
setLoading(false);
}
};
// Інструкції для різних ОС
const instructions = {
macos: {
title: '🍎 macOS',
steps: [
{
title: '1. Завантажити Bootstrap Agent',
description: 'Скачайте скрипт автоматичної реєстрації',
action: 'download',
code: 'curl -O http://localhost:9205/bootstrap/node_bootstrap.py',
},
{
title: '2. Встановити залежності',
description: 'Встановіть необхідні Python бібліотеки',
code: 'pip3 install --user requests psutil',
},
{
title: '3. Запустити Bootstrap Agent',
description: 'Запустіть агент для автоматичної реєстрації',
code: `export NODE_REGISTRY_URL="${registryUrl}"
export NODE_ROLE="worker"
python3 node_bootstrap.py`,
},
],
},
linux: {
title: '🐧 Linux',
steps: [
{
title: '1. Завантажити Bootstrap Agent',
description: 'Скачайте скрипт автоматичної реєстрації',
code: 'curl -O http://localhost:9205/bootstrap/node_bootstrap.py',
},
{
title: '2. Встановити залежності',
description: 'Встановіть необхідні Python бібліотеки',
code: 'pip3 install requests psutil',
},
{
title: '3. Запустити Bootstrap Agent',
description: 'Запустіть агент для автоматичної реєстрації',
code: `export NODE_REGISTRY_URL="${registryUrl}"
export NODE_ROLE="worker"
python3 node_bootstrap.py`,
},
{
title: '4. (Опціонально) Додати як systemd service',
description: 'Для автоматичного запуску при перезавантаженні',
code: `sudo tee /etc/systemd/system/node-bootstrap.service << EOF
[Unit]
Description=DAGI Node Bootstrap
After=network.target
[Service]
Type=simple
Environment="NODE_REGISTRY_URL=${registryUrl}"
Environment="NODE_ROLE=worker"
ExecStart=/usr/bin/python3 /opt/dagi/node_bootstrap.py
Restart=always
[Install]
WantedBy=multi-user.target
EOF
sudo systemctl enable node-bootstrap
sudo systemctl start node-bootstrap`,
},
],
},
windows: {
title: '🪟 Windows',
steps: [
{
title: '1. Завантажити Bootstrap Agent',
description: 'Скачайте скрипт автоматичної реєстрації',
code: 'curl -O http://localhost:9205/bootstrap/node_bootstrap.py',
},
{
title: '2. Встановити Python',
description: 'Завантажте Python 3.9+ з python.org',
link: 'https://www.python.org/downloads/',
},
{
title: '3. Встановити залежності',
description: 'Відкрийте PowerShell та виконайте',
code: 'pip install requests psutil',
},
{
title: '4. Запустити Bootstrap Agent',
description: 'Запустіть агент для автоматичної реєстрації',
code: `$env:NODE_REGISTRY_URL="${registryUrl}"
$env:NODE_ROLE="worker"
python node_bootstrap.py`,
},
],
},
};
fetchInstructions();
}, []);
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text);
@@ -116,182 +36,161 @@ python node_bootstrap.py`,
setTimeout(() => setCopied(false), 2000);
};
const currentInstructions = instructions[selectedOS];
if (loading) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="animate-spin rounded-full h-12 w-12 border-4 border-blue-600 border-t-transparent"></div>
</div>
);
}
return (
<div className="min-h-screen bg-gradient-to-br from-slate-950 via-purple-950/20 to-slate-950 text-white p-6">
<div className="min-h-screen bg-gray-50 p-6">
<div className="max-w-5xl mx-auto">
{/* Header */}
<div className="mb-8">
<h1 className="text-4xl font-bold mb-2 bg-gradient-to-r from-purple-400 to-pink-400 bg-clip-text text-transparent">
🔌 Підключити Ноду до DAGI
<Link to="/nodes" className="text-blue-600 hover:underline mb-4 inline-block">&larr; Назад до списку нод</Link>
<h1 className="text-4xl font-bold mb-2 text-gray-900">
🔌 Підключити Ноду
</h1>
<p className="text-slate-400">
Простий спосіб підключити ваш комп'ютер до децентралізованої мережі AI
<p className="text-gray-600">
Інструкція з розгортання обчислювальної ноди DAARION
</p>
</div>
{/* Benefits */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-8">
<div className="bg-gradient-to-br from-purple-900/30 to-purple-950/30 border border-purple-800/30 rounded-xl p-6">
<div className="bg-white border border-blue-100 rounded-xl p-6 shadow-sm">
<div className="text-3xl mb-3">💰</div>
<h3 className="text-lg font-semibold mb-2">Заробляйте μGOV</h3>
<p className="text-slate-400 text-sm">
Отримуйте токени за надання обчислювальних ресурсів
<h3 className="text-lg font-semibold mb-2 text-gray-900">Заробляйте токени</h3>
<p className="text-gray-500 text-sm">
Отримуйте винагороду за надання обчислювальних ресурсів
</p>
</div>
<div className="bg-gradient-to-br from-blue-900/30 to-blue-950/30 border border-blue-800/30 rounded-xl p-6">
<div className="bg-white border border-purple-100 rounded-xl p-6 shadow-sm">
<div className="text-3xl mb-3">🤖</div>
<h3 className="text-lg font-semibold mb-2">Доступ до AI</h3>
<p className="text-slate-400 text-sm">
Використовуйте AI моделі мережі безкоштовно
<h3 className="text-lg font-semibold mb-2 text-gray-900">Доступ до AI</h3>
<p className="text-gray-500 text-sm">
Використовуйте AI моделі мережі безкоштовно для своїх агентів
</p>
</div>
<div className="bg-gradient-to-br from-green-900/30 to-green-950/30 border border-green-800/30 rounded-xl p-6">
<div className="bg-white border border-green-100 rounded-xl p-6 shadow-sm">
<div className="text-3xl mb-3">🌱</div>
<h3 className="text-lg font-semibold mb-2">Підтримайте спільноту</h3>
<p className="text-slate-400 text-sm">
Станьте частиною децентралізованої AI мережі
<h3 className="text-lg font-semibold mb-2 text-gray-900">Розвивайте мережу</h3>
<p className="text-gray-500 text-sm">
Станьте частиною децентралізованої інфраструктури
</p>
</div>
</div>
{/* System Requirements */}
<div className="bg-slate-900/50 backdrop-blur border border-slate-800 rounded-xl p-6 mb-8">
<h2 className="text-xl font-bold mb-4">📋 Системні вимоги</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="flex items-center gap-3">
<Cpu className="w-6 h-6 text-purple-400" />
<div>
<div className="text-slate-400 text-sm">CPU</div>
<div className="font-medium">4+ ядра</div>
</div>
</div>
<div className="flex items-center gap-3">
<Monitor className="w-6 h-6 text-blue-400" />
<div>
<div className="text-slate-400 text-sm">RAM</div>
<div className="font-medium">8+ GB</div>
</div>
</div>
<div className="flex items-center gap-3">
<HardDrive className="w-6 h-6 text-green-400" />
<div>
<div className="text-slate-400 text-sm">Disk</div>
<div className="font-medium">50+ GB вільно</div>
</div>
</div>
</div>
</div>
{/* OS Selector */}
<div className="mb-6">
<div className="flex gap-2">
<button
onClick={() => setSelectedOS('macos')}
className={`flex-1 py-3 px-4 rounded-lg font-medium transition-all ${
selectedOS === 'macos'
? 'bg-purple-600 text-white'
: 'bg-slate-800 text-slate-400 hover:bg-slate-700'
}`}
>
🍎 macOS
</button>
<button
onClick={() => setSelectedOS('linux')}
className={`flex-1 py-3 px-4 rounded-lg font-medium transition-all ${
selectedOS === 'linux'
? 'bg-purple-600 text-white'
: 'bg-slate-800 text-slate-400 hover:bg-slate-700'
}`}
>
🐧 Linux
</button>
<button
onClick={() => setSelectedOS('windows')}
className={`flex-1 py-3 px-4 rounded-lg font-medium transition-all ${
selectedOS === 'windows'
? 'bg-purple-600 text-white'
: 'bg-slate-800 text-slate-400 hover:bg-slate-700'
}`}
>
🪟 Windows
</button>
</div>
</div>
{/* Instructions */}
<div className="space-y-6">
<h2 className="text-2xl font-bold">{currentInstructions.title}</h2>
{currentInstructions.steps.map((step, index) => (
<div
key={index}
className="bg-slate-900/50 backdrop-blur border border-slate-800 rounded-xl p-6"
>
<h3 className="text-lg font-semibold mb-2">{step.title}</h3>
<p className="text-slate-400 text-sm mb-4">{step.description}</p>
{step.code && (
<div className="relative">
<pre className="bg-slate-950 border border-slate-700 rounded-lg p-4 overflow-x-auto text-sm">
<code className="text-green-400">{step.code}</code>
</pre>
<button
onClick={() => copyToClipboard(step.code)}
className="absolute top-2 right-2 p-2 bg-slate-800 hover:bg-slate-700 rounded-lg transition-colors"
title="Скопіювати"
>
{copied ? (
<CheckCircle className="w-4 h-4 text-green-400" />
) : (
<Copy className="w-4 h-4 text-slate-400" />
)}
</button>
{/* Content */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Main Instructions */}
<div className="lg:col-span-2 space-y-6">
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-8 prose prose-blue max-w-none">
{instructions ? (
<ReactMarkdown
components={{
code({node, inline, className, children, ...props}: any) {
const match = /language-(\w+)/.exec(className || '')
return !inline && match ? (
<div className="relative group">
<pre className={className} {...props}>
<code>{children}</code>
</pre>
<button
onClick={() => copyToClipboard(String(children).replace(/\n$/, ''))}
className="absolute top-2 right-2 p-2 bg-gray-700 text-white rounded opacity-0 group-hover:opacity-100 transition-opacity"
title="Copy"
>
{copied ? <CheckCircle className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
</button>
</div>
) : (
<code className={className} {...props}>
{children}
</code>
)
}
}}
>
{instructions}
</ReactMarkdown>
) : (
<div className="text-center py-12 text-gray-500">
<AlertCircle className="w-12 h-12 mx-auto mb-4 opacity-50" />
<p>Інструкції не знайдено. Зверніться до адміністратора.</p>
</div>
)}
{step.link && (
<a
href={step.link}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors"
>
<Download className="w-4 h-4" />
Завантажити Python
</a>
)}
</div>
))}
</div>
</div>
{/* Alternative: One-Click Installer */}
<div className="mt-8 bg-gradient-to-r from-purple-900/30 to-pink-900/30 border border-purple-800/30 rounded-xl p-6">
<h2 className="text-xl font-bold mb-4">⚡ Швидке підключення (Coming Soon)</h2>
<p className="text-slate-400 mb-4">
Скоро буде доступний інсталятор в один клік для автоматичного налаштування ноди
</p>
<button
disabled
className="px-6 py-3 bg-purple-600/50 text-white rounded-lg cursor-not-allowed opacity-50"
>
<Download className="w-4 h-4 inline mr-2" />
Завантажити інсталятор (незабаром)
</button>
</div>
{/* Sidebar */}
<div className="space-y-6">
{/* Help Section */}
<div className="bg-blue-50 border border-blue-100 rounded-xl p-6">
<h2 className="text-xl font-bold mb-4 text-blue-900 flex items-center gap-2">
<AlertCircle className="w-5 h-5" />
Потрібна допомога?
</h2>
<div className="space-y-3 text-blue-800 text-sm">
<p>Для отримання токенів доступу (NATS credentials) зверніться до адміністраторів:</p>
<a
href="https://matrix.to/#/#daarion:daarion.space"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 p-3 bg-white rounded-lg hover:shadow-md transition-shadow"
>
<span className="font-semibold">Matrix Chat</span>
<ExternalLink className="w-4 h-4 ml-auto" />
</a>
<a
href="#"
className="flex items-center gap-2 p-3 bg-white rounded-lg hover:shadow-md transition-shadow"
>
<span className="font-semibold">Discord Server</span>
<ExternalLink className="w-4 h-4 ml-auto" />
</a>
</div>
</div>
{/* Help Section */}
<div className="mt-8 bg-slate-900/50 backdrop-blur border border-slate-800 rounded-xl p-6">
<h2 className="text-xl font-bold mb-4">❓ Потрібна допомога?</h2>
<div className="space-y-2 text-slate-400">
<p>• 📚 Документація: <a href="#" className="text-purple-400 hover:underline">docs.dagi.ai</a></p>
<p>• 💬 Telegram спільнота: <a href="#" className="text-purple-400 hover:underline">@dagi_community</a></p>
<p>• 🐛 Проблеми: <a href="#" className="text-purple-400 hover:underline">GitHub Issues</a></p>
{/* System Requirements Summary */}
<div className="bg-white border border-gray-200 rounded-xl p-6">
<h2 className="text-lg font-bold mb-4 text-gray-900">Вимоги</h2>
<div className="space-y-4">
<div className="flex items-center gap-3">
<Cpu className="w-5 h-5 text-gray-400" />
<div>
<div className="text-xs text-gray-500">CPU</div>
<div className="font-medium text-gray-900">4+ Cores</div>
</div>
</div>
<div className="flex items-center gap-3">
<Monitor className="w-5 h-5 text-gray-400" />
<div>
<div className="text-xs text-gray-500">RAM</div>
<div className="font-medium text-gray-900">16GB+ (32GB rec.)</div>
</div>
</div>
<div className="flex items-center gap-3">
<HardDrive className="w-5 h-5 text-gray-400" />
<div>
<div className="text-xs text-gray-500">Storage</div>
<div className="font-medium text-gray-900">100GB+ SSD</div>
</div>
</div>
<div className="flex items-center gap-3">
<Terminal className="w-5 h-5 text-gray-400" />
<div>
<div className="text-xs text-gray-500">OS</div>
<div className="font-medium text-gray-900">Ubuntu 22.04 / Debian</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,7 +1,8 @@
import { useParams, useNavigate } from 'react-router-dom';
import { ArrowLeft, Server, Activity, Cpu, HardDrive, Network, Users, Settings, BarChart3, Plug, RefreshCw, CheckCircle2, XCircle, AlertCircle, Filter, Play, Loader2, Wrench, Download, Bot, Database, AlertTriangle, PlusCircle } from 'lucide-react';
import { ArrowLeft, Server, Activity, Cpu, HardDrive, Network, Users, Settings, BarChart3, Plug, RefreshCw, CheckCircle2, XCircle, AlertCircle, Filter, Play, Loader2, Wrench, Download, Bot, Database, AlertTriangle, PlusCircle, Boxes, Shield } from 'lucide-react';
import { useState, useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';
import { apiGet } from '../api/client';
import { getNode2Agents, type Node2Agent } from '../api/node2Agents';
import { getNode1Agents, type Node1Agent } from '../api/node1Agents';
import { deployAgentToNode2, deployAllAgentsToNode2, checkNode2AgentsDeployment } from '../api/node2Deployment';
@@ -41,6 +42,24 @@ interface NodeDetails {
network_in: number;
network_out: number;
};
microdaos?: Array<{
id: string;
name: string;
slug: string;
role?: string;
}>;
guardian_agent?: {
id: string;
name: string;
slug?: string;
status?: string;
};
steward_agent?: {
id: string;
name: string;
slug?: string;
status?: string;
};
}
// Grafana та Prometheus URLs (налаштувати під ваші сервери)
@@ -103,6 +122,14 @@ export function NodeCabinetPage() {
const agents = agentsData?.items || [];
const isNode1 = nodeId?.includes('node-1');
// Отримуємо профіль з API (для MicroDAOs та агентів)
let apiNodeProfile: any = null;
try {
apiNodeProfile = await apiGet(`/public/nodes/${nodeId}`);
} catch (e) {
console.warn('Failed to fetch node profile from API', e);
}
// Отримуємо реальні дані з інвентаризації
const inv = inventory;
@@ -195,6 +222,9 @@ export function NodeCabinetPage() {
network_in: 1250,
network_out: 890,
},
microdaos: apiNodeProfile?.microdaos || [],
guardian_agent: apiNodeProfile?.guardian_agent,
steward_agent: apiNodeProfile?.steward_agent,
};
},
enabled: !!nodeId,
@@ -551,6 +581,113 @@ export function NodeCabinetPage() {
↓ {nodeDetails.metrics?.network_in || 0} MB/s ↑ {nodeDetails.metrics?.network_out || 0} MB/s
</div>
</div>
</div>
</div>
{/* Core Runtime & Participation */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Core Runtime */}
<div className="bg-white rounded-lg shadow p-6">
<h3 className="font-semibold text-gray-900 mb-4 flex items-center gap-2">
<Activity className="w-5 h-5 text-blue-600" />
Core Runtime
</h3>
<div className="space-y-3">
{[
{ name: 'Node Registry', icon: Database },
{ name: 'NATS JetStream', icon: Network },
{ name: 'Swapper Service', icon: RefreshCw },
{ name: 'Ollama', icon: Bot },
].map((service) => {
const s = nodeDetails.services?.find(s => s.name.includes(service.name) || (service.name === 'Ollama' && s.name.includes('ollama')));
const status = s?.status === 'running' ? 'online' : 'offline'; // Simple mapping
const Icon = service.icon;
return (
<div key={service.name} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
<div className="flex items-center gap-3">
<Icon className="w-4 h-4 text-gray-500" />
<span className="font-medium text-gray-700">{service.name}</span>
</div>
<div className="flex items-center gap-2">
<span className={`w-2 h-2 rounded-full ${status === 'online' ? 'bg-green-500' : 'bg-gray-400'}`} />
<span className="text-sm text-gray-600">{status === 'online' ? 'Active' : 'Unknown'}</span>
</div>
</div>
);
})}
{/* Guardian & Steward */}
<div className="mt-4 pt-4 border-t border-gray-200 space-y-3">
<div className="flex items-center justify-between p-3 bg-purple-50 rounded-lg border border-purple-100">
<div className="flex items-center gap-3">
<Shield className="w-4 h-4 text-purple-600" />
<span className="font-medium text-purple-900">Guardian Agent</span>
</div>
<span className="text-sm font-medium text-purple-700">
{nodeDetails.guardian_agent?.name || 'Not active'}
</span>
</div>
<div className="flex items-center justify-between p-3 bg-blue-50 rounded-lg border border-blue-100">
<div className="flex items-center gap-3">
<Users className="w-4 h-4 text-blue-600" />
<span className="font-medium text-blue-900">Steward Agent</span>
</div>
<span className="text-sm font-medium text-blue-700">
{nodeDetails.steward_agent?.name || 'Not active'}
</span>
</div>
</div>
</div>
</div>
{/* Participation */}
<div className="bg-white rounded-lg shadow p-6">
<h3 className="font-semibold text-gray-900 mb-4 flex items-center gap-2">
<Boxes className="w-5 h-5 text-green-600" />
Participation
</h3>
<div className="space-y-4">
<div>
<h4 className="text-sm font-medium text-gray-500 mb-2 uppercase">Hosted MicroDAOs</h4>
{nodeDetails.microdaos && nodeDetails.microdaos.length > 0 ? (
<div className="grid grid-cols-1 gap-2">
{nodeDetails.microdaos.map(dao => (
<div key={dao.id} className="flex items-center justify-between p-3 bg-green-50 border border-green-100 rounded-lg">
<span className="font-medium text-green-900">{dao.name}</span>
<span className="text-xs px-2 py-1 bg-white text-green-700 rounded border border-green-200">
Hosting
</span>
</div>
))}
</div>
) : (
<div className="text-center py-6 bg-gray-50 rounded-lg border border-dashed border-gray-200">
<p className="text-gray-500 text-sm">No MicroDAOs hosted yet</p>
<button
onClick={() => navigate('/microdao')}
className="mt-2 text-sm text-blue-600 hover:underline"
>
Join a MicroDAO
</button>
</div>
)}
</div>
<div>
<h4 className="text-sm font-medium text-gray-500 mb-2 uppercase">Agent Capabilities</h4>
<div className="grid grid-cols-2 gap-2">
<div className="p-3 bg-gray-50 rounded-lg text-center">
<div className="text-xl font-bold text-gray-900">{departments.length}</div>
<div className="text-xs text-gray-500">Teams</div>
</div>
<div className="p-3 bg-gray-50 rounded-lg text-center">
<div className="text-xl font-bold text-gray-900">{nodeDetails.agents?.length || 0}</div>
<div className="text-xs text-gray-500">Agents</div>
</div>
</div>
</div>
</div>
</div>
</div>
{/* Quick Stats */}

View File

@@ -1,5 +1,5 @@
import { useNavigate } from 'react-router-dom';
import { Server, Activity, Cpu, HardDrive, Network, ArrowRight, RefreshCw, Zap } from 'lucide-react';
import { Server, Activity, Cpu, HardDrive, Network, ArrowRight, RefreshCw, Zap, Plus } from 'lucide-react';
import { useState, useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';
@@ -216,6 +216,13 @@ export function NodesPage() {
<p className="text-gray-600 mt-2">Управління нодами та моніторинг системи</p>
</div>
<div className="flex items-center gap-4">
<button
onClick={() => navigate('/connect-node')}
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors flex items-center gap-2 shadow-sm"
>
<Plus className="w-4 h-4" />
Підключити ноду
</button>
<button
onClick={fetchAllNodesStatus}
disabled={loading}

118
src/types/agent-cabinet.ts Normal file
View File

@@ -0,0 +1,118 @@
export interface HomeNodeView {
id: string;
name: string;
hostname?: string;
roles: string[];
environment?: string;
}
export interface MicrodaoBadge {
id: string;
name: string;
slug?: string;
role?: string;
is_public: boolean;
is_platform: boolean;
}
export interface AgentSummary {
id: string;
slug?: string;
display_name: string;
title?: string;
tagline?: string;
kind: string;
avatar_url?: string;
status: string;
node_id?: string;
node_label?: string;
home_node?: HomeNodeView;
visibility_scope: string;
is_listed_in_directory: boolean;
is_system: boolean;
is_public: boolean;
is_orchestrator: boolean;
primary_microdao_id?: string;
primary_microdao_name?: string;
primary_microdao_slug?: string;
district?: string;
microdaos: MicrodaoBadge[];
public_skills: string[];
}
export interface AgentVisibilityPayload {
is_public: boolean;
public_title?: string | null;
public_tagline?: string | null;
public_slug?: string | null;
}
export interface AgentSystemPrompt {
content: string;
version: number;
created_at: string;
note?: string;
}
export interface AgentSystemPrompts {
core?: AgentSystemPrompt;
safety?: AgentSystemPrompt;
governance?: AgentSystemPrompt;
tools?: AgentSystemPrompt;
}
export interface AgentDetailDashboard {
profile: {
agent_id: string;
display_name: string;
kind: string;
status: string;
node_id?: string;
is_public: boolean;
public_slug?: string;
is_orchestrator: boolean;
primary_microdao_id?: string;
primary_microdao_name?: string;
primary_microdao_slug?: string;
avatar_url?: string;
city_presence?: {
primary_room_slug?: string;
district?: string;
}
};
node: {
node_id: string;
name: string;
hostname?: string;
roles: string[];
environment?: string;
status: string;
guardian_agent?: { id: string; name: string; };
steward_agent?: { id: string; name: string; };
} | null;
primary_city_room: {
id: string;
slug: string;
name: string;
matrix_room_id?: string;
} | null;
system_prompts: AgentSystemPrompts;
public_profile: {
is_public: boolean;
public_slug?: string;
public_title?: string;
public_tagline?: string;
public_skills: string[];
public_district?: string;
public_primary_room_slug?: string;
} | null;
microdao_memberships: {
microdao_id: string;
microdao_slug?: string;
microdao_name?: string;
logo_url?: string;
role?: string;
is_core: boolean;
}[];
}