Initial commit: MVP structure + Cursor documentation + Onboarding components

This commit is contained in:
Apple
2025-11-13 06:12:20 -08:00
commit 5520665600
58 changed files with 7683 additions and 0 deletions

51
.gitignore vendored Normal file
View File

@@ -0,0 +1,51 @@
# Dependencies
node_modules/
.pnp
.pnp.js
# Testing
coverage/
.nyc_output
# Production
build/
dist/
*.local
# Environment variables
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# Logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# OS
.DS_Store
Thumbs.db
# Temporary files
*.tmp
*.temp
.cache/
# Build outputs
*.tsbuildinfo

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 22 KiB

BIN
DAGI Science Parser +.pdf Normal file

Binary file not shown.

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 30 KiB

View File

@@ -0,0 +1,294 @@
Micro Dao Orchestrator Ui — React Layout (shell)· typescript
import React from "react";
\<Input placeholder="Пошук по всіх модулях" className="max-w-md" /\>
\<Tabs defaultValue="dao" className="ml-2"\>
\<TabsList\>
\<TabsTrigger value="private"\>Private\</TabsTrigger\>
\<TabsTrigger value="dao"\>DAO\</TabsTrigger\>
\<TabsTrigger value="public"\>Public\</TabsTrigger\>
\</TabsList\>
\</Tabs\>
\<div className="ml-auto flex items-center gap-2"\>
\<Badge variant="outline" className="flex items-center gap-1"\>
\<Zap className={\`h-4 w-4 ${netOnline ? 'text-emerald-600' : 'text-amber-600'}\`}/\>
{netOnline ? 'Online' : 'Offline'}
\</Badge\>
\<Badge variant="outline" className="flex items-center gap-1"\>
\<Database className={\`h-4 w-4 ${orchOk ? 'text-emerald-600' : 'text-amber-600'}\`}/\>
Orchestrator {orchOk ? 'ok' : 'unreachable'}
\</Badge\>
\<Button variant="secondary" size="sm" className="gap-2"\>\<Settings className="h-4 w-4"/\>Налаштування\</Button\>
\</div\>
\</div\>
);
}
function HealthGrid() {
const items \= \[
{ title: "Messenger", ok: true },
{ title: "Parser", ok: false },
{ title: "KB Core", ok: true },
{ title: "RAG", ok: true },
{ title: "Wallet", ok: true },
{ title: "DAO", ok: true },
\];
return (
\<div className="grid grid-cols-2 lg:grid-cols-3 gap-3"\>
{items.map((x) \=\> (
\<Card key={x.title} className="rounded-2xl"\>
\<CardHeader className="py-3"\>
\<CardTitle className="text-sm font-medium flex items-center gap-2"\>
{x.ok ? (
\<CheckCircle2 className="h-4 w-4 text-emerald-600" /\>
) : (
\<AlertTriangle className="h-4 w-4 text-amber-600" /\>
)}
{x.title}
\</CardTitle\>
\</CardHeader\>
\<CardContent className="py-2"\>
\<div className="text-xs text-slate-500"\>{x.ok ? "Працює стабільно" : "Черга задач \> p95"}\</div\>
\</CardContent\>
\</Card\>
))}
\</div\>
);
}
function OrchestratorChat() {
return (
\<Card className="rounded-2xl h-full flex flex-col"\>
\<CardHeader className="py-3"\>
\<CardTitle className="text-sm"\>Чат з Оркестратором\</CardTitle\>
\</CardHeader\>
\<CardContent className="flex-1 flex flex-col gap-3"\>
\<div className="flex-1 rounded-xl border bg-slate-50 p-3 text-sm overflow-auto"\>
\<div className="text-slate-500"\>Вітаю. Чим допомогти? Наприклад: "Розбери PDF та створй короткий бріф у Проєктах".\</div\>
\</div\>
\<div className="flex items-center gap-2"\>
\<Textarea placeholder="Запитайте або використайте @agent" className="min-h-\[44px\]" /\>
\<Button className="whitespace-nowrap"\>Надіслати\</Button\>
\</div\>
\</CardContent\>
\</Card\>
);
}
function ActivityFeed() {
const rows \= \[
{ t: "ingest.completed", d: "USDO готово", ts: "09:15" },
{ t: "message.created", d: "\#general", ts: "09:12" },
{ t: "vote.finalized", d: "Постанова \#12", ts: "08:55" },
{ t: "payment.sent", d: "Reward 2.5 μUTIL", ts: "08:40" },
\];
return (
\<Card className="rounded-2xl h-full"\>
\<CardHeader className="py-3"\>\<CardTitle className="text-sm"\>Стрічка подій\</CardTitle\>\</CardHeader\>
\<CardContent\>
\<ul className="space-y-2 text-sm"\>
{rows.map((r, i) \=\> (
\<li key={i} className="flex items-center gap-2"\>
\<Activity className="h-4 w-4 text-slate-500"/\>
\<span className="font-medium"\>{r.t}\</span\>
\<span className="text-slate-500"\>— {r.d}\</span\>
\<span className="ml-auto text-xs text-slate-400"\>{r.ts}\</span\>
\</li\>
))}
Зміни потрібні. Я спробував застосувати правки в Canvas автоматично, але інструмент не знайшов кілька точних збігів у вашій версії файлу і відхилив патч.
Щоб не блокуватися, ось мінімальні дифи для ручної правки у вашому поточному файлі **“Micro Dao Orchestrator Ui — React Layout (shell)”**. Вони точно відповідають його актуальному вмісту вище.
# **1\) Topbar: 3-статус оркестратора**
**A. Сигнатура:**
`- function Topbar({ netOnline, orchOk }: { netOnline: boolean; orchOk: boolean }) {`
`+ function Topbar({ netOnline, orchStatus }: { netOnline: boolean; orchStatus: 'ok'|'down'|'unknown' }) {`
**B. Бейдж Orchestrator:**
``- <Database className={`h-4 w-4 ${orchOk ? 'text-emerald-600' : 'text-amber-600'}`}/>``
`- Orchestrator {orchOk ? 'ok' : 'unreachable'}`
``+ <Database className={`h-4 w-4 ${orchStatus === 'ok' ? 'text-emerald-600' : 'text-amber-600'}`}/>``
`+ Orchestrator {orchStatus === 'ok' ? 'ok' : orchStatus === 'down' ? 'down' : 'unknown (CORS?)'}`
**C. Виклик Topbar у return:**
`- <Topbar netOnline={netOnline} orchOk={orchOk} />`
`+ <Topbar netOnline={netOnline} orchStatus={orchStatus} />`
**D. Стейт у OrchestratorLayout():**
`- const [orchOk, setOrchOk] = React.useState<boolean>(true);`
`+ const [orchStatus, setOrchStatus] = React.useState<'ok'|'down'|'unknown'>('unknown');`
# **2\) Блок helpers: /healthz з конфігом, рекомендація профілю, оцінка індексу**
Замініть усе від рядка `// --- helpers ---` до початку `export default function OrchestratorLayout()` на це:
`// --- helpers ---`
`const CONFIG = {`
`HEALTHZ_URL: (typeof import !== 'undefined' && (import.meta as any)?.env?.VITE_HEALTHZ_URL) || '/healthz',`
`};`
`async function pingHealthz(url: string, timeoutMs = 3000): Promise<'ok'|'down'|'unknown'> {`
`try {`
`const ctrl = new AbortController();`
`const t = setTimeout(() => ctrl.abort(), timeoutMs);`
`const res = await fetch(url, { signal: ctrl.signal, headers: { accept: 'application/json,text/plain,*/*' } });`
`clearTimeout(t);`
`return res.ok ? 'ok' : 'down';`
`} catch {`
`const online = typeof navigator !== 'undefined' ? navigator.onLine : false;`
`return online ? 'unknown' : 'down';`
`}`
`}`
`async function recommendModelProfile(): Promise<'Lite'|'Base'|'Plus'|'Pro'> {`
`// Heuristics: deviceMemory (GB), CPU cores, simple UA.`
`// @ts-ignore`
`const dm = (navigator as any).deviceMemory || 4;`
`const cores = navigator.hardwareConcurrency || 4;`
`const isMobile = /iPhone|Android/i.test(navigator.userAgent);`
`if (dm >= 24 && cores >= 8 && !isMobile) return 'Pro';`
`if (dm >= 12 && cores >= 8) return 'Plus';`
`if (dm >= 6) return 'Base';`
`return 'Lite';`
`}`
`function profileSizeMB(p: 'Lite'|'Base'|'Plus'|'Pro'): number {`
`return p === 'Lite' ? 300 : p === 'Base' ? 1000 : p === 'Plus' ? 4000 : 7000;`
`}`
`function parseIndexSizeMB(label: string): number | null {`
`if (!label) return null;`
`if (label.startsWith('custom:')) return null;`
`if (label.toUpperCase().endsWith('GB')) return parseInt(label) * 1024;`
`if (label.toUpperCase().endsWith('MB')) return parseInt(label);`
`return null;`
`}`
`// ~2KB/chunk → ≈512 чанків/MB`
`function estimateChunksInt8(sizeMB: number): number { return Math.floor(sizeMB * 512); }`
# **3\) OrchestratorLayout(): стейти, useEffect, інсталятор, пропси StartScreen**
**A. Додайте/оновіть стейти:**
`const [modelProfile, setModelProfile] = React.useState<string>('Base');`
`const [recommendedProfile, setRecommendedProfile] = React.useState<string>('Base');`
`const [indexSize, setIndexSize] = React.useState<string>('500MB');`
`+ const [estChunks, setEstChunks] = React.useState<string>('');`
`const [netOnline, setNetOnline] = React.useState<boolean>(typeof navigator !== 'undefined' ? navigator.onLine : true);`
`- const [orchOk, setOrchOk] = React.useState<boolean>(true);`
`+ const [orchStatus, setOrchStatus] = React.useState<'ok'|'down'|'unknown'>('unknown');`
**B. Замініть увесь `useEffect` на:**
`React.useEffect(() => {`
`const on = () => setNetOnline(true);`
`const off = () => setNetOnline(false);`
`window.addEventListener('online', on);`
`window.addEventListener('offline', off);`
`let mounted = true;`
`// real /healthz ping every 10s with config + CORS aware`
`const tick = async () => {`
`const st = await pingHealthz(CONFIG.HEALTHZ_URL);`
`if (mounted) setOrchStatus(st);`
`};`
`tick();`
`const id = setInterval(tick, 10000);`
`// model profile recommendation`
`recommendModelProfile().then(p => { if (mounted) { setRecommendedProfile(p); setModelProfile(p); } });`
`// restore custom index path if saved`
`const savedPath = localStorage.getItem('microdao.indexPath');`
``if (savedPath) setIndexSize(`custom:${savedPath}`);``
`// estimate chunks for selected index size`
`const estId = setInterval(() => {`
`const mb = parseIndexSizeMB(indexSize);`
``if (mb) setEstChunks(`≈ ${Math.round(estimateChunksInt8(mb)/1000)} тис.`);``
`else setEstChunks('');`
`}, 300);`
`return () => { mounted = false; window.removeEventListener('online', on); window.removeEventListener('offline', off); clearInterval(id); clearInterval(estId); };`
`}, [indexSize]);`
**C. Інсталятор ваг, прив’язаний до профілю/індексу:**
`const handleInstallAgent = () => {`
`if (installing || agentReady) return;`
`setInstalling(true);`
`setProgress(0);`
`- const timer = setInterval(() => {`
`- setProgress((p) => {`
`- const next = Math.min(100, p + Math.random() * 18 + 5);`
`- if (next >= 100) {`
`- clearInterval(timer);`
`- setInstalling(false);`
`- setAgentReady(true);`
`- }`
`- return next;`
`- });`
`- }, 400);`
`+ localStorage.setItem('microdao.modelProfile', modelProfile);`
`+ localStorage.setItem('microdao.indexSize', indexSize);`
`+ const modelMB = profileSizeMB(modelProfile as any);`
`+ const idxMB = parseIndexSizeMB(indexSize) || 0;`
`+ const totalMB = modelMB + idxMB;`
`+ const estTimeMs = Math.min(120000, 1500 * (totalMB / 50)); // ~1.5s per 50MB, cap 2min`
`+ const started = Date.now();`
`+ const timer = setInterval(() => {`
`+ const elapsed = Date.now() - started;`
`+ let pct = Math.min(100, (elapsed / estTimeMs) * 100);`
`+ pct = pct < 100 ? (pct * 0.85) : 100; // ease-out`
`+ setProgress(pct);`
`+ if (pct >= 99.5) {`
`+ clearInterval(timer);`
`+ setInstalling(false);`
`+ setAgentReady(true);`
`+ }`
`+ }, 300);`
`};`
**D. Пропси для `StartScreen` у `content()` (внизу):**
`<StartScreen`
`...`
`recommendedProfile={recommendedProfile}`
`- onPickCustomIndex={handlePickCustomIndex}`
`+ onPickCustomIndex={handlePickCustomIndex}`
`+ estChunks={estChunks}`
`/>`
# **4\) StartScreen: додайте проп і підказку**
**A. Сигнатура:**
`- function StartScreen({ onCreateDAO, onJoinDAO, onSolo, agentReady, onInstallAgent, installing, progress, modelProfile, setModelProfile, indexSize, setIndexSize, recommendedProfile, onPickCustomIndex }:`
`+ function StartScreen({ onCreateDAO, onJoinDAO, onSolo, agentReady, onInstallAgent, installing, progress, modelProfile, setModelProfile, indexSize, setIndexSize, recommendedProfile, onPickCustomIndex, estChunks }:`
…та в типі параметрів додайте `estChunks: string;`.
**B. Підказка під індексом:**
`- <div className="text-xs text-slate-500">int8 квантування заощаджує ×34 місця.</div>`
`+ <div className="text-xs text-slate-500">`
`+ int8 квантування заощаджує ×34 місця. {estChunks && <span className="ml-1">Орієнтовно: {estChunks} чанків (int8).</span>}`
`+ </div>`
Після цих замін у вас буде:
* `/healthz` з URL із конфіга і CORS-статусом `unknown`.
* Автовибір профілю моделі та збереження вибору.
* Оцінка місткості індексу в чанках (int8).
* Тристатусний бейдж оркестратора.

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 17 KiB

1
MicroDAO Demo Flow.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 18 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 21 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 25 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 28 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 23 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 19 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 19 KiB

52
deploy-matrix.yml Normal file
View File

@@ -0,0 +1,52 @@
# .github/workflows/deploy-matrix.yml
name: Deploy MicroDAO
on:
workflow_dispatch:
push:
branches: [ main ]
jobs:
plan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Upload dependency matrix artifact
uses: actions/upload-artifact@v4
with:
name: microdao-matrix
path: microdao_dependency_matrix.yaml
deploy:
runs-on: ubuntu-latest
needs: plan
strategy:
fail-fast: false
matrix:
stage: [P0, P1, P2, P3, P4]
env: [dev, stage, prod]
steps:
- uses: actions/checkout@v4
- name: Download matrix
uses: actions/download-artifact@v4
with:
name: microdao-matrix
path: .
- name: Render deployment plan
run: |
python - <<'PY'
import yaml, sys
y = yaml.safe_load(open('microdao_dependency_matrix.yaml'))
stage='${{ matrix.stage }}'
env='${{ matrix.env }}'
svcs=[s for s in y['services'] if s['stage']==stage]
print(f"Deploy plan for {stage} in {env}:")
for s in svcs:
print(f"- {s['id']} deps={s.get('depends_on', [])}")
PY
- name: Deploy
run: |
echo "Deploying stage=${{ matrix.stage }} env=${{ matrix.env }}"
# Insert helm/terraform commands here, filtered by stage and env
- name: Post-deploy health checks
run: |
echo "Run health gates and canary analysis"

View File

@@ -0,0 +1,33 @@
# MicroDAO - Огляд системи
## Що таке MicroDAO
MicroDAO — це приватна мережа ШІ-агентів для малих спільнот (5-50 учасників). Система дозволяє створювати спільноти (teams) з автоматичним створенням micro-DAO, публічні та приватні канали для спілкування, проєкти з задачами, базу знань (Co-Memory) та приватних ШІ-агентів, які допомагають у роботі команди.
## Ключові модулі
1. **Auth** — авторизація через magic-link (email)
2. **Teams** — спільноти з автоматичним створенням micro-DAO
3. **Channels** — публічні та приватні канали для спілкування
4. **Messages** — повідомлення в каналах з підтримкою markdown
5. **Co-Memory** — база знань (файли, посилання, wiki)
6. **Follow-ups** — задачі, створені з повідомлень
7. **Projects** — проєкти з канбан-дошками (Backlog / In Progress / Done)
8. **Agents** — приватні ШІ-агенти для кожної спільноти
9. **Search** — пошук по повідомленнях та документах (Meilisearch)
## Режими спільнот
- **Public** — публічний канал, гості можуть читати та реєструватися як глядачі (viewer-type)
- **Confidential** — тільки запрошені учасники, E2EE для чатів, без публічного індексування
## Посилання на документацію
- `01_product_brief_mvp.md` — Product Requirements для MVP
- `02_architecture_basics.md` — Технічна архітектура
- `03_api_core_snapshot.md` — API контракти для MVP
- `04_ui_ux_onboarding_chat.md` — UI/UX специфікація
- `05_coding_standards.md` — Стандарти кодування
- `06_tasks_onboarding_mvp.md` — Задачі для реалізації
- `07_testing_checklist_mvp.md` — Чеклист тестування

View File

@@ -0,0 +1,154 @@
# Product Brief - MVP
## 1. Мета
Дати першим користувачам (фаунерам спільнот та їхнім командам) простий спосіб:
- створити свою micro-DAO (спільноту),
- налаштувати базову приватність (public / confidential),
- почати працювати в чаті з каналами,
- керувати простими задачами в проєктах,
- спробувати приватного AI-агента всередині спільноти.
Ціль: **реальний робочий простір для 12 команд**, а не демо-скріншоти.
## 2. Персони
1. **Фаундер спільноти / команди**
- Хоче створити свій "маленький всесвіт": чат, задачі, базу знань.
- Цінує приватність, простоту і контроль над даними.
- Не хоче розбиратися в технічних деталях DAO / токенів.
2. **Учасник команди**
- Приходить за інвайтом або з публічного каналу.
- Хоче: писати в чат, бачити задачі, отримувати фоллоу-апи.
- Агента сприймає як "корисного помічника", а не як складну систему.
3. **Ранні технічні тестувальники**
- Можуть пробачити сирість інтерфейсу.
- Важливо: стабільність базових флоу, зрозумілий API та логічна структура.
## 3. Ключові сценарії (Core Flows)
### 3.1. Onboarding: створення першої micro-DAO
1. Користувач заходить на сайт.
2. Логін через email (magic-link).
3. В онбордингу задає:
- назву спільноти,
- режим: Public / Confidential,
- перший канал (наприклад, `#general`),
- базові налаштування приватного агента.
4. Потрапляє в основний інтерфейс чату своєї нової спільноти.
### 3.2. Публічний канал як точка входу
1. Гість відкриває публічний канал за посиланням `/c/:slug`.
2. Читає стрічку (read-only).
3. Через форму "Зареєструватися в каналі" вводить email + ім'я + viewer-type.
4. Стає учасником (Member / Visitor) і може писати в канал.
### 3.3. Командний чат
1. Учасники пишуть повідомлення в канали.
2. Можуть створювати follow-up із будь-якого повідомлення.
3. Бачать базову активність (нові повідомлення, треди).
### 3.4. Follow-ups
1. З повідомлення в чаті користувач натискає "Створити follow-up".
2. Задає: назву, відповідального (assignee), дедлайн (опційно).
3. У вкладці "Follow-ups" бачить:
- Assigned to me,
- All (простий список задач із статусом).
### 3.5. Проєкти та задачі (Kanban-lite)
1. Користувач створює проєкт для команди.
2. Додає задачі (title, статус, опційний due).
3. Переміщує задачі між колонками Backlog / In Progress / Done.
4. Фільтрує задачі за статусом (мінімально).
### 3.6. Приватний агент
1. У налаштуваннях спільноти або онбордингу вмикається "Team Assistant".
2. У спеціальному чаті з агентом користувач ставить питання по контексту спільноти.
3. Агент відповідає, використовуючи:
- історію чату (контекст сесії),
- в майбутньому — Co-Memory і документи (для MVP можна обмежитися контекстом чату).
## 4. Обсяг MVP (In Scope)
### 4.1. Функції
- **Auth:**
- Логін через email (magic-link).
- **Teams / micro-DAO:**
- Створення спільноти.
- Перегляд списку моїх спільнот.
- Перемикач режиму: Public / Confidential.
- **Channels:**
- Створення public / group каналів.
- Список каналів для обраної спільноти.
- **Messages:**
- Відправка / отримання повідомлень у каналі.
- Пагінація стрічки (cursor / limit).
- **Public Channel Landing:**
- Read-only стрічка для гостей.
- Форма реєстрації (email + ім'я + viewer-type).
- **Follow-ups:**
- Створення follow-up з повідомлення.
- Перегляд списку follow-up (фільтр по assignee / статусу).
- **Projects & Tasks (спрощено):**
- Створення проєкту.
- Додавання задач.
- Зміна статусу задачі між базовими колонками.
- **Agents:**
- Створення / наявність одного "Team Assistant".
- Базовий чат з агентом через API існуючого LLM-провайдера.
- **Settings:**
- Мова інтерфейсу (мінімум: uk + en).
- Часовий пояс.
- Прості параметри агента (on/off, мова, профіль).
### 4.2. Нефункціональні вимоги
- Стабільність під 1050 активних користувачів.
- Чат відповідає ≤ 300 мс (до LLM-викликів).
- Мінімальна мобільна адаптація (читання + базове введення).
## 5. Що НЕ входить в MVP (Out of Scope)
- Повна реалізація токеноміки (RINGK, 1T, KWT, DAARION) та стейкінгу.
- Governance (пропозиції, голосування, timelock).
- Повний Co-Memory (файли, wiki, RAG-індексація) — можна мати лише базові заглушки.
- Складні інтеграції (Gmail, Calendar, Notion та ін.).
- Просунуте управління правами (детальний RBAC/UI для ролей, кастомні ACL).
- Повний multi-agent orchestration (мережа агентів, роутинг між моделями).
- Робототехніка та фізичні інтеграції (на рівні MVP лише як стратегічна перспектива).
## 6. Успіх MVP (Success Criteria)
- 12 живі спільноти (520 людей), що:
- щодня використовують чат,
- створюють проєкти й задачі,
- користуються хоча б одним агентом.
- Мінімум 35 сесій на користувача на тиждень.
- Нуль критичних блокерів:
- логін завжди працює,
- повідомлення не губляться,
- онбординг можна пройти від початку до кінця без допомоги девів.
## 7. Примітки для розробників
- Цей brief — **орієнтир, а не жорсткий контракт**.
- Якщо функція не потрібна для основних флоу (описаних вище) — її можна перенести в наступні ітерації.
- Головний пріоритет: **простий, стабільний досвід для перших реальних користувачів**, навіть ціною урізаного функціоналу.

View File

@@ -0,0 +1,239 @@
# 02 — MicroDAO Architecture Basics (MVP)
Цей документ дає Cursor і розробникам стисле уявлення про архітектуру MicroDAO, необхідне для реалізації перших функцій MVP.
## 1. Загальний огляд архітектури
MicroDAO складається з:
- **Front-end SPA** (React + TypeScript)
- **API Gateway** (`https://api.microdao.xyz/v1`)
- **Core Services** (Teams, Channels, Messages, Followups, Projects, Agents)
- **PostgreSQL** — основна база даних
- **NATS JetStream** — message bus (події, outbox-патерн)
- **Meilisearch** — індексація і пошук
- **S3-compatible storage** — файли
- **WebSockets** — оновлення повідомлень у реальному часі
Джерела:
- Data Model & Event Catalog
- Tech Spec / Технічний опис MicroDAO
- API Specification (OpenAPI 3.1)
## 2. Стек MVP
- **Frontend:** React 18, TypeScript, Vite або Next SPA-режим
- **State:** React Query / TanStack Query
- **Design System:** базовий UI-компонентний набір (кнопки, поля, layout)
- **Backend:** Go або Node (вже залежить від вашої реалізації — Cursor адаптується)
- **Auth:** Magic-link email (JWT)
- **Transport:** REST + WebSockets
## 3. Основні модулі
### 3.1. Auth Service
- Відповідає за:
- `POST /auth/login-email`
- `POST /auth/exchange`
- Видає JWT (користувач, локаль, tz).
- Email з кодом / magic-link відправляє окремий SMTP-модуль.
- Після входу SPA зберігає токен та ініціалізує сесію.
### 3.2. Teams / MicroDAO Service
- Створення спільноти — автоматично створює micro-DAO:
- `POST /teams`
- `PATCH /teams/{id}` — public/confidential
- Зберігає:
- id спільноти
- slug
- режим (`public`, `confidential`)
- Members / Guardians
- Взаємодіє з Channels, Messages, Projects, Agents.
### 3.3. Channels Service
- Створення каналів:
- `POST /channels`
- Типи:
- `public` — доступні гостям (read-only)
- `group` — приватні групові канали
- Channel data:
- team_id
- type
- mode (public/confidential)
### 3.4. Messaging Service
- Головне ядро MVP.
- API:
- `GET /channels/{id}/messages`
- `POST /channels/{id}/messages`
- `PATCH /messages/{id}`
- `DELETE /messages/{id}`
- Зберігає:
- текстові повідомлення
- автора (user_id або agent_id)
- E2EE шифротекст у confidential режимі
- WebSocket транслює нові повідомлення в реальному часі.
### 3.5. Followups Service
- Легкий таскер, прив'язаний до повідомлень.
- API:
- `POST /followups`
- `GET /followups?assignee=...`
- Статуси:
- `open`, `in_progress`, `done`
- Використовується для персональних нагадувань і мікро-задач.
### 3.6. Projects & Tasks Service (Kanban-lite)
- API:
- `POST /projects`
- `GET /projects`
- `POST /projects/{id}/tasks`
- `GET /projects/{id}/tasks`
- Статуси задач:
- `backlog`, `in_progress`, `review`, `done`
- Проста Kanban-дошка для MVP.
### 3.7. Agents Service
- Зберігає приватних агентів користувача або команди.
- API:
- `GET /agents`
- `POST /agents`
- Для MVP:
- один агент «Team Assistant»
- мінімальний чат з LLM
- Під капотом можна використовувати будь-який зовнішній LLM API.
### 3.8. Search Service
- На базі Meilisearch.
- API:
- `GET /search?q=...&scope=messages|docs|tasks`
- MVP:
- індексація публічних повідомлень + задач.
## 4. Дані та моделі
### 4.1. База даних (PostgreSQL)
Згідно з Data Model & Event Catalog:
- `users`
- `teams`, `team_members`
- `channels`, `messages`, `reactions`
- `followups`
- `projects`, `tasks`
- `agents`, `agent_runs`
- `files`
- `audit_log`
- мінімальні індекси для пошуку повідомлень
**ID формати:** `ulid` або `ksuid` (обов'язково глобально унікальні).
### 4.2. Message Bus (NATS JetStream)
Використовується не на всіх етапах MVP, але:
- дозволяє публікувати події:
- `message.created`
- `followup.created`
- `task.created`
- забезпечує надійний outbox pattern.
### 4.3. Пошукові індекси (Meilisearch)
Структури документів:
- **Messages**: id, team_id, channel_id, created_at, body_plain (якщо public)
- **Tasks**: id, project_id, title, status, priority, labels
- **Docs** (можна не включати в MVP)
## 5. WebSockets
- Створений окремий WS endpoint.
- Події які обробляє фронт:
- нове повідомлення
- оновлення повідомлення
- реакція
- В MVP достатньо канального namespace:
- `/ws/channels/{id}`
## 6. Приватність та режими (Public / Confidential)
### Public Mode
- Канал доступний гостям на `/c/:slug`.
- Повідомлення індексуються у Meilisearch.
- Дані зберігаються у `messages.body_plain`.
### Confidential Mode
- Повідомлення зберігаються як `body_enc` + `key_id`.
- Клієнт розшифровує.
- Не індексується, не надсилається в Meili.
- Всі вкладення — шифротекст із pre-signed URL.
- На фронті потрібно використовувати **E2EE-хелпери** (поза scope MVP — stub OK).
## 7. API взаємодія (загальні правила)
- Усі виклики захищені Bearer JWT.
- Потрібно використовувати typed API-клієнт (можна автогенерувати зі спрощеної OpenAPI).
- Обробка помилок:
- 400 → помилка користувача
- 403 → access denied
- 404 → ресурс не знайдено
- 429 → rate limit
- 500 → системна помилка
## 8. Front-End архітектура
### 8.1. Каталоги
```
src/
api/
components/
features/
onboarding/
auth/
chat/
channels/
followups/
projects/
agents/
hooks/
layout/
routes/
store/
styles/
```
### 8.2. Рекомендовані патерни
- React Query для запитів і кешу.
- Zustand або Context для глобального стану онбордингу.
- Мовна локалізація через простий i18n dictionary.
- ErrorBoundary на рівні layout.
## 9. MVP Нефункціональні очікування
- Латентність чатів ≤ 300 мс (без LLM).
- Одночасно 1050 активних користувачів.
- Стабільна робота мобільної версії (мінімально).
- Стійкий логін, без циклів і моклих лінків.
## 10. Для Cursor
Цей документ дає базу для:
- генерації React-компонентів,
- створення нового маршруту `/onboarding`,
- реалізації каналів і чатів,
- інтеграції базового агента,
- роботи з API без необхідності читати всю специфікацію.

View File

@@ -0,0 +1,414 @@
# 03 — MicroDAO API Core Snapshot (MVP)
Цей документ — стисла витяжка з OpenAPI 3.1 специфікації MicroDAO.
Він містить тільки ті ендпоїнти, які необхідні для реалізації MVP онбордингу, чатів, задач та приватного агента.
Повна OpenAPI: див. `microdao — API Specification (OpenAPI 3.1)`.
## 1. Auth
### POST /auth/login-email
Надсилає магічний лінк користувачу на email.
**Body**
```json
{ "email": "user@example.com" }
```
**Response**
`204 No Content`
---
### POST /auth/exchange
Обмін коду з email-лінка на JWT.
**Body**
```json
{ "code": "XXXXXX" }
```
**Response 200**
```json
{
"token": "jwt-string",
"user": {
"id": "u_123",
"locale": "uk-UA",
"tz": "Europe/Kyiv"
}
}
```
---
## 2. Teams (micro-DAO)
### POST /teams
Створює нову спільноту (micro-DAO).
**Body**
```json
{ "name": "My Team" }
```
**Response 201**
```json
{
"id": "t_123",
"name": "My Team",
"slug": "my-team",
"mode": "public"
}
```
---
### PATCH /teams/{teamId}
Оновлює налаштування спільноти.
**Body**
```json
{ "mode": "public" | "confidential" }
```
**Response 200**
```json
{
"id": "t_123",
"name": "My Team",
"mode": "confidential"
}
```
---
### GET /teams
Список моїх спільнот.
**Response**
```json
{
"items": [
{ "id": "t_1", "name": "Team 1", "mode": "public" },
{ "id": "t_2", "name": "Project Alpha", "mode": "confidential" }
]
}
```
---
## 3. Channels
### POST /channels
Створює канал.
**Body**
```json
{
"team_id": "t_123",
"type": "public" | "group",
"title": "general",
"mode": "public" | "confidential"
}
```
**Response 201**
```json
{
"id": "c_123",
"team_id": "t_123",
"title": "general",
"type": "public",
"mode": "public"
}
```
---
### GET /channels/{channelId}/messages
Отримує повідомлення каналу (cursor pagination).
**Query params**
* `cursor` (optional)
* `limit` (1200)
**Response**
```json
{
"items": [
{
"id": "m_1",
"author_user_id": "u_123",
"kind": "text",
"body_plain": "Hello world",
"created_at": "2025-01-01T12:00:00Z"
}
],
"next_cursor": "abc123"
}
```
(У confidential-каналах буде `body_enc` + `key_id` замість `body_plain`.)
---
### POST /channels/{channelId}/messages
Надсилає повідомлення.
**Body**
```json
{
"kind": "text",
"body": "Привіт командо!"
}
```
**Response 201**
```json
{
"id": "m_123",
"kind": "text",
"author_user_id": "u_123",
"created_at": "2025-01-01T12:00:00Z"
}
```
---
## 4. Follow-ups
### POST /followups
Створює follow-up із повідомлення.
**Body**
```json
{
"team_id": "t_123",
"assignee_id": "u_123",
"src_message_id": "m_456",
"due": "2025-02-01T09:00:00Z"
}
```
**Response 201**
```json
{
"id": "fu_1",
"status": "open"
}
```
---
### GET /followups
Список follow-up.
**Query**
* `assignee` (optional)
* `status` (optional)
* `cursor` (optional)
**Response**
```json
{
"items": [
{
"id": "fu_1",
"status": "open",
"assignee_id": "u_123",
"due": "2025-02-01T09:00:00Z"
}
]
}
```
---
## 5. Projects & Tasks
### POST /projects
Створює проєкт.
**Body**
```json
{
"team_id": "t_123",
"name": "Website Launch",
"visibility": "public"
}
```
**Response**
```json
{
"id": "p_1",
"team_id": "t_123",
"name": "Website Launch"
}
```
---
### GET /projects
Список проєктів.
**Response**
```json
{ "items": [ { "id": "p_1", "name": "Website Launch" } ] }
```
---
### POST /projects/{projectId}/tasks
Створює задачу.
**Body**
```json
{
"title": "Design homepage",
"status": "backlog"
}
```
**Response 201**
```json
{
"id": "task_1",
"project_id": "p_1",
"status": "backlog"
}
```
---
### GET /projects/{projectId}/tasks
Отримує задачі.
**Query**
* `status` (optional)
**Response**
```json
{
"items": [
{
"id": "task_1",
"title": "Design homepage",
"status": "backlog"
}
]
}
```
---
## 6. Agents
### GET /agents
Список приватних агентів.
**Response**
```json
{
"items": [
{
"id": "ag_1",
"name": "Team Assistant",
"owner_kind": "team",
"owner_id": "t_123"
}
]
}
```
---
### POST /agents
Створює агента.
**Body**
```json
{
"owner_kind": "team",
"owner_id": "t_123",
"name": "Team Assistant",
"role": "general",
"scopes": ["chat"]
}
```
**Response**
```json
{
"id": "ag_1",
"status": "created"
}
```
---
## 7. Search
### GET /search
Глобальний пошук по команді.
**Query**
* `q` — текст
* `scope`: `messages | files | docs | tasks | people`
**Response**
```json
{
"results": [
{
"type": "message",
"id": "m_1",
"snippet": "Hello world"
}
]
}
```
---
## 8. Errors (узагальнення)
* **400** — неправильні дані
* **401** — без авторизації
* **403** — заборонено (немає прав)
* **404** — не знайдено
* **409** — конфлікт
* **429** — rate limit
* **500** — помилка сервера
Cursor повинен обробляти помилки через toast + лог у консоль.
---
## 9. Примітка
Цей документ — спрощена карта API.
Він узятий з офіційної специфікації MicroDAO і адаптований для:
* автоматичної генерації типів,
* швидкої розробки фронтенду,
* мінімізації зайвих деталей.

View File

@@ -0,0 +1,278 @@
# 04 — UI/UX Specification: Onboarding, Chat & Public Channel (MVP)
Цей документ описує екрани, компоненти та UX-флоу, необхідні для реалізації MVP MicroDAO. Без зайвих деталей — тільки те, що потрібне для роботи Cursor і фронтенду.
Джерела: UI/UX Specification — microdao (web), Test Plan, API Snapshot.
## 1. Загальні принципи UX
- Мінімалістичний інтерфейс: світла тема, чисті лінії, помірні інтервали.
- Мова інтерфейсу: українська (тексти вказані нижче).
- Основний фокус MVP:
**простота**, **читабельність**, **мінімум кліків**, **логічні флоу**.
- Усі критичні дії мають підтвердження (модалки).
- Повідомлення про помилки — короткі та зрозумілі.
## 2. Онбординг (`/onboarding`)
Онбординг реалізується як stepper (5 кроків).
Стан зберігається у локальному React state.
### Step 1 — Welcome
**UX-цілі:**
Пояснити, що таке MicroDAO та що буде далі.
**UI:**
- Заголовок: **"Створимо твою MicroDAO"**
- Підзаголовок: **"5 кроків — і твоя спільнота буде готова до роботи."**
- Кнопка: **"Почати"**
### Step 2 — Назва спільноти
**UI поля:**
- `Назва спільноти` (input, required)
- `Опис (необов'язково)` (textarea)
**UX-підказка:**
> Спільнота — це мікро-DAO. Вона матиме свій чат, агента та приватний простір.
**Кнопка:**
- **"Продовжити"**
**API:** `POST /teams`
### Step 3 — Приватність (Public / Confidential)
**UI:**
Два великі карточки-режими:
#### Карточка "Public"
- Заголовок: **Відкрита**
- Пояснення:
> Є публічний канал. Гості можуть читати та приєднатися через email.
#### Карточка "Confidential"
- Заголовок: **Приватна**
- Пояснення:
> Тільки запрошені учасники. Чати зашифровані між клієнтами.
**Кнопка:**
- **"Вибрати режим"**
**API:** `PATCH /teams/{id}`
### Step 4 — Перший канал
**UI поля:**
- Назва каналу (input, наприклад "general")
- Тип:
- `Публічний канал`
- `Приватна кімната`
**Кнопка:**
- **"Створити канал"**
**API:** `POST /channels`
### Step 5 — Агенти та пам'ять
**UI:**
Перемикачі:
- **Увімкнути приватного агента** (toggle)
- Випадаючий список мов: **Українська / English**
- Профіль агента:
- `Загальний`
- `Бізнес`
- `Технічний`
- `Креатив`
Блок пам'яті (дуже простий):
- **Що агент може памʼятати?**
- Радіо-кнопки:
- `Лише цей канал`
- `Всі канали спільноти`
- `Увесь мій MicroDAO` (опція на майбутнє, можна заблокувати)
**Кнопка:**
- **"Готово" → Перехід до чату**
## 3. Публічний канал (`/c/:slug`)
Це дуже важливий елемент MVP — публічна сторінка для залучення нових користувачів.
### 3.1. Для гостей
**UI структура:**
```
---
| Назва спільноти |
| Опис |
-----------------------------------------
## | Стрічка повідомлень (read-only) |
| Форма приєднання |
| - Імʼя |
| - Email |
| - Кнопка "Приєднатися" |
-----------------------------------------
```
**Тексти:**
- Заголовок: **Публічний канал спільноти**
- Поля:
- "Ваше ім'я"
- "Email"
- Кнопка: **"Приєднатися до спільноти"**
**API:**
- GET `/channels/{id}/messages` (публічні, без авторизації)
- POST `/auth/login-email` → exchange → auto-join public channel (viewer-type)
### 3.2. Для зареєстрованих учасників
- Показуємо повноцінний чат.
- Можливість написати повідомлення.
- У стрічці доступні треди та follow-up.
## 4. Основний Chat UI (`/t/:teamId/c/:channelId`)
Структура:
```
---
## | Sidebar (список каналів) |
## | Chat Header |
## | Messages Stream |
## | Composer (ввести повідомлення) |
---
```
### 4.1. Sidebar
**Елементи:**
- Назва спільноти
- Список каналів:
- Публічний канал
- Приватні групи
- Кнопка "+ Новий канал"
**Active state:** підсвітка поточного каналу.
### 4.2. Chat Header
- Назва каналу
- Тип (публічний / приватний)
- Кнопка меню (3 крапки):
- "Параметри каналу" (можна stub)
### 4.3. Messages Stream
#### Повідомлення містить:
- Аватар автора
- Ім'я
- Час
- Текст (markdown support)
- Меню дій:
- "Зробити follow-up"
- "Скопіювати посилання"
- "Видалити" (тільки автор)
#### Треди — опціонально
Для MVP можна зробити collapsible replies або приховати.
### 4.4. Follow-up creation
Модалка:
Поля:
- Назва (автоматично з фрагменту повідомлення)
- Відповідальний
- Дедлайн (optional)
Кнопки:
- **"Створити"**
- "Скасувати"
API:
- `POST /followups`
### 4.5. Composer
Простий інпут:
```
[Написати повідомлення… ] (Кнопка Надіслати)
```
- Підтримка Enter для відправки.
- Shift+Enter → новий рядок.
- Drag&drop файлів — out of scope.
API:
- `POST /channels/{id}/messages`
## 5. Вкладка "Follow-ups"
URL: `/t/:teamId/followups`
**UI:**
- Фільтри:
- "Assigned to me",
- "All",
- "Open / In progress / Done"
- Список:
- Назва
- Статус
- Дедлайн
- Коротке посилання на оригінальне повідомлення
API:
- `GET /followups`
## 6. Межі MVP
Що **не робимо** у цій версії:
- Немає вкладених тредів 2 рівня.
- Немає реакцій (emoji).
- Немає пересилання повідомлень.
- Немає Co-Memory (файли, документи).
- Немає складного редактора повідомлень.
- Немає налаштувань ролей (тільки Member / Viewer).
## 7. Стандарти UI
- Шрифти: System fonts.
- Primary color: #3F51F5
- Error: #E53935
- Success: #43A047
- Border radius: 8px
- Spacing: 8 / 12 / 16 / 24
## 8. Адаптивність
Minimum viable mobile support:
- Sidebar → Drawer
- Messages → 100% ширина
- Composer зафіксований знизу
- Onboarding — одна колонка
## 9. Для Cursor
При розробці:
- Всі тексти беріть з цього документа.
- Усі екрани повинні відповідати маршрутам, зазначеним у файлі.
- Важливо: onboarding flow має один глобальний state + виклики API на кожному кроці.
- Чат повинен бути повністю інтерактивним.
- Messages Stream має працювати з cursor-based pagination.

View File

@@ -0,0 +1,241 @@
# 05 — MicroDAO Coding Standards (MVP)
Цей документ визначає мінімальні стандарти коду, яким повинен відповідати фронтенд MicroDAO.
Його мета — забезпечити якість, узгодженість і стабільність розробки, особливо при використанні Cursor.
## 1. Загальні принципи
1. **Тільки TypeScript.**
Заборонено `any` та `unknown`, окрім явно позначених місць.
2. **Компоненти — функціональні.**
Не використовувати класові компоненти.
3. **Стан — мінімалістичний.**
Локальний стан → React useState
Глобальний короткочасний стан → Context або Zustand
Дані з API → React Query
4. **Ясність важливіша за магію.**
Прості компоненти, зрозумілі хуки, передбачувані сторінки.
5. **Принцип: один файл — одна відповідальність.**
## 2. Архітектура проєкту
```
src/
api/ // Typed API clients
components/ // UI components (buttons, inputs, modals)
features/ // Business-level modules (chat, onboarding, agents)
hooks/ // Reusable react hooks
layout/ // Application layout
routes/ // Route definitions
store/ // Zustand stores (optional)
styles/ // Global CSS/tokens
utils/ // Formatting, validation
```
- `features/*` містять логіку конкретних модулів.
- `components/*` — лише dumb UI-компоненти (без бізнес-логіки).
## 3. TypeScript Правила
### 3.1. Строгий режим
У `tsconfig.json`:
```json
{
"compilerOptions": {
"strict": true
}
}
```
### 3.2. Заборонено
* `any`
* `!` non-null assertion (за винятком рідкісних випадків)
* глобальний mutable state
### 3.3. API-типи
* Генеруємо типи з API Snapshot / OpenAPI.
* Типи для відповідей зберігаються в `src/api/types.ts`.
## 4. React Query (network layer)
### 4.1. Fetch wrapper
Один універсальний wrapper:
```ts
export async function api<T>(path: string, options?: RequestInit): Promise<T> {
const res = await fetch(`/v1${path}`, {
headers: {
"Content-Type": "application/json",
...options?.headers
},
...options
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.message || `Request failed: ${res.status}`);
}
return res.json();
}
```
### 4.2. Query Keys
```
["teams"]
["teams", teamId]
["channels", teamId]
["messages", channelId]
["followups", teamId]
["projects", teamId]
```
## 5. Стандарти компонентів
### 5.1. Іменування
* Компоненти: `PascalCase`
* Хуки: `useCamelCase`
* Файли: `camel-case.tsx`
* Папки: `kebab-case`
### 5.2. Компонент повинен мати:
* Чіткий props-інтерфейс:
```ts
interface MyCompProps {
title: string;
onClick: () => void;
}
```
* Внутрішній стан не змішується з зовнішнім API-станом.
* Міжкомпонентна логіка виноситься в хуки (наприклад: `useMessages(channelId)`).
## 6. Обробка помилок
### 6.1. Toast/notification
Помилка API → коротке повідомлення:
> "Не вдалося виконати дію. Спробуйте ще раз."
### 6.2. ErrorBoundary
Окрема сторінка помилки для критичних збоїв.
### 6.3. Retry policy
React Query retry: `retry: 1` для GET-запитів
POST — без retry.
## 7. i18n стандарти
Всі тексти повинні бути в словнику:
```
src/i18n/uk.json
src/i18n/en.json
```
Формат ключів:
```
onboarding.welcome_title
onboarding.next
chat.send
chat.input_placeholder
followup.create
```
Форсувати одразу правильну структуру.
## 8. UI та дизайн
### 8.1. Кольори
```
--primary: #3F51F5;
--success: #43A047;
--error: #E53935;
--gray-100: #F8F9FA;
--gray-200: #ECEFF1;
--gray-800: #263238;
```
### 8.2. Типографіка
* System font stack:
`"Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto`
### 8.3. Контрасти
Всі текстові елементи повинні відповідати WCAG AA (axe test).
## 9. Робота з WebSockets
* Використовуємо один хук: `useChannelStream(channelId)`.
* WS підключається коли відкрито чат.
* Події:
* `message.created`
* `message.updated`
Не зберігати WS-стан у глобальному store.
## 10. Обмеження для MVP
Що треба **вимкнути** у коді, щоб не перевантажити ранніх користувачів:
* Без drag'n'drop для файлів.
* Без реакцій (emoji).
* Без WYSIWYG редактора.
* Без Co-Memory (файли/документи), лише stub.
* Без granular RBAC.
## 11. Патерни, які Cursor повинен дотримуватися
1. **Atomic commits**: 1 Фіча → 1 commit.
2. **File-oriented prompts**: кожен запит до Cursor повинен містити список файлів для зміни.
3. **Не переписувати цілі модулі**, якщо не потрібно.
4. **Перевіряти типи** перед генерацією нового коду.
5. **Не вигадувати API** — брати тільки з `03_api_core_snapshot.md`.
## 12. Приклад робочого промта для Cursor
```
You are a senior React/TS engineer.
Implement Step 2 of the onboarding flow (/onboarding).
Specs:
- design from 04_ui_ux_onboarding_chat.md
- API from 03_api_core_snapshot.md
- coding standards from 05_coding_standards.md
Please output:
- list of files to modify
- code diff
```
## 13. Мета документа
Цей файл — "правила дорожнього руху" для команди і Cursor.
Він гарантує:
* узгоджений стиль,
* передбачуваний код,
* мінімум помилок,
* легку підтримку,
* зрозумілість структури для нових девелоперів.

View File

@@ -0,0 +1,332 @@
# 06 — Tasks: Onboarding & MVP Core (for Cursor)
Цей документ містить чіткі технічні задачі для Cursor.
Кожна задача сформульована у форматі, який Cursor розуміє найкраще:
- контекст
- специфікації
- API
- acceptance criteria
- очікуваний вивід (list of files + diff)
Всі задачі беруть дані з:
- 01_product_brief_mvp.md
- 02_architecture_basics.md
- 03_api_core_snapshot.md
- 04_ui_ux_onboarding_chat.md
- 05_coding_standards.md
## BLOCK A — ONBOARDING (5 кроків)
### Task A1 — Create route `/onboarding` + base layout
**Context:**
Onboarding складається з 5 кроків. Потрібен базовий контейнер зі state machine.
**Specs:**
- Створити сторінку `/onboarding`.
- Додати компонент `OnboardingLayout`.
- Зберігати поточний крок у локальному стані.
- Кроки: `welcome`, `team`, `privacy`, `channel`, `agent`, `invite`.
- У верхній частині: step indicator.
**Acceptance Criteria:**
- `/onboarding` відкривається без помилок.
- Є stepper з актуальною позначкою (15).
- Немає реальних API-викликів (тільки каркас).
**Cursor Output:**
- Список файлів для змін.
- Код.
### Task A2 — Onboarding Step 1: Welcome Screen
**Specs:**
- Заголовок: "Створимо твою MicroDAO".
- Підзаголовок: "5 кроків — і твоя спільнота буде готова до роботи."
- Кнопка: "Почати".
- При натисканні — перехід на Step 2.
**Acceptance Criteria:**
- Стиль згідно з 04_ui_ux_onboarding_chat.md.
- Робоча кнопка.
### Task A3 — Step 2: Create Team (API: POST /teams)
**Specs:**
- Форма:
- `Назва спільноти` (required)
- `Опис` (optional)
- Виклик: `POST /teams`
- Результат: зберегти `teamId` у state onboarding.
**Acceptance Criteria:**
- Форма валідна: без назви кнопка disabled.
- Після успішного виклику → Step 3.
- Обробка помилок через toast.
### Task A4 — Step 3: Privacy mode (PATCH /teams/{id})
**Specs:**
UI: дві великі карточки:
- PUBLIC:
- Текст: "Є публічний канал. Гості можуть читати та приєднатися."
- CONFIDENTIAL:
- Текст: "Тільки запрошені учасники. Чати зашифровані."
При натисканні — PATCH `/teams/{teamId}`.
**Acceptance Criteria:**
- Виділяється вибраний режим.
- Успішний PATCH → Step 4.
### Task A5 — Step 4: Create first channel (POST /channels)
**Specs:**
- Поля:
- Назва каналу
- Тип: public | group
- Виклик:
```json
{
"team_id": "...",
"type": "...",
"title": "...",
"mode": "public" | "confidential"
}
```
**Acceptance Criteria:**
* Після успіху → Step 5.
* Канал створений і додається до списку каналів у state.
### Task A6 — Step 5: Agent & memory settings (POST /agents)
**Specs:**
UI:
* toggle: "Увімкнути приватного агента"
* select: мова агента
* select: профіль агента
* select: memory depth
API:
1. Якщо toggle ON →
`POST /agents` body:
```json
{
"owner_kind": "team",
"owner_id": "t_123",
"name": "Team Assistant",
"role": "general",
"scopes": ["chat"]
}
```
2. Якщо OFF → skip
**Acceptance Criteria:**
* Вибір зберігається в onboarding state.
* API викликається тільки якщо агент включений.
* Після успіху → Step 6.
### Task A7 — Step 6: Invite (UI only)
**Specs:**
UI:
* Заголовок: "Спільнота створена!"
* Показати посилання-запрошення (stub: `/invite?t=ID`).
* Кнопка: "Перейти в чат".
**Acceptance Criteria:**
* Немає API.
* При натисканні — redirect до `/t/:teamId/c/:channelId`.
## BLOCK B — CHAT CORE
### Task B1 — Channel List in Sidebar
**Specs:**
* Зробити компонент `SidebarChannels`.
* Отримати список каналів командою:
* Використати локальний state (оновлює онбординг).
* У реальному додатку — GET `/teams/{id}/channels` (можна додати).
* Показати активний канал.
**Acceptance Criteria:**
* Sidebar показує всі канали.
* Active канал підсвічений.
### Task B2 — Messages Stream (GET /channels/{id}/messages)
**Specs:**
* Компонент: `MessagesStream`.
* Пагінація: cursor-based scroll.
* Рендер: avatar + name + time + text.
* Confidential → body_enc (можна stub дешифрування).
**Acceptance Criteria:**
* Стрічка відображає повідомлення.
* При скролі догори → підвантаження старих.
### Task B3 — Composer (POST /messages)
**Specs:**
* Компонент: `MessageComposer`.
* Input + кнопка "Надіслати".
* Enter → відправка.
* Shift+Enter → новий рядок.
**Acceptance Criteria:**
* Повідомлення додається в стрічку без перезавантаження.
* Порожній інпут → заборонити надсилання.
### Task B4 — Follow-up creation (POST /followups)
**Specs:**
* Контекстне меню у повідомленні: "Створити follow-up".
* Модалка: назва (автоматично), assignee (список членів), due.
* API: POST `/followups`.
**Acceptance Criteria:**
* Follow-up створюється успішно.
* Помилки показуються через toast.
## BLOCK C — PROJECTS & TASKS
### Task C1 — Project List (GET /projects)
**Specs:**
* Вкладка "Проєкти".
* Список проєктів (назва).
* Кнопка "Створити проєкт".
**Acceptance Criteria:**
* Працює рендер списку.
* Порожній стан: "Проєкти ще не створені".
### Task C2 — Create Project (POST /projects)
**Specs:**
* Модалка → створення нового проєкту.
* Поля: назва, visibility (public/confidential).
* API: POST `/projects`.
**Acceptance Criteria:**
* Новий проєкт зʼявляється в списку.
### Task C3 — Tasks Board (GET/POST /projects/{id}/tasks)
**Specs:**
* 3 колонки: backlog, in_progress, done.
* Карточка задачі: title + status.
* При кліку → змінити статус.
**Acceptance Criteria:**
* Задачі змінюють статус (PATCH можна stub: просто оновлювати client state).
* Мінімальний Kanban працює.
## BLOCK D — AGENTS
### Task D1 — Agents List (GET /agents)
**Specs:**
* Вкладка "Агенти".
* Показати всіх агентів команди.
**Acceptance Criteria:**
* Один агент "Team Assistant" відображається.
### Task D2 — Agent Chat (stub)
**Specs:**
* Створити окремий чат з агентом:
* `MessageComposer`
* потік повідомлень (локальний state)
* В API-запиті викликати зовнішній LLM (можна mock)
* Зберігати історію до reload.
**Acceptance Criteria:**
* Агент відповідає у вигляді тексту.
* Історія видно в UI.
## BLOCK E — FINALIZATION
### Task E1 — Route redirect after onboarding
**Specs:**
* Після Step 6 redirect:
`/t/:teamId/c/:channelId`
**Acceptance Criteria:**
* Після онбордингу користувач потрапляє у свій перший канал.
### Task E2 — Mobile adaptation
**Specs:**
* Sidebar → Drawer
* Composer sticky bottom
* Onboarding → одна колонка
**Acceptance Criteria:**
* Мобільна версія не ламається.
### Task E3 — Error Handling Audit
**Specs:**
Перевірити всі виклики API:
* login
* teams
* channels
* messages
* followups
* projects
* tasks
* agents
**Acceptance Criteria:**
* Усі помилки показуються через toast.
* Немає uncaught exceptions у консолі.
## Кінець документа
Цей файл є головним TODO для Cursor.
Кожна задача може бути надіслана як окремий prompt,
Cursor повинен завжди відповідати:
* списком файлів,
* diff,
* коротким summary.

View File

@@ -0,0 +1,273 @@
# 07 — Testing Checklist (MVP)
Цей документ визначає мінімальний набір тестів, необхідних для перевірки MVP MicroDAO.
Він створений на основі повного QA Test Plan, але сфокусований на ключових флоу.
## 1. Environment
Тестувати на:
- Desktop ≥1280px
- Chrome (останній)
- Safari (останній)
- Firefox ESR (опціонально)
Мова інтерфейсу: uk-UA
Часовий пояс: Europe/Kyiv
## 2. Critical End-to-End Tests (обов'язково)
### E2E-01 — Magic-link login
**Кроки:**
1. Ввести email у форму логіну.
2. Отримати код/лінк.
3. Авторизуватися.
**Очікування:**
- `POST /auth/login-email → 204`
- `POST /auth/exchange → 200`
- Користувач потрапляє у `/onboarding`
### E2E-02 — Створення спільноти
**Кроки:**
1. Onboarding Step 2: ввести назву.
2. Натиснути "Продовжити".
**Очікування:**
- `POST /teams → 201`
- Зберігається `teamId`
- Перехід до Step 3
### E2E-03 — Вибір режиму (Public / Confidential)
**Кроки:**
1. На Step 3 обрати режим.
2. Натиснути "Вибрати режим".
**Очікування:**
- `PATCH /teams/{id} → 200`
- У state онбордингу режим оновлено
### E2E-04 — Створення першого каналу
**Кроки:**
1. Step 4: назва "general".
2. Тип: public.
**Очікування:**
- `POST /channels → 201`
- `channelId` збережено
- Перехід до Step 5
### E2E-05 — Увімкнення приватного агента
**Кроки:**
1. Step 5 → toggle ON
2. Натиснути "Готово"
**Очікування:**
- `POST /agents → 201`
- Агент видимий у списку `/agents`
### E2E-06 — Фінальний redirect
**Кроки:**
1. Step 6 → "Перейти в чат"
**Очікування:**
- Перенаправлення на `/t/:teamId/c/:channelId`
- Відображено стрічку повідомлень
## 3. Chat Tests
### CHAT-01 — Відправка повідомлення
**Кроки:**
1. Ввести текст.
2. Натиснути "Надіслати".
**Очікування:**
- `POST /channels/{id}/messages → 201`
- Повідомлення зʼявляється у стрічці без reload
### CHAT-02 — Пагінація стрічки (cursor)
**Кроки:**
1. Прокрутити догори.
2. Завантаження старих повідомлень.
**Очікування:**
- `GET /messages?cursor=...`
- Нові елементи додаються на початок
### CHAT-03 — Публічний канал для гостей
**Кроки:**
1. Відкрити `/c/:slug` в режимі інкогніто.
2. Переглянути стрічку.
3. Спробувати відправити повідомлення.
**Очікування:**
- Read-only режим
- Кнопка "Приєднатися до спільноти"
## 4. Follow-ups Tests
### FU-01 — Створення follow-up
**Кроки:**
1. Клік по меню повідомлення → "Створити follow-up".
2. Заповнити форму.
**Очікування:**
- `POST /followups → 201`
- Follow-up у списку `/followups`
### FU-02 — Список follow-ups
**Очікування:**
- `GET /followups` працює
- Фільтрація по статусу
## 5. Projects & Tasks
### PRJ-01 — Створення проєкту
**Кроки:**
- Натиснути "Новий проєкт".
- Ввести назву.
**Очікування:**
- `POST /projects → 201`
- Проєкт у списку
### TASK-01 — Створення задачі
**Кроки:**
- Додати нову задачу в Backlog.
**Очікування:**
- `POST /projects/{id}/tasks → 201`
- Задача показана у колонці
### TASK-02 — Зміна статусу задачі
**Кроки:**
- Клікнути задачу → змінити статус.
**Очікування:**
- Статус змінений у UI
- API можна stub (MVP)
## 6. Agents
### AG-01 — Список агентів
**Очікування:**
- `GET /agents` ще до онбордингу повертає 0
- Після Step 5 → ≥1
### AG-02 — Чат із агентом (stub)
**Очікування:**
- Агент відповідає на повідомлення
- Історія залишається до reload
## 7. Error Handling
### ERR-01 — 400 Bad Request
**Наприклад:**
- порожнє поле назви спільноти
- некоректний email
**Очікування:**
- toast з повідомленням
- API не падає в консоль
### ERR-02 — 403 Forbidden
**Наприклад:**
- спроба писати в приватний канал без доступу
**Очікування:**
- toast: "Недостатньо прав"
### ERR-03 — 404 Not Found
- неправильний канал
- неправильний проєкт
Очікування:
- зрозуміла сторінка 404
- ніяких uncaught errors
## 8. Performance (MVP)
### PERF-01 — Chat latency
Очікування:
- p95 ≤ 300 мс для `GET /messages` та `POST /messages`.
### PERF-02 — WebSocket stability
Очікування:
- Нові повідомлення з'являються ≤100 мс після відправки.
- З'єднання не падає при простому використанні.
## 9. Accessibility (basic)
### A11Y-01 — Keyboard navigation
- Усі кнопки фокусуються
- Enter / Space працюють
### A11Y-02 — Контрасти
- Текст контрастний (WCAG 2.1 AA)
## 10. Успішність MVP (визначення)
MVP вважається стабільним, якщо:
- **Усі критичні E2E проходять.**
- **Немає P0/P1 багів** (блокуючих).
- **Менше 5 P2 багів.**
- **Чат та онбординг працюють стабільно.**
- **2 реальні команди використовують систему кілька днів без критичних помилок.**

103
docs/cursor/README.md Normal file
View File

@@ -0,0 +1,103 @@
# MicroDAO — Документація для Cursor
Ця папка містить структуровану документацію для розробки MVP MicroDAO з використанням Cursor AI.
## Структура документації
### 00_overview_microdao.md
Загальний огляд системи MicroDAO, ключові модулі та посилання на інші документи.
**Коли використовувати:** Для швидкого ознайомлення з проєктом.
### 01_product_brief_mvp.md
Product Requirements для MVP: мета, персони, ключові сценарії, обсяг та межі.
**Коли використовувати:** Для розуміння бізнес-логіки та цілей MVP.
### 02_architecture_basics.md
Технічна архітектура: стек, основні сервіси, дані та моделі, WebSockets, приватність.
**Коли використовувати:** Для розуміння технічної архітектури та інтеграцій.
### 03_api_core_snapshot.md
Стисла витяжка з OpenAPI 3.1: всі ендпоїнти, необхідні для MVP, з прикладами запитів та відповідей.
**Коли використовувати:** При створенні API клієнтів та інтеграції з бекендом.
### 04_ui_ux_onboarding_chat.md
UI/UX специфікація: онбординг, чат, публічний канал, стандарти дизайну, адаптивність.
**Коли використовувати:** При розробці UI компонентів та сторінок.
### 05_coding_standards.md
Стандарти кодування: TypeScript правила, React патерни, обробка помилок, i18n, UI стандарти.
**Коли використовувати:** При написанні коду для забезпечення якості та узгодженості.
### 06_tasks_onboarding_mvp.md
Технічні задачі для Cursor: детальні специфікації для кожної функції MVP з acceptance criteria.
**Коли використовувати:** Як "панель управління" розробкою — копіювати задачі в Cursor.
### 07_testing_checklist_mvp.md
Тестовий чеклист: критичні E2E тести, тести чату, follow-ups, проєктів, агентів, обробка помилок.
**Коли використовувати:** При тестуванні та перевірці готовності MVP.
## Як використовувати з Cursor
### 1. Початкове налаштування
Додай всю папку `docs/cursor/` в контекст Cursor або вкажи на конкретні файли при створенні промптів.
### 2. Створення промптів
Використовуй формат з `06_tasks_onboarding_mvp.md`:
```
You are a senior React/TypeScript engineer.
Task: [Назва задачі з 06_tasks_onboarding_mvp.md]
Context:
- Product brief: 01_product_brief_mvp.md
- API specs: 03_api_core_snapshot.md
- UI/UX: 04_ui_ux_onboarding_chat.md
- Coding standards: 05_coding_standards.md
Please output:
- List of files to modify/create
- Code diff
- Short summary
```
### 3. Перевірка коду
Після генерації коду перевіряй відповідність:
- `05_coding_standards.md` — стандарти кодування
- `07_testing_checklist_mvp.md` — тестові сценарії
## Швидкий старт
1. **Ознайомся з проєктом:** `00_overview_microdao.md`
2. **Зрозумій бізнес-логіку:** `01_product_brief_mvp.md`
3. **Вивчи архітектуру:** `02_architecture_basics.md`
4. **Почни з онбордингу:** `06_tasks_onboarding_mvp.md` → Block A
5. **Тестуй:** `07_testing_checklist_mvp.md`
## Важливі примітки
- Всі API контракти беріть тільки з `03_api_core_snapshot.md`
- Всі UI тексти беріть з `04_ui_ux_onboarding_chat.md`
- Дотримуйтесь стандартів з `05_coding_standards.md`
- Не вигадуйте нові API або UI елементи без узгодження
## Посилання на повну документацію
- Повна OpenAPI специфікація (за потреби)
- Data Model & Event Catalog
- Tech Spec / Технічний опис MicroDAO
- UI/UX Specification — microdao (web)
---
**Версія:** MVP v1.0
**Останнє оновлення:** 2025-01-XX

1
mermaid-diagram (1).svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 17 KiB

1
mermaid-diagram.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 16 KiB

View File

@@ -0,0 +1,417 @@
import React from "react";
import {
Activity,
AlertTriangle,
CheckCircle2,
Cpu,
Database,
GitBranch,
LineChart,
MessageSquare,
Settings,
ShieldCheck,
User,
Vote,
Wallet,
Zap,
Bot,
Boxes,
BookOpen,
FileText,
Network,
Video,
Palette,
Bell,
Store,
Plug,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Badge } from "@/components/ui/badge";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import { Progress } from "@/components/ui/progress";
// ---------- Utility types ----------
type ModuleKey =
| "home"
| "messenger"
| "projects"
| "memory"
| "parser"
| "graph"
| "meetings"
| "agents"
| "training"
| "dao"
| "treasury"
| "integrations"
| "market"
| "analytics"
| "notifications"
| "admin"
| "creative"
| "security"
| "profile";
const MODULES: Array<{
key: ModuleKey;
label: string;
icon: React.ElementType;
}> = [
{ key: "home", label: "Головна / Оркестратор", icon: Cpu },
{ key: "messenger", label: "Чати та канали", icon: MessageSquare },
{ key: "projects", label: "Проєкти", icon: Boxes },
{ key: "memory", label: "База знань", icon: BookOpen },
{ key: "parser", label: "Парсер документів", icon: FileText },
{ key: "graph", label: "Граф знань", icon: Network },
{ key: "meetings", label: "Зустрічі", icon: Video },
{ key: "agents", label: "Агенти", icon: Bot },
{ key: "training", label: "Навчальний кабінет", icon: GitBranch },
{ key: "dao", label: "Голосування / DAO", icon: Vote },
{ key: "treasury", label: "Фінанси / Казна", icon: Wallet },
{ key: "integrations", label: "Інтеграції", icon: Plug },
{ key: "market", label: "Маркетплейс", icon: Store },
{ key: "analytics", label: "Аналітика", icon: LineChart },
{ key: "notifications", label: "Сповіщення", icon: Bell },
{ key: "admin", label: "Адмін-панель", icon: Settings },
{ key: "creative", label: "Креативна студія", icon: Palette },
{ key: "security", label: "Безпека / Аудит", icon: ShieldCheck },
{ key: "profile", label: "Профіль", icon: User },
];
// ---------- Reusable UI blocks ----------
function Sidebar({ active, onSelect }: { active: ModuleKey; onSelect: (k: ModuleKey) => void }) {
return (
<aside className="h-full w-72 border-r bg-white/60 backdrop-blur-sm">
<div className="p-4 border-b flex items-center gap-2">
<Cpu className="h-5 w-5" />
<span className="font-semibold">MicroDAO</span>
<Badge variant="secondary" className="ml-auto">orchestrator</Badge>
</div>
<nav className="p-2 space-y-1 overflow-y-auto h-[calc(100%-3.5rem)]">
{MODULES.map((m) => (
<button
key={m.key}
onClick={() => onSelect(m.key)}
className={`w-full flex items-center gap-3 rounded-xl px-3 py-2 hover:bg-slate-100 transition ${
active === m.key ? "bg-slate-900 text-white hover:bg-slate-900" : ""
}`}
aria-current={active === m.key}
>
<m.icon className="h-4 w-4" />
<span className="text-sm text-left truncate">{m.label}</span>
{m.key === "notifications" && (
<Badge className={`ml-auto ${active === m.key ? "bg-white text-slate-900" : ""}`}>3</Badge>
)}
</button>
))}
</nav>
</aside>
);
}
function Topbar() {
return (
<div className="h-14 border-b flex items-center gap-3 px-4 bg-white/70 backdrop-blur">
<Input placeholder="Пошук по всіх модулях" className="max-w-md" />
<Tabs defaultValue="dao" className="ml-2">
<TabsList>
<TabsTrigger value="private">Private</TabsTrigger>
<TabsTrigger value="dao">DAO</TabsTrigger>
<TabsTrigger value="public">Public</TabsTrigger>
</TabsList>
</Tabs>
<div className="ml-auto flex items-center gap-2">
<Badge variant="outline" className="flex items-center gap-1"><Database className="h-4 w-4"/>DB ok</Badge>
<Badge variant="outline" className="flex items-center gap-1"><Zap className="h-4 w-4"/>DAGI sync</Badge>
<Button variant="secondary" size="sm" className="gap-2"><Settings className="h-4 w-4"/>Налаштування</Button>
</div>
</div>
);
}
function HealthGrid() {
const items = [
{ title: "Messenger", ok: true },
{ title: "Parser", ok: false },
{ title: "KB Core", ok: true },
{ title: "RAG", ok: true },
{ title: "Wallet", ok: true },
{ title: "DAO", ok: true },
];
return (
<div className="grid grid-cols-2 lg:grid-cols-3 gap-3">
{items.map((x) => (
<Card key={x.title} className="rounded-2xl">
<CardHeader className="py-3">
<CardTitle className="text-sm font-medium flex items-center gap-2">
{x.ok ? (
<CheckCircle2 className="h-4 w-4 text-emerald-600" />
) : (
<AlertTriangle className="h-4 w-4 text-amber-600" />
)}
{x.title}
</CardTitle>
</CardHeader>
<CardContent className="py-2">
<div className="text-xs text-slate-500">{x.ok ? "Працює стабільно" : "Черга задач > p95"}</div>
</CardContent>
</Card>
))}
</div>
);
}
function OrchestratorChat() {
return (
<Card className="rounded-2xl h-full flex flex-col">
<CardHeader className="py-3">
<CardTitle className="text-sm">Чат з Оркестратором</CardTitle>
</CardHeader>
<CardContent className="flex-1 flex flex-col gap-3">
<div className="flex-1 rounded-xl border bg-slate-50 p-3 text-sm overflow-auto">
<div className="text-slate-500">Вітаю. Чим допомогти? Наприклад: "Розбери PDF та створй короткий бріф у Проєктах".</div>
</div>
<div className="flex items-center gap-2">
<Textarea placeholder="Запитайте або використайте @agent" className="min-h-[44px]" />
<Button className="whitespace-nowrap">Надіслати</Button>
</div>
</CardContent>
</Card>
);
}
function ActivityFeed() {
const rows = [
{ t: "ingest.completed", d: "USDO готово", ts: "09:15" },
{ t: "message.created", d: "#general", ts: "09:12" },
{ t: "vote.finalized", d: "Постанова #12", ts: "08:55" },
{ t: "payment.sent", d: "Reward 2.5 μUTIL", ts: "08:40" },
];
return (
<Card className="rounded-2xl h-full">
<CardHeader className="py-3"><CardTitle className="text-sm">Стрічка подій</CardTitle></CardHeader>
<CardContent>
<ul className="space-y-2 text-sm">
{rows.map((r, i) => (
<li key={i} className="flex items-center gap-2">
<Activity className="h-4 w-4 text-slate-500"/>
<span className="font-medium">{r.t}</span>
<span className="text-slate-500"> {r.d}</span>
<span className="ml-auto text-xs text-slate-400">{r.ts}</span>
</li>
))}
</ul>
</CardContent>
</Card>
);
}
function TasksTable() {
const rows = [
{ agent: "parser-agent", task: "PDF→USDO", status: "running", eta: "2m" },
{ agent: "project-agent", task: "Бріф + Kanban", status: "queued", eta: "—" },
{ agent: "wallet-agent", task: "Нарахувати винагороду", status: "completed", eta: "0" },
];
const color = (s: string) => (s === "completed" ? "text-emerald-600" : s === "running" ? "text-amber-600" : "text-slate-500");
return (
<Card className="rounded-2xl h-full">
<CardHeader className="py-3"><CardTitle className="text-sm">Поточні задачі агентів</CardTitle></CardHeader>
<CardContent>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="text-left text-slate-500">
<tr>
<th className="py-2 pr-4">Агент</th>
<th className="py-2 pr-4">Задача</th>
<th className="py-2 pr-4">Статус</th>
<th className="py-2">ETA</th>
</tr>
</thead>
<tbody>
{rows.map((r, i) => (
<tr key={i} className="border-t">
<td className="py-2 pr-4 font-mono text-xs">{r.agent}</td>
<td className="py-2 pr-4">{r.task}</td>
<td className={`py-2 pr-4 ${color(r.status)}`}>{r.status}</td>
<td className="py-2">{r.eta}</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
);
}
function AgentGraph() {
// Minimal static SVG graph placeholder
return (
<Card className="rounded-2xl h-full">
<CardHeader className="py-3"><CardTitle className="text-sm">Граф агентів</CardTitle></CardHeader>
<CardContent>
<svg viewBox="0 0 400 220" className="w-full h-48">
<defs>
<filter id="shadow" x="-50%" y="-50%" width="200%" height="200%">
<feDropShadow dx="0" dy="1" stdDeviation="2" floodOpacity="0.2" />
</filter>
</defs>
<g filter="url(#shadow)">
<circle cx="200" cy="110" r="28" className="fill-slate-900" />
<text x="200" y="115" textAnchor="middle" className="fill-white text-xs">Orch</text>
{[
{ x: 80, y: 40, t: "Parser" },
{ x: 320, y: 40, t: "KB" },
{ x: 80, y: 180, t: "Wallet" },
{ x: 320, y: 180, t: "DAO" },
].map((n, i) => (
<g key={i}>
<line x1="200" y1="110" x2={n.x} y2={n.y} stroke="#94a3b8" />
<circle cx={n.x} cy={n.y} r="22" className="fill-white stroke-slate-300" />
<text x={n.x} y={n.y + 4} textAnchor="middle" className="fill-slate-700 text-xs">{n.t}</text>
</g>
))}
</g>
</svg>
</CardContent>
</Card>
);
}
function HomeOrchestrator() {
return (
<div className="p-4 space-y-4">
<HealthGrid />
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
<OrchestratorChat />
<div className="grid grid-rows-2 gap-4">
<ActivityFeed />
<TasksTable />
</div>
</div>
<AgentGraph />
</div>
);
}
function Placeholder({ title }: { title: string }) {
return (
<div className="p-6 text-sm text-slate-600">
<p className="mb-2">Розділ: <span className="font-medium">{title}</span></p>
<p>Тут буде детальний екран модуля. Структура готова до підключення реальних даних та маршрутів.</p>
</div>
);
}
function StartScreen({ onCreateDAO, onJoinDAO, onSolo, agentReady, onInstallAgent }: { onCreateDAO: () => void; onJoinDAO: () => void; onSolo: () => void; agentReady: boolean; onInstallAgent: () => void; }) {
return (
<div className="h-full flex items-center justify-center p-8">
<div className="max-w-3xl w-full grid grid-cols-1 lg:grid-cols-3 gap-4">
<Card className="rounded-2xl lg:col-span-2">
<CardHeader>
<CardTitle>Ласкаво просимо до MicroDAO</CardTitle>
</CardHeader>
<CardContent className="space-y-4 text-sm">
<p className="text-slate-600">Почніть зі створення спільноти DAO, приєднайтесь за інвайтом або спробуйте Solo-режим з локальним агентом.</p>
<div className="grid sm:grid-cols-3 gap-2">
<Button onClick={onCreateDAO} className="rounded-xl">Створити DAO</Button>
<Button variant="secondary" onClick={onJoinDAO} className="rounded-xl">Приєднатись</Button>
<Button variant="outline" onClick={onSolo} className="rounded-xl">Solo-режим</Button>
</div>
<div className="text-xs text-slate-500">Можна змінити вибір пізніше у Налаштуваннях.</div>
</CardContent>
</Card>
<Card className="rounded-2xl">
<CardHeader className="pb-2">
<CardTitle className="text-sm flex items-center gap-2"><Bot className="h-4 w-4"/>Локальний агент</CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm">
{agentReady ? (
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-emerald-600"/>Готовий до роботи</div>
) : (
<>
<div className="flex items-center gap-2"><AlertTriangle className="h-4 w-4 text-amber-600"/>Ще не встановлено</div>
<Button size="sm" onClick={onInstallAgent} className="rounded-xl w-full mt-1">Встановити агента</Button>
<div className="text-xs text-slate-500">Обсяг моделі підбирається автоматично за можливостями пристрою.</div>
</>
)}
</CardContent>
</Card>
</div>
</div>
);
}
export default function OrchestratorLayout() {
const [active, setActive] = React.useState<ModuleKey>("home");
const [orgId, setOrgId] = React.useState<string | null>(null); // null = ще немає DAO/спільноти
const [agentReady, setAgentReady] = React.useState<boolean>(false);
const content = () => {
if (!orgId) {
return (
<StartScreen
onCreateDAO={() => setOrgId("dao-created")}
onJoinDAO={() => setOrgId("dao-joined")}
onSolo={() => setOrgId("solo-mode")}
agentReady={agentReady}
onInstallAgent={() => setAgentReady(true)}
/>
);
}
switch (active) {
case "home":
return <HomeOrchestrator />;
case "messenger":
return <Placeholder title="Чати та канали" />;
case "projects":
return <Placeholder title="Проєкти" />;
case "memory":
return <Placeholder title="База знань" />;
case "parser":
return <Placeholder title="Парсер документів" />;
case "graph":
return <Placeholder title="Граф знань" />;
case "meetings":
return <Placeholder title="Зустрічі" />;
case "agents":
return <Placeholder title="Агенти" />;
case "training":
return <Placeholder title="Навчальний кабінет" />;
case "dao":
return <Placeholder title="Голосування / DAO" />;
case "treasury":
return <Placeholder title="Фінанси / Казна" />;
case "integrations":
return <Placeholder title="Інтеграції" />;
case "market":
return <Placeholder title="Маркетплейс" />;
case "analytics":
return <Placeholder title="Аналітика" />;
case "notifications":
return <Placeholder title="Сповіщення" />;
case "admin":
return <Placeholder title="Адмін-панель" />;
case "creative":
return <Placeholder title="Креативна студія" />;
case "security":
return <Placeholder title="Безпека / Аудит" />;
case "profile":
return <Placeholder title="Профіль" />;
}
};
return (
<div className="h-[100vh] w-full grid grid-cols-[18rem_1fr] bg-slate-50 text-slate-900">
<Sidebar active={active} onSelect={setActive} />
<main className="flex flex-col">
<Topbar />
<div className="flex-1 overflow-auto">{content()}</div>
</main>
</div>
);
}

View File

@@ -0,0 +1,454 @@
import React from "react";
import {
Activity,
AlertTriangle,
CheckCircle2,
Cpu,
Database,
GitBranch,
LineChart,
MessageSquare,
Settings,
ShieldCheck,
User,
Vote,
Wallet,
Zap,
Bot,
Boxes,
BookOpen,
FileText,
Network,
Video,
Palette,
Bell,
Store,
Plug,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Badge } from "@/components/ui/badge";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import { Progress } from "@/components/ui/progress";
// ---------- Utility types ----------
type ModuleKey =
| "home"
| "messenger"
| "projects"
| "memory"
| "parser"
| "graph"
| "meetings"
| "agents"
| "training"
| "dao"
| "treasury"
| "integrations"
| "market"
| "analytics"
| "notifications"
| "admin"
| "creative"
| "security"
| "profile";
const MODULES: Array<{
key: ModuleKey;
label: string;
icon: React.ElementType;
}> = [
{ key: "home", label: "Головна / Оркестратор", icon: Cpu },
{ key: "messenger", label: "Чати та канали", icon: MessageSquare },
{ key: "projects", label: "Проєкти", icon: Boxes },
{ key: "memory", label: "База знань", icon: BookOpen },
{ key: "parser", label: "Парсер документів", icon: FileText },
{ key: "graph", label: "Граф знань", icon: Network },
{ key: "meetings", label: "Зустрічі", icon: Video },
{ key: "agents", label: "Агенти", icon: Bot },
{ key: "training", label: "Навчальний кабінет", icon: GitBranch },
{ key: "dao", label: "Голосування / DAO", icon: Vote },
{ key: "treasury", label: "Фінанси / Казна", icon: Wallet },
{ key: "integrations", label: "Інтеграції", icon: Plug },
{ key: "market", label: "Маркетплейс", icon: Store },
{ key: "analytics", label: "Аналітика", icon: LineChart },
{ key: "notifications", label: "Сповіщення", icon: Bell },
{ key: "admin", label: "Адмін-панель", icon: Settings },
{ key: "creative", label: "Креативна студія", icon: Palette },
{ key: "security", label: "Безпека / Аудит", icon: ShieldCheck },
{ key: "profile", label: "Профіль", icon: User },
];
// ---------- Reusable UI blocks ----------
function Sidebar({ active, onSelect }: { active: ModuleKey; onSelect: (k: ModuleKey) => void }) {
return (
<aside className="h-full w-72 border-r bg-white/60 backdrop-blur-sm">
<div className="p-4 border-b flex items-center gap-2">
<Cpu className="h-5 w-5" />
<span className="font-semibold">MicroDAO</span>
<Badge variant="secondary" className="ml-auto">orchestrator</Badge>
</div>
<nav className="p-2 space-y-1 overflow-y-auto h-[calc(100%-3.5rem)]">
{MODULES.map((m) => (
<button
key={m.key}
onClick={() => onSelect(m.key)}
className={`w-full flex items-center gap-3 rounded-xl px-3 py-2 hover:bg-slate-100 transition ${
active === m.key ? "bg-slate-900 text-white hover:bg-slate-900" : ""
}`}
aria-current={active === m.key}
>
<m.icon className="h-4 w-4" />
<span className="text-sm text-left truncate">{m.label}</span>
{m.key === "notifications" && (
<Badge className={`ml-auto ${active === m.key ? "bg-white text-slate-900" : ""}`}>3</Badge>
)}
</button>
))}
</nav>
</aside>
);
}
function Topbar() {
return (
<div className="h-14 border-b flex items-center gap-3 px-4 bg-white/70 backdrop-blur">
<Input placeholder="Пошук по всіх модулях" className="max-w-md" />
<Tabs defaultValue="dao" className="ml-2">
<TabsList>
<TabsTrigger value="private">Private</TabsTrigger>
<TabsTrigger value="dao">DAO</TabsTrigger>
<TabsTrigger value="public">Public</TabsTrigger>
</TabsList>
</Tabs>
<div className="ml-auto flex items-center gap-2">
<Badge variant="outline" className="flex items-center gap-1"><Database className="h-4 w-4"/>DB ok</Badge>
<Badge variant="outline" className="flex items-center gap-1"><Zap className="h-4 w-4"/>DAGI sync</Badge>
<Button variant="secondary" size="sm" className="gap-2"><Settings className="h-4 w-4"/>Налаштування</Button>
</div>
</div>
);
}
function HealthGrid() {
const items = [
{ title: "Messenger", ok: true },
{ title: "Parser", ok: false },
{ title: "KB Core", ok: true },
{ title: "RAG", ok: true },
{ title: "Wallet", ok: true },
{ title: "DAO", ok: true },
];
return (
<div className="grid grid-cols-2 lg:grid-cols-3 gap-3">
{items.map((x) => (
<Card key={x.title} className="rounded-2xl">
<CardHeader className="py-3">
<CardTitle className="text-sm font-medium flex items-center gap-2">
{x.ok ? (
<CheckCircle2 className="h-4 w-4 text-emerald-600" />
) : (
<AlertTriangle className="h-4 w-4 text-amber-600" />
)}
{x.title}
</CardTitle>
</CardHeader>
<CardContent className="py-2">
<div className="text-xs text-slate-500">{x.ok ? "Працює стабільно" : "Черга задач > p95"}</div>
</CardContent>
</Card>
))}
</div>
);
}
function OrchestratorChat() {
return (
<Card className="rounded-2xl h-full flex flex-col">
<CardHeader className="py-3">
<CardTitle className="text-sm">Чат з Оркестратором</CardTitle>
</CardHeader>
<CardContent className="flex-1 flex flex-col gap-3">
<div className="flex-1 rounded-xl border bg-slate-50 p-3 text-sm overflow-auto">
<div className="text-slate-500">Вітаю. Чим допомогти? Наприклад: "Розбери PDF та створй короткий бріф у Проєктах".</div>
</div>
<div className="flex items-center gap-2">
<Textarea placeholder="Запитайте або використайте @agent" className="min-h-[44px]" />
<Button className="whitespace-nowrap">Надіслати</Button>
</div>
</CardContent>
</Card>
);
}
function ActivityFeed() {
const rows = [
{ t: "ingest.completed", d: "USDO готово", ts: "09:15" },
{ t: "message.created", d: "#general", ts: "09:12" },
{ t: "vote.finalized", d: "Постанова #12", ts: "08:55" },
{ t: "payment.sent", d: "Reward 2.5 μUTIL", ts: "08:40" },
];
return (
<Card className="rounded-2xl h-full">
<CardHeader className="py-3"><CardTitle className="text-sm">Стрічка подій</CardTitle></CardHeader>
<CardContent>
<ul className="space-y-2 text-sm">
{rows.map((r, i) => (
<li key={i} className="flex items-center gap-2">
<Activity className="h-4 w-4 text-slate-500"/>
<span className="font-medium">{r.t}</span>
<span className="text-slate-500"> {r.d}</span>
<span className="ml-auto text-xs text-slate-400">{r.ts}</span>
</li>
))}
</ul>
</CardContent>
</Card>
);
}
function TasksTable() {
const rows = [
{ agent: "parser-agent", task: "PDF→USDO", status: "running", eta: "2m" },
{ agent: "project-agent", task: "Бріф + Kanban", status: "queued", eta: "—" },
{ agent: "wallet-agent", task: "Нарахувати винагороду", status: "completed", eta: "0" },
];
const color = (s: string) => (s === "completed" ? "text-emerald-600" : s === "running" ? "text-amber-600" : "text-slate-500");
return (
<Card className="rounded-2xl h-full">
<CardHeader className="py-3"><CardTitle className="text-sm">Поточні задачі агентів</CardTitle></CardHeader>
<CardContent>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="text-left text-slate-500">
<tr>
<th className="py-2 pr-4">Агент</th>
<th className="py-2 pr-4">Задача</th>
<th className="py-2 pr-4">Статус</th>
<th className="py-2">ETA</th>
</tr>
</thead>
<tbody>
{rows.map((r, i) => (
<tr key={i} className="border-t">
<td className="py-2 pr-4 font-mono text-xs">{r.agent}</td>
<td className="py-2 pr-4">{r.task}</td>
<td className={`py-2 pr-4 ${color(r.status)}`}>{r.status}</td>
<td className="py-2">{r.eta}</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
);
}
function AgentGraph() {
// Minimal static SVG graph placeholder
return (
<Card className="rounded-2xl h-full">
<CardHeader className="py-3"><CardTitle className="text-sm">Граф агентів</CardTitle></CardHeader>
<CardContent>
<svg viewBox="0 0 400 220" className="w-full h-48">
<defs>
<filter id="shadow" x="-50%" y="-50%" width="200%" height="200%">
<feDropShadow dx="0" dy="1" stdDeviation="2" floodOpacity="0.2" />
</filter>
</defs>
<g filter="url(#shadow)">
<circle cx="200" cy="110" r="28" className="fill-slate-900" />
<text x="200" y="115" textAnchor="middle" className="fill-white text-xs">Orch</text>
{[
{ x: 80, y: 40, t: "Parser" },
{ x: 320, y: 40, t: "KB" },
{ x: 80, y: 180, t: "Wallet" },
{ x: 320, y: 180, t: "DAO" },
].map((n, i) => (
<g key={i}>
<line x1="200" y1="110" x2={n.x} y2={n.y} stroke="#94a3b8" />
<circle cx={n.x} cy={n.y} r="22" className="fill-white stroke-slate-300" />
<text x={n.x} y={n.y + 4} textAnchor="middle" className="fill-slate-700 text-xs">{n.t}</text>
</g>
))}
</g>
</svg>
</CardContent>
</Card>
);
}
function HomeOrchestrator() {
return (
<div className="p-4 space-y-4">
<HealthGrid />
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
<OrchestratorChat />
<div className="grid grid-rows-2 gap-4">
<ActivityFeed />
<TasksTable />
</div>
</div>
<AgentGraph />
</div>
);
}
function Placeholder({ title }: { title: string }) {
return (
<div className="p-6 text-sm text-slate-600">
<p className="mb-2">Розділ: <span className="font-medium">{title}</span></p>
<p>Тут буде детальний екран модуля. Структура готова до підключення реальних даних та маршрутів.</p>
</div>
);
}
function StartScreen({ onCreateDAO, onJoinDAO, onSolo, agentReady, onInstallAgent, installing, progress }: { onCreateDAO: () => void; onJoinDAO: () => void; onSolo: () => void; agentReady: boolean; onInstallAgent: () => void; installing: boolean; progress: number; }) {
return (
<div className="h-full flex items-center justify-center p-8">
<div className="max-w-3xl w-full grid grid-cols-1 lg:grid-cols-3 gap-4">
<Card className="rounded-2xl lg:col-span-2">
<CardHeader>
<CardTitle>Ласкаво просимо до MicroDAO</CardTitle>
</CardHeader>
<CardContent className="space-y-4 text-sm">
<p className="text-slate-600">Почніть зі створення спільноти DAO, приєднайтесь за інвайтом або спробуйте Solo-режим з локальним агентом.</p>
<div className="grid sm:grid-cols-3 gap-2">
<Button onClick={onCreateDAO} className="rounded-xl">Створити DAO</Button>
<Button variant="secondary" onClick={onJoinDAO} className="rounded-xl">Приєднатись</Button>
<Button variant="outline" onClick={onSolo} className="rounded-xl">Solo-режим</Button>
</div>
<div className="text-xs text-slate-500">Можна змінити вибір пізніше у Налаштуваннях.</div>
</CardContent>
</Card>
<Card className="rounded-2xl">
<CardHeader className="pb-2">
<CardTitle className="text-sm flex items-center gap-2"><Bot className="h-4 w-4"/>Локальний агент</CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm">
{agentReady ? (
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-emerald-600"/>Готовий до роботи</div>
) : (
<>
<div className="flex items-center gap-2">
{installing ? (
<AlertTriangle className="h-4 w-4 text-amber-600" />
) : (
<AlertTriangle className="h-4 w-4 text-amber-600" />
)}
{installing ? 'Встановлення моделі…' : 'Ще не встановлено'}
</div>
<Button size="sm" disabled={installing} onClick={onInstallAgent} className="rounded-xl w-full mt-1">
{installing ? 'Встановлення…' : 'Встановити агента'}
</Button>
{installing && (
<div className="space-y-1">
<Progress value={progress} />
<div className="text-xs text-slate-500">Завантаження моделі: {Math.round(progress)}%</div>
</div>
)}
<div className="text-xs text-slate-500">Обсяг моделі підбирається автоматично за можливостями пристрою.</div>
</>
)}
</CardContent>
</Card>
</div>
</div>
);
}
export default function OrchestratorLayout() {
const [active, setActive] = React.useState<ModuleKey>("home");
const [orgId, setOrgId] = React.useState<string | null>(null); // null = ще немає DAO/спільноти
const [agentReady, setAgentReady] = React.useState<boolean>(false);
const [installing, setInstalling] = React.useState<boolean>(false);
const [progress, setProgress] = React.useState<number>(0);
// Симуляція інсталяції агента/моделі
const handleInstallAgent = () => {
if (installing || agentReady) return;
setInstalling(true);
setProgress(0);
const timer = setInterval(() => {
setProgress((p) => {
const next = Math.min(100, p + Math.random() * 18 + 5);
if (next >= 100) {
clearInterval(timer);
setInstalling(false);
setAgentReady(true);
}
return next;
});
}, 400);
};
const content = () => {
if (!orgId) {
return (
<StartScreen
onCreateDAO={() => setOrgId("dao-created")}
onJoinDAO={() => setOrgId("dao-joined")}
onSolo={() => setOrgId("solo-mode")}
agentReady={agentReady}
onInstallAgent={handleInstallAgent}
installing={installing}
progress={progress}
/>
);
}
switch (active) {
case "home":
return <HomeOrchestrator />;
case "messenger":
return <Placeholder title="Чати та канали" />;
case "projects":
return <Placeholder title="Проєкти" />;
case "memory":
return <Placeholder title="База знань" />;
case "parser":
return <Placeholder title="Парсер документів" />;
case "graph":
return <Placeholder title="Граф знань" />;
case "meetings":
return <Placeholder title="Зустрічі" />;
case "agents":
return <Placeholder title="Агенти" />;
case "training":
return <Placeholder title="Навчальний кабінет" />;
case "dao":
return <Placeholder title="Голосування / DAO" />;
case "treasury":
return <Placeholder title="Фінанси / Казна" />;
case "integrations":
return <Placeholder title="Інтеграції" />;
case "market":
return <Placeholder title="Маркетплейс" />;
case "analytics":
return <Placeholder title="Аналітика" />;
case "notifications":
return <Placeholder title="Сповіщення" />;
case "admin":
return <Placeholder title="Адмін-панель" />;
case "creative":
return <Placeholder title="Креативна студія" />;
case "security":
return <Placeholder title="Безпека / Аудит" />;
case "profile":
return <Placeholder title="Профіль" />;
}
};
return (
<div className="h-[100vh] w-full grid grid-cols-[18rem_1fr] bg-slate-50 text-slate-900">
<Sidebar active={active} onSelect={setActive} />
<main className="flex flex-col">
<Topbar />
<div className="flex-1 overflow-auto">{content()}</div>
</main>
</div>
);
}

View File

@@ -0,0 +1,525 @@
import React from "react";
import {
Activity,
AlertTriangle,
CheckCircle2,
Cpu,
Database,
GitBranch,
LineChart,
MessageSquare,
Settings,
ShieldCheck,
User,
Vote,
Wallet,
Zap,
Bot,
Boxes,
BookOpen,
FileText,
Network,
Video,
Palette,
Bell,
Store,
Plug,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Badge } from "@/components/ui/badge";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import { Progress } from "@/components/ui/progress";
// ---------- Utility types ----------
type ModuleKey =
| "home"
| "messenger"
| "projects"
| "memory"
| "parser"
| "graph"
| "meetings"
| "agents"
| "training"
| "dao"
| "treasury"
| "integrations"
| "market"
| "analytics"
| "notifications"
| "admin"
| "creative"
| "security"
| "profile";
const MODULES: Array<{
key: ModuleKey;
label: string;
icon: React.ElementType;
}> = [
{ key: "home", label: "Головна / Оркестратор", icon: Cpu },
{ key: "messenger", label: "Чати та канали", icon: MessageSquare },
{ key: "projects", label: "Проєкти", icon: Boxes },
{ key: "memory", label: "База знань", icon: BookOpen },
{ key: "parser", label: "Парсер документів", icon: FileText },
{ key: "graph", label: "Граф знань", icon: Network },
{ key: "meetings", label: "Зустрічі", icon: Video },
{ key: "agents", label: "Агенти", icon: Bot },
{ key: "training", label: "Навчальний кабінет", icon: GitBranch },
{ key: "dao", label: "Голосування / DAO", icon: Vote },
{ key: "treasury", label: "Фінанси / Казна", icon: Wallet },
{ key: "integrations", label: "Інтеграції", icon: Plug },
{ key: "market", label: "Маркетплейс", icon: Store },
{ key: "analytics", label: "Аналітика", icon: LineChart },
{ key: "notifications", label: "Сповіщення", icon: Bell },
{ key: "admin", label: "Адмін-панель", icon: Settings },
{ key: "creative", label: "Креативна студія", icon: Palette },
{ key: "security", label: "Безпека / Аудит", icon: ShieldCheck },
{ key: "profile", label: "Профіль", icon: User },
];
// ---------- Reusable UI blocks ----------
function Sidebar({ active, onSelect }: { active: ModuleKey; onSelect: (k: ModuleKey) => void }) {
return (
<aside className="h-full w-72 border-r bg-white/60 backdrop-blur-sm">
<div className="p-4 border-b flex items-center gap-2">
<Cpu className="h-5 w-5" />
<span className="font-semibold">MicroDAO</span>
<Badge variant="secondary" className="ml-auto">orchestrator</Badge>
</div>
<nav className="p-2 space-y-1 overflow-y-auto h-[calc(100%-3.5rem)]">
{MODULES.map((m) => (
<button
key={m.key}
onClick={() => onSelect(m.key)}
className={`w-full flex items-center gap-3 rounded-xl px-3 py-2 hover:bg-slate-100 transition ${
active === m.key ? "bg-slate-900 text-white hover:bg-slate-900" : ""
}`}
aria-current={active === m.key}
>
<m.icon className="h-4 w-4" />
<span className="text-sm text-left truncate">{m.label}</span>
{m.key === "notifications" && (
<Badge className={`ml-auto ${active === m.key ? "bg-white text-slate-900" : ""}`}>3</Badge>
)}
</button>
))}
</nav>
</aside>
);
}
function Topbar({ netOnline, orchOk }: { netOnline: boolean; orchOk: boolean }) {
return (
<div className="h-14 border-b flex items-center gap-3 px-4 bg-white/70 backdrop-blur">
<Input placeholder="Пошук по всіх модулях" className="max-w-md" />
<Tabs defaultValue="dao" className="ml-2">
<TabsList>
<TabsTrigger value="private">Private</TabsTrigger>
<TabsTrigger value="dao">DAO</TabsTrigger>
<TabsTrigger value="public">Public</TabsTrigger>
</TabsList>
</Tabs>
<div className="ml-auto flex items-center gap-2">
<Badge variant="outline" className="flex items-center gap-1">
<Zap className={`h-4 w-4 ${netOnline ? 'text-emerald-600' : 'text-amber-600'}`}/>
{netOnline ? 'Online' : 'Offline'}
</Badge>
<Badge variant="outline" className="flex items-center gap-1">
<Database className={`h-4 w-4 ${orchOk ? 'text-emerald-600' : 'text-amber-600'}`}/>
Orchestrator {orchOk ? 'ok' : 'unreachable'}
</Badge>
<Button variant="secondary" size="sm" className="gap-2"><Settings className="h-4 w-4"/>Налаштування</Button>
</div>
</div>
);
}
function HealthGrid() {
const items = [
{ title: "Messenger", ok: true },
{ title: "Parser", ok: false },
{ title: "KB Core", ok: true },
{ title: "RAG", ok: true },
{ title: "Wallet", ok: true },
{ title: "DAO", ok: true },
];
return (
<div className="grid grid-cols-2 lg:grid-cols-3 gap-3">
{items.map((x) => (
<Card key={x.title} className="rounded-2xl">
<CardHeader className="py-3">
<CardTitle className="text-sm font-medium flex items-center gap-2">
{x.ok ? (
<CheckCircle2 className="h-4 w-4 text-emerald-600" />
) : (
<AlertTriangle className="h-4 w-4 text-amber-600" />
)}
{x.title}
</CardTitle>
</CardHeader>
<CardContent className="py-2">
<div className="text-xs text-slate-500">{x.ok ? "Працює стабільно" : "Черга задач > p95"}</div>
</CardContent>
</Card>
))}
</div>
);
}
function OrchestratorChat() {
return (
<Card className="rounded-2xl h-full flex flex-col">
<CardHeader className="py-3">
<CardTitle className="text-sm">Чат з Оркестратором</CardTitle>
</CardHeader>
<CardContent className="flex-1 flex flex-col gap-3">
<div className="flex-1 rounded-xl border bg-slate-50 p-3 text-sm overflow-auto">
<div className="text-slate-500">Вітаю. Чим допомогти? Наприклад: "Розбери PDF та створй короткий бріф у Проєктах".</div>
</div>
<div className="flex items-center gap-2">
<Textarea placeholder="Запитайте або використайте @agent" className="min-h-[44px]" />
<Button className="whitespace-nowrap">Надіслати</Button>
</div>
</CardContent>
</Card>
);
}
function ActivityFeed() {
const rows = [
{ t: "ingest.completed", d: "USDO готово", ts: "09:15" },
{ t: "message.created", d: "#general", ts: "09:12" },
{ t: "vote.finalized", d: "Постанова #12", ts: "08:55" },
{ t: "payment.sent", d: "Reward 2.5 μUTIL", ts: "08:40" },
];
return (
<Card className="rounded-2xl h-full">
<CardHeader className="py-3"><CardTitle className="text-sm">Стрічка подій</CardTitle></CardHeader>
<CardContent>
<ul className="space-y-2 text-sm">
{rows.map((r, i) => (
<li key={i} className="flex items-center gap-2">
<Activity className="h-4 w-4 text-slate-500"/>
<span className="font-medium">{r.t}</span>
<span className="text-slate-500"> {r.d}</span>
<span className="ml-auto text-xs text-slate-400">{r.ts}</span>
</li>
))}
</ul>
</CardContent>
</Card>
);
}
function TasksTable() {
const rows = [
{ agent: "parser-agent", task: "PDF→USDO", status: "running", eta: "2m" },
{ agent: "project-agent", task: "Бріф + Kanban", status: "queued", eta: "—" },
{ agent: "wallet-agent", task: "Нарахувати винагороду", status: "completed", eta: "0" },
];
const color = (s: string) => (s === "completed" ? "text-emerald-600" : s === "running" ? "text-amber-600" : "text-slate-500");
return (
<Card className="rounded-2xl h-full">
<CardHeader className="py-3"><CardTitle className="text-sm">Поточні задачі агентів</CardTitle></CardHeader>
<CardContent>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="text-left text-slate-500">
<tr>
<th className="py-2 pr-4">Агент</th>
<th className="py-2 pr-4">Задача</th>
<th className="py-2 pr-4">Статус</th>
<th className="py-2">ETA</th>
</tr>
</thead>
<tbody>
{rows.map((r, i) => (
<tr key={i} className="border-t">
<td className="py-2 pr-4 font-mono text-xs">{r.agent}</td>
<td className="py-2 pr-4">{r.task}</td>
<td className={`py-2 pr-4 ${color(r.status)}`}>{r.status}</td>
<td className="py-2">{r.eta}</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
);
}
function AgentGraph() {
// Minimal static SVG graph placeholder
return (
<Card className="rounded-2xl h-full">
<CardHeader className="py-3"><CardTitle className="text-sm">Граф агентів</CardTitle></CardHeader>
<CardContent>
<svg viewBox="0 0 400 220" className="w-full h-48">
<defs>
<filter id="shadow" x="-50%" y="-50%" width="200%" height="200%">
<feDropShadow dx="0" dy="1" stdDeviation="2" floodOpacity="0.2" />
</filter>
</defs>
<g filter="url(#shadow)">
<circle cx="200" cy="110" r="28" className="fill-slate-900" />
<text x="200" y="115" textAnchor="middle" className="fill-white text-xs">Orch</text>
{[
{ x: 80, y: 40, t: "Parser" },
{ x: 320, y: 40, t: "KB" },
{ x: 80, y: 180, t: "Wallet" },
{ x: 320, y: 180, t: "DAO" },
].map((n, i) => (
<g key={i}>
<line x1="200" y1="110" x2={n.x} y2={n.y} stroke="#94a3b8" />
<circle cx={n.x} cy={n.y} r="22" className="fill-white stroke-slate-300" />
<text x={n.x} y={n.y + 4} textAnchor="middle" className="fill-slate-700 text-xs">{n.t}</text>
</g>
))}
</g>
</svg>
</CardContent>
</Card>
);
}
function HomeOrchestrator() {
return (
<div className="p-4 space-y-4">
<HealthGrid />
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
<OrchestratorChat />
<div className="grid grid-rows-2 gap-4">
<ActivityFeed />
<TasksTable />
</div>
</div>
<AgentGraph />
</div>
);
}
function Placeholder({ title }: { title: string }) {
return (
<div className="p-6 text-sm text-slate-600">
<p className="mb-2">Розділ: <span className="font-medium">{title}</span></p>
<p>Тут буде детальний екран модуля. Структура готова до підключення реальних даних та маршрутів.</p>
</div>
);
}
function StartScreen({ onCreateDAO, onJoinDAO, onSolo, agentReady, onInstallAgent, installing, progress, modelProfile, setModelProfile, indexSize, setIndexSize }: { onCreateDAO: () => void; onJoinDAO: () => void; onSolo: () => void; agentReady: boolean; onInstallAgent: () => void; installing: boolean; progress: number; modelProfile: string; setModelProfile: (v: string) => void; indexSize: string; setIndexSize: (v: string) => void; }) {
return (
<div className="h-full flex items-center justify-center p-8">
<div className="max-w-3xl w-full grid grid-cols-1 lg:grid-cols-3 gap-4">
<Card className="rounded-2xl lg:col-span-2">
<CardHeader>
<CardTitle>Ласкаво просимо до MicroDAO</CardTitle>
</CardHeader>
<CardContent className="space-y-4 text-sm">
<p className="text-slate-600">Почніть зі створення спільноти DAO, приєднайтесь за інвайтом або спробуйте Solo-режим з локальним агентом.</p>
<div className="grid sm:grid-cols-3 gap-2">
<Button onClick={onCreateDAO} className="rounded-xl">Створити DAO</Button>
<Button variant="secondary" onClick={onJoinDAO} className="rounded-xl">Приєднатись</Button>
<Button variant="outline" onClick={onSolo} className="rounded-xl">Solo-режим</Button>
</div>
<div className="grid gap-3 md:grid-cols-2">
<div className="space-y-2">
<div className="text-xs uppercase text-slate-500">Профіль моделі</div>
<div className="flex flex-wrap gap-2">
{['Lite','Base','Plus','Pro'].map(p => (
<Button key={p} variant={modelProfile===p? 'default':'outline'} size="sm" className="rounded-xl" onClick={() => setModelProfile(p)}>{p}</Button>
))}
</div>
<div className="text-xs text-slate-500">Автовибір за можливостями пристрою. Можна змінити пізніше.</div>
</div>
<div className="space-y-2">
<div className="text-xs uppercase text-slate-500">Локальний індекс</div>
<div className="flex flex-wrap gap-2">
{['200MB','500MB','1GB','5GB','20GB'].map(s => (
<Button key={s} variant={indexSize===s? 'default':'outline'} size="sm" className="rounded-xl" onClick={() => setIndexSize(s)}>{s}</Button>
))}
<Button variant="outline" size="sm" className="rounded-xl">Власний шлях</Button>
</div>
<div className="text-xs text-slate-500">int8 квантування заощаджує ×34 місця.</div>
</div>
</div>
<div className="text-xs text-slate-500">Можна змінити вибір пізніше у Налаштуваннях.</div>
</CardContent>
</Card>
<Card className="rounded-2xl">
<CardHeader className="pb-2">
<CardTitle className="text-sm flex items-center gap-2"><Bot className="h-4 w-4"/>Локальний агент</CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm">
{agentReady ? (
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-emerald-600"/>Готовий до роботи</div>
) : (
<>
<div className="flex items-center gap-2">
{installing ? (
<AlertTriangle className="h-4 w-4 text-amber-600" />
) : (
<AlertTriangle className="h-4 w-4 text-amber-600" />
)}
{installing ? 'Встановлення моделі…' : 'Ще не встановлено'}
</div>
<Button size="sm" disabled={installing} onClick={onInstallAgent} className="rounded-xl w-full mt-1">
{installing ? 'Встановлення…' : 'Встановити агента'}
</Button>
{installing && (
<div className="space-y-1">
<Progress value={progress} />
<div className="text-xs text-slate-500">Завантаження моделі: {Math.round(progress)}%</div>
</div>
)}
<div className="text-xs text-slate-500">Обсяг моделі підбирається автоматично за можливостями пристрою.</div>
</>
)}
</CardContent>
</Card>
</div>
</div>
);
}
export default function OrchestratorLayout() {
const [active, setActive] = React.useState<ModuleKey>("home");
const [orgId, setOrgId] = React.useState<string | null>(null); // null = ще немає DAO/спільноти
const [agentReady, setAgentReady] = React.useState<boolean>(false);
const [installing, setInstalling] = React.useState<boolean>(false);
const [progress, setProgress] = React.useState<number>(0);
const [modelProfile, setModelProfile] = React.useState<string>('Base');
const [indexSize, setIndexSize] = React.useState<string>('500MB');
const [netOnline, setNetOnline] = React.useState<boolean>(typeof navigator !== 'undefined' ? navigator.onLine : true);
const [orchOk, setOrchOk] = React.useState<boolean>(true);
React.useEffect(() => {
const on = () => setNetOnline(true);
const off = () => setNetOnline(false);
window.addEventListener('online', on);
window.addEventListener('offline', off);
const ping = setInterval(() => {
// TODO: replace with real /healthz ping
setOrchOk((v) => (Math.random() > 0.05));
}, 5000);
return () => { window.removeEventListener('online', on); window.removeEventListener('offline', off); clearInterval(ping); };
}, []);
// Симуляція інсталяції агента/моделі
const handleInstallAgent = () => {
if (installing || agentReady) return;
setInstalling(true);
setProgress(0);
const timer = setInterval(() => {
setProgress((p) => {
const next = Math.min(100, p + Math.random() * 18 + 5);
if (next >= 100) {
clearInterval(timer);
setInstalling(false);
setAgentReady(true);
}
return next;
});
}, 400);
};
// Роутінг після вибору режиму — встановлюємо orgId і відкриваємо домашню
const goReady = (id: string) => { setOrgId(id); setActive('home'); };
const content = () => {<number>(0);
// Симуляція інсталяції агента/моделі
const handleInstallAgent = () => {
if (installing || agentReady) return;
setInstalling(true);
setProgress(0);
const timer = setInterval(() => {
setProgress((p) => {
const next = Math.min(100, p + Math.random() * 18 + 5);
if (next >= 100) {
clearInterval(timer);
setInstalling(false);
setAgentReady(true);
}
return next;
});
}, 400);
};
const content = () => {
if (!orgId) {
return (
<StartScreen
onCreateDAO={() => goReady('dao-created')}
onJoinDAO={() => goReady('dao-joined')}
onSolo={() => goReady('solo-mode')}
agentReady={agentReady}
onInstallAgent={handleInstallAgent}
installing={installing}
progress={progress}
modelProfile={modelProfile}
setModelProfile={setModelProfile}
indexSize={indexSize}
setIndexSize={setIndexSize}
progress={progress}
/>
);
}
switch (active) {
case "home":
return <HomeOrchestrator />;
case "messenger":
return <Placeholder title="Чати та канали" />;
case "projects":
return <Placeholder title="Проєкти" />;
case "memory":
return <Placeholder title="База знань" />;
case "parser":
return <Placeholder title="Парсер документів" />;
case "graph":
return <Placeholder title="Граф знань" />;
case "meetings":
return <Placeholder title="Зустрічі" />;
case "agents":
return <Placeholder title="Агенти" />;
case "training":
return <Placeholder title="Навчальний кабінет" />;
case "dao":
return <Placeholder title="Голосування / DAO" />;
case "treasury":
return <Placeholder title="Фінанси / Казна" />;
case "integrations":
return <Placeholder title="Інтеграції" />;
case "market":
return <Placeholder title="Маркетплейс" />;
case "analytics":
return <Placeholder title="Аналітика" />;
case "notifications":
return <Placeholder title="Сповіщення" />;
case "admin":
return <Placeholder title="Адмін-панель" />;
case "creative":
return <Placeholder title="Креативна студія" />;
case "security":
return <Placeholder title="Безпека / Аудит" />;
case "profile":
return <Placeholder title="Профіль" />;
}
};
return (
<div className="h-[100vh] w-full grid grid-cols-[18rem_1fr] bg-slate-50 text-slate-900">
<Sidebar active={active} onSelect={setActive} />
<main className="flex flex-col">
<Topbar netOnline={netOnline} orchOk={orchOk} />
<div className="flex-1 overflow-auto">{content()}</div>
</main>
</div>
);
}

View File

@@ -0,0 +1,525 @@
import React from "react";
import {
Activity,
AlertTriangle,
CheckCircle2,
Cpu,
Database,
GitBranch,
LineChart,
MessageSquare,
Settings,
ShieldCheck,
User,
Vote,
Wallet,
Zap,
Bot,
Boxes,
BookOpen,
FileText,
Network,
Video,
Palette,
Bell,
Store,
Plug,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Badge } from "@/components/ui/badge";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import { Progress } from "@/components/ui/progress";
// ---------- Utility types ----------
type ModuleKey =
| "home"
| "messenger"
| "projects"
| "memory"
| "parser"
| "graph"
| "meetings"
| "agents"
| "training"
| "dao"
| "treasury"
| "integrations"
| "market"
| "analytics"
| "notifications"
| "admin"
| "creative"
| "security"
| "profile";
const MODULES: Array<{
key: ModuleKey;
label: string;
icon: React.ElementType;
}> = [
{ key: "home", label: "Головна / Оркестратор", icon: Cpu },
{ key: "messenger", label: "Чати та канали", icon: MessageSquare },
{ key: "projects", label: "Проєкти", icon: Boxes },
{ key: "memory", label: "База знань", icon: BookOpen },
{ key: "parser", label: "Парсер документів", icon: FileText },
{ key: "graph", label: "Граф знань", icon: Network },
{ key: "meetings", label: "Зустрічі", icon: Video },
{ key: "agents", label: "Агенти", icon: Bot },
{ key: "training", label: "Навчальний кабінет", icon: GitBranch },
{ key: "dao", label: "Голосування / DAO", icon: Vote },
{ key: "treasury", label: "Фінанси / Казна", icon: Wallet },
{ key: "integrations", label: "Інтеграції", icon: Plug },
{ key: "market", label: "Маркетплейс", icon: Store },
{ key: "analytics", label: "Аналітика", icon: LineChart },
{ key: "notifications", label: "Сповіщення", icon: Bell },
{ key: "admin", label: "Адмін-панель", icon: Settings },
{ key: "creative", label: "Креативна студія", icon: Palette },
{ key: "security", label: "Безпека / Аудит", icon: ShieldCheck },
{ key: "profile", label: "Профіль", icon: User },
];
// ---------- Reusable UI blocks ----------
function Sidebar({ active, onSelect }: { active: ModuleKey; onSelect: (k: ModuleKey) => void }) {
return (
<aside className="h-full w-72 border-r bg-white/60 backdrop-blur-sm">
<div className="p-4 border-b flex items-center gap-2">
<Cpu className="h-5 w-5" />
<span className="font-semibold">MicroDAO</span>
<Badge variant="secondary" className="ml-auto">orchestrator</Badge>
</div>
<nav className="p-2 space-y-1 overflow-y-auto h-[calc(100%-3.5rem)]">
{MODULES.map((m) => (
<button
key={m.key}
onClick={() => onSelect(m.key)}
className={`w-full flex items-center gap-3 rounded-xl px-3 py-2 hover:bg-slate-100 transition ${
active === m.key ? "bg-slate-900 text-white hover:bg-slate-900" : ""
}`}
aria-current={active === m.key}
>
<m.icon className="h-4 w-4" />
<span className="text-sm text-left truncate">{m.label}</span>
{m.key === "notifications" && (
<Badge className={`ml-auto ${active === m.key ? "bg-white text-slate-900" : ""}`}>3</Badge>
)}
</button>
))}
</nav>
</aside>
);
}
function Topbar({ netOnline, orchOk }: { netOnline: boolean; orchOk: boolean }) {
return (
<div className="h-14 border-b flex items-center gap-3 px-4 bg-white/70 backdrop-blur">
<Input placeholder="Пошук по всіх модулях" className="max-w-md" />
<Tabs defaultValue="dao" className="ml-2">
<TabsList>
<TabsTrigger value="private">Private</TabsTrigger>
<TabsTrigger value="dao">DAO</TabsTrigger>
<TabsTrigger value="public">Public</TabsTrigger>
</TabsList>
</Tabs>
<div className="ml-auto flex items-center gap-2">
<Badge variant="outline" className="flex items-center gap-1">
<Zap className={`h-4 w-4 ${netOnline ? 'text-emerald-600' : 'text-amber-600'}`}/>
{netOnline ? 'Online' : 'Offline'}
</Badge>
<Badge variant="outline" className="flex items-center gap-1">
<Database className={`h-4 w-4 ${orchOk ? 'text-emerald-600' : 'text-amber-600'}`}/>
Orchestrator {orchOk ? 'ok' : 'unreachable'}
</Badge>
<Button variant="secondary" size="sm" className="gap-2"><Settings className="h-4 w-4"/>Налаштування</Button>
</div>
</div>
);
}
function HealthGrid() {
const items = [
{ title: "Messenger", ok: true },
{ title: "Parser", ok: false },
{ title: "KB Core", ok: true },
{ title: "RAG", ok: true },
{ title: "Wallet", ok: true },
{ title: "DAO", ok: true },
];
return (
<div className="grid grid-cols-2 lg:grid-cols-3 gap-3">
{items.map((x) => (
<Card key={x.title} className="rounded-2xl">
<CardHeader className="py-3">
<CardTitle className="text-sm font-medium flex items-center gap-2">
{x.ok ? (
<CheckCircle2 className="h-4 w-4 text-emerald-600" />
) : (
<AlertTriangle className="h-4 w-4 text-amber-600" />
)}
{x.title}
</CardTitle>
</CardHeader>
<CardContent className="py-2">
<div className="text-xs text-slate-500">{x.ok ? "Працює стабільно" : "Черга задач > p95"}</div>
</CardContent>
</Card>
))}
</div>
);
}
function OrchestratorChat() {
return (
<Card className="rounded-2xl h-full flex flex-col">
<CardHeader className="py-3">
<CardTitle className="text-sm">Чат з Оркестратором</CardTitle>
</CardHeader>
<CardContent className="flex-1 flex flex-col gap-3">
<div className="flex-1 rounded-xl border bg-slate-50 p-3 text-sm overflow-auto">
<div className="text-slate-500">Вітаю. Чим допомогти? Наприклад: "Розбери PDF та створй короткий бріф у Проєктах".</div>
</div>
<div className="flex items-center gap-2">
<Textarea placeholder="Запитайте або використайте @agent" className="min-h-[44px]" />
<Button className="whitespace-nowrap">Надіслати</Button>
</div>
</CardContent>
</Card>
);
}
function ActivityFeed() {
const rows = [
{ t: "ingest.completed", d: "USDO готово", ts: "09:15" },
{ t: "message.created", d: "#general", ts: "09:12" },
{ t: "vote.finalized", d: "Постанова #12", ts: "08:55" },
{ t: "payment.sent", d: "Reward 2.5 μUTIL", ts: "08:40" },
];
return (
<Card className="rounded-2xl h-full">
<CardHeader className="py-3"><CardTitle className="text-sm">Стрічка подій</CardTitle></CardHeader>
<CardContent>
<ul className="space-y-2 text-sm">
{rows.map((r, i) => (
<li key={i} className="flex items-center gap-2">
<Activity className="h-4 w-4 text-slate-500"/>
<span className="font-medium">{r.t}</span>
<span className="text-slate-500"> {r.d}</span>
<span className="ml-auto text-xs text-slate-400">{r.ts}</span>
</li>
))}
</ul>
</CardContent>
</Card>
);
}
function TasksTable() {
const rows = [
{ agent: "parser-agent", task: "PDF→USDO", status: "running", eta: "2m" },
{ agent: "project-agent", task: "Бріф + Kanban", status: "queued", eta: "—" },
{ agent: "wallet-agent", task: "Нарахувати винагороду", status: "completed", eta: "0" },
];
const color = (s: string) => (s === "completed" ? "text-emerald-600" : s === "running" ? "text-amber-600" : "text-slate-500");
return (
<Card className="rounded-2xl h-full">
<CardHeader className="py-3"><CardTitle className="text-sm">Поточні задачі агентів</CardTitle></CardHeader>
<CardContent>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="text-left text-slate-500">
<tr>
<th className="py-2 pr-4">Агент</th>
<th className="py-2 pr-4">Задача</th>
<th className="py-2 pr-4">Статус</th>
<th className="py-2">ETA</th>
</tr>
</thead>
<tbody>
{rows.map((r, i) => (
<tr key={i} className="border-t">
<td className="py-2 pr-4 font-mono text-xs">{r.agent}</td>
<td className="py-2 pr-4">{r.task}</td>
<td className={`py-2 pr-4 ${color(r.status)}`}>{r.status}</td>
<td className="py-2">{r.eta}</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
);
}
function AgentGraph() {
// Minimal static SVG graph placeholder
return (
<Card className="rounded-2xl h-full">
<CardHeader className="py-3"><CardTitle className="text-sm">Граф агентів</CardTitle></CardHeader>
<CardContent>
<svg viewBox="0 0 400 220" className="w-full h-48">
<defs>
<filter id="shadow" x="-50%" y="-50%" width="200%" height="200%">
<feDropShadow dx="0" dy="1" stdDeviation="2" floodOpacity="0.2" />
</filter>
</defs>
<g filter="url(#shadow)">
<circle cx="200" cy="110" r="28" className="fill-slate-900" />
<text x="200" y="115" textAnchor="middle" className="fill-white text-xs">Orch</text>
{[
{ x: 80, y: 40, t: "Parser" },
{ x: 320, y: 40, t: "KB" },
{ x: 80, y: 180, t: "Wallet" },
{ x: 320, y: 180, t: "DAO" },
].map((n, i) => (
<g key={i}>
<line x1="200" y1="110" x2={n.x} y2={n.y} stroke="#94a3b8" />
<circle cx={n.x} cy={n.y} r="22" className="fill-white stroke-slate-300" />
<text x={n.x} y={n.y + 4} textAnchor="middle" className="fill-slate-700 text-xs">{n.t}</text>
</g>
))}
</g>
</svg>
</CardContent>
</Card>
);
}
function HomeOrchestrator() {
return (
<div className="p-4 space-y-4">
<HealthGrid />
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
<OrchestratorChat />
<div className="grid grid-rows-2 gap-4">
<ActivityFeed />
<TasksTable />
</div>
</div>
<AgentGraph />
</div>
);
}
function Placeholder({ title }: { title: string }) {
return (
<div className="p-6 text-sm text-slate-600">
<p className="mb-2">Розділ: <span className="font-medium">{title}</span></p>
<p>Тут буде детальний екран модуля. Структура готова до підключення реальних даних та маршрутів.</p>
</div>
);
}
function StartScreen({ onCreateDAO, onJoinDAO, onSolo, agentReady, onInstallAgent, installing, progress, modelProfile, setModelProfile, indexSize, setIndexSize }: { onCreateDAO: () => void; onJoinDAO: () => void; onSolo: () => void; agentReady: boolean; onInstallAgent: () => void; installing: boolean; progress: number; modelProfile: string; setModelProfile: (v: string) => void; indexSize: string; setIndexSize: (v: string) => void; }) {
return (
<div className="h-full flex items-center justify-center p-8">
<div className="max-w-3xl w-full grid grid-cols-1 lg:grid-cols-3 gap-4">
<Card className="rounded-2xl lg:col-span-2">
<CardHeader>
<CardTitle>Ласкаво просимо до MicroDAO</CardTitle>
</CardHeader>
<CardContent className="space-y-4 text-sm">
<p className="text-slate-600">Почніть зі створення спільноти DAO, приєднайтесь за інвайтом або спробуйте Solo-режим з локальним агентом.</p>
<div className="grid sm:grid-cols-3 gap-2">
<Button onClick={onCreateDAO} className="rounded-xl">Створити DAO</Button>
<Button variant="secondary" onClick={onJoinDAO} className="rounded-xl">Приєднатись</Button>
<Button variant="outline" onClick={onSolo} className="rounded-xl">Solo-режим</Button>
</div>
<div className="grid gap-3 md:grid-cols-2">
<div className="space-y-2">
<div className="text-xs uppercase text-slate-500">Профіль моделі</div>
<div className="flex flex-wrap gap-2">
{['Lite','Base','Plus','Pro'].map(p => (
<Button key={p} variant={modelProfile===p? 'default':'outline'} size="sm" className="rounded-xl" onClick={() => setModelProfile(p)}>{p}</Button>
))}
</div>
<div className="text-xs text-slate-500">Автовибір за можливостями пристрою. Можна змінити пізніше.</div>
</div>
<div className="space-y-2">
<div className="text-xs uppercase text-slate-500">Локальний індекс</div>
<div className="flex flex-wrap gap-2">
{['200MB','500MB','1GB','5GB','20GB'].map(s => (
<Button key={s} variant={indexSize===s? 'default':'outline'} size="sm" className="rounded-xl" onClick={() => setIndexSize(s)}>{s}</Button>
))}
<Button variant="outline" size="sm" className="rounded-xl">Власний шлях</Button>
</div>
<div className="text-xs text-slate-500">int8 квантування заощаджує ×34 місця.</div>
</div>
</div>
<div className="text-xs text-slate-500">Можна змінити вибір пізніше у Налаштуваннях.</div>
</CardContent>
</Card>
<Card className="rounded-2xl">
<CardHeader className="pb-2">
<CardTitle className="text-sm flex items-center gap-2"><Bot className="h-4 w-4"/>Локальний агент</CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm">
{agentReady ? (
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-emerald-600"/>Готовий до роботи</div>
) : (
<>
<div className="flex items-center gap-2">
{installing ? (
<AlertTriangle className="h-4 w-4 text-amber-600" />
) : (
<AlertTriangle className="h-4 w-4 text-amber-600" />
)}
{installing ? 'Встановлення моделі…' : 'Ще не встановлено'}
</div>
<Button size="sm" disabled={installing} onClick={onInstallAgent} className="rounded-xl w-full mt-1">
{installing ? 'Встановлення…' : 'Встановити агента'}
</Button>
{installing && (
<div className="space-y-1">
<Progress value={progress} />
<div className="text-xs text-slate-500">Завантаження моделі: {Math.round(progress)}%</div>
</div>
)}
<div className="text-xs text-slate-500">Обсяг моделі підбирається автоматично за можливостями пристрою.</div>
</>
)}
</CardContent>
</Card>
</div>
</div>
);
}
export default function OrchestratorLayout() {
const [active, setActive] = React.useState<ModuleKey>("home");
const [orgId, setOrgId] = React.useState<string | null>(null); // null = ще немає DAO/спільноти
const [agentReady, setAgentReady] = React.useState<boolean>(false);
const [installing, setInstalling] = React.useState<boolean>(false);
const [progress, setProgress] = React.useState<number>(0);
const [modelProfile, setModelProfile] = React.useState<string>('Base');
const [indexSize, setIndexSize] = React.useState<string>('500MB');
const [netOnline, setNetOnline] = React.useState<boolean>(typeof navigator !== 'undefined' ? navigator.onLine : true);
const [orchOk, setOrchOk] = React.useState<boolean>(true);
React.useEffect(() => {
const on = () => setNetOnline(true);
const off = () => setNetOnline(false);
window.addEventListener('online', on);
window.addEventListener('offline', off);
const ping = setInterval(() => {
// TODO: replace with real /healthz ping
setOrchOk((v) => (Math.random() > 0.05));
}, 5000);
return () => { window.removeEventListener('online', on); window.removeEventListener('offline', off); clearInterval(ping); };
}, []);
// Симуляція інсталяції агента/моделі
const handleInstallAgent = () => {
if (installing || agentReady) return;
setInstalling(true);
setProgress(0);
const timer = setInterval(() => {
setProgress((p) => {
const next = Math.min(100, p + Math.random() * 18 + 5);
if (next >= 100) {
clearInterval(timer);
setInstalling(false);
setAgentReady(true);
}
return next;
});
}, 400);
};
// Роутінг після вибору режиму — встановлюємо orgId і відкриваємо домашню
const goReady = (id: string) => { setOrgId(id); setActive('home'); };
const content = () => {<number>(0);
// Симуляція інсталяції агента/моделі
const handleInstallAgent = () => {
if (installing || agentReady) return;
setInstalling(true);
setProgress(0);
const timer = setInterval(() => {
setProgress((p) => {
const next = Math.min(100, p + Math.random() * 18 + 5);
if (next >= 100) {
clearInterval(timer);
setInstalling(false);
setAgentReady(true);
}
return next;
});
}, 400);
};
const content = () => {
if (!orgId) {
return (
<StartScreen
onCreateDAO={() => goReady('dao-created')}
onJoinDAO={() => goReady('dao-joined')}
onSolo={() => goReady('solo-mode')}
agentReady={agentReady}
onInstallAgent={handleInstallAgent}
installing={installing}
progress={progress}
modelProfile={modelProfile}
setModelProfile={setModelProfile}
indexSize={indexSize}
setIndexSize={setIndexSize}
progress={progress}
/>
);
}
switch (active) {
case "home":
return <HomeOrchestrator />;
case "messenger":
return <Placeholder title="Чати та канали" />;
case "projects":
return <Placeholder title="Проєкти" />;
case "memory":
return <Placeholder title="База знань" />;
case "parser":
return <Placeholder title="Парсер документів" />;
case "graph":
return <Placeholder title="Граф знань" />;
case "meetings":
return <Placeholder title="Зустрічі" />;
case "agents":
return <Placeholder title="Агенти" />;
case "training":
return <Placeholder title="Навчальний кабінет" />;
case "dao":
return <Placeholder title="Голосування / DAO" />;
case "treasury":
return <Placeholder title="Фінанси / Казна" />;
case "integrations":
return <Placeholder title="Інтеграції" />;
case "market":
return <Placeholder title="Маркетплейс" />;
case "analytics":
return <Placeholder title="Аналітика" />;
case "notifications":
return <Placeholder title="Сповіщення" />;
case "admin":
return <Placeholder title="Адмін-панель" />;
case "creative":
return <Placeholder title="Креативна студія" />;
case "security":
return <Placeholder title="Безпека / Аудит" />;
case "profile":
return <Placeholder title="Профіль" />;
}
};
return (
<div className="h-[100vh] w-full grid grid-cols-[18rem_1fr] bg-slate-50 text-slate-900">
<Sidebar active={active} onSelect={setActive} />
<main className="flex flex-col">
<Topbar netOnline={netOnline} orchOk={orchOk} />
<div className="flex-1 overflow-auto">{content()}</div>
</main>
</div>
);
}

View File

@@ -0,0 +1,576 @@
import React from "react";
import {
Activity,
AlertTriangle,
CheckCircle2,
Cpu,
Database,
GitBranch,
LineChart,
MessageSquare,
Settings,
ShieldCheck,
User,
Vote,
Wallet,
Zap,
Bot,
Boxes,
BookOpen,
FileText,
Network,
Video,
Palette,
Bell,
Store,
Plug,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Badge } from "@/components/ui/badge";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import { Progress } from "@/components/ui/progress";
// ---------- Utility types ----------
type ModuleKey =
| "home"
| "messenger"
| "projects"
| "memory"
| "parser"
| "graph"
| "meetings"
| "agents"
| "training"
| "dao"
| "treasury"
| "integrations"
| "market"
| "analytics"
| "notifications"
| "admin"
| "creative"
| "security"
| "profile";
const MODULES: Array<{
key: ModuleKey;
label: string;
icon: React.ElementType;
}> = [
{ key: "home", label: "Головна / Оркестратор", icon: Cpu },
{ key: "messenger", label: "Чати та канали", icon: MessageSquare },
{ key: "projects", label: "Проєкти", icon: Boxes },
{ key: "memory", label: "База знань", icon: BookOpen },
{ key: "parser", label: "Парсер документів", icon: FileText },
{ key: "graph", label: "Граф знань", icon: Network },
{ key: "meetings", label: "Зустрічі", icon: Video },
{ key: "agents", label: "Агенти", icon: Bot },
{ key: "training", label: "Навчальний кабінет", icon: GitBranch },
{ key: "dao", label: "Голосування / DAO", icon: Vote },
{ key: "treasury", label: "Фінанси / Казна", icon: Wallet },
{ key: "integrations", label: "Інтеграції", icon: Plug },
{ key: "market", label: "Маркетплейс", icon: Store },
{ key: "analytics", label: "Аналітика", icon: LineChart },
{ key: "notifications", label: "Сповіщення", icon: Bell },
{ key: "admin", label: "Адмін-панель", icon: Settings },
{ key: "creative", label: "Креативна студія", icon: Palette },
{ key: "security", label: "Безпека / Аудит", icon: ShieldCheck },
{ key: "profile", label: "Профіль", icon: User },
];
// ---------- Reusable UI blocks ----------
function Sidebar({ active, onSelect }: { active: ModuleKey; onSelect: (k: ModuleKey) => void }) {
return (
<aside className="h-full w-72 border-r bg-white/60 backdrop-blur-sm">
<div className="p-4 border-b flex items-center gap-2">
<Cpu className="h-5 w-5" />
<span className="font-semibold">MicroDAO</span>
<Badge variant="secondary" className="ml-auto">orchestrator</Badge>
</div>
<nav className="p-2 space-y-1 overflow-y-auto h-[calc(100%-3.5rem)]">
{MODULES.map((m) => (
<button
key={m.key}
onClick={() => onSelect(m.key)}
className={`w-full flex items-center gap-3 rounded-xl px-3 py-2 hover:bg-slate-100 transition ${
active === m.key ? "bg-slate-900 text-white hover:bg-slate-900" : ""
}`}
aria-current={active === m.key}
>
<m.icon className="h-4 w-4" />
<span className="text-sm text-left truncate">{m.label}</span>
{m.key === "notifications" && (
<Badge className={`ml-auto ${active === m.key ? "bg-white text-slate-900" : ""}`}>3</Badge>
)}
</button>
))}
</nav>
</aside>
);
}
function Topbar({ netOnline, orchOk }: { netOnline: boolean; orchOk: boolean }) {
return (
<div className="h-14 border-b flex items-center gap-3 px-4 bg-white/70 backdrop-blur">
<Input placeholder="Пошук по всіх модулях" className="max-w-md" />
<Tabs defaultValue="dao" className="ml-2">
<TabsList>
<TabsTrigger value="private">Private</TabsTrigger>
<TabsTrigger value="dao">DAO</TabsTrigger>
<TabsTrigger value="public">Public</TabsTrigger>
</TabsList>
</Tabs>
<div className="ml-auto flex items-center gap-2">
<Badge variant="outline" className="flex items-center gap-1">
<Zap className={`h-4 w-4 ${netOnline ? 'text-emerald-600' : 'text-amber-600'}`}/>
{netOnline ? 'Online' : 'Offline'}
</Badge>
<Badge variant="outline" className="flex items-center gap-1">
<Database className={`h-4 w-4 ${orchOk ? 'text-emerald-600' : 'text-amber-600'}`}/>
Orchestrator {orchOk ? 'ok' : 'unreachable'}
</Badge>
<Button variant="secondary" size="sm" className="gap-2"><Settings className="h-4 w-4"/>Налаштування</Button>
</div>
</div>
);
}
function HealthGrid() {
const items = [
{ title: "Messenger", ok: true },
{ title: "Parser", ok: false },
{ title: "KB Core", ok: true },
{ title: "RAG", ok: true },
{ title: "Wallet", ok: true },
{ title: "DAO", ok: true },
];
return (
<div className="grid grid-cols-2 lg:grid-cols-3 gap-3">
{items.map((x) => (
<Card key={x.title} className="rounded-2xl">
<CardHeader className="py-3">
<CardTitle className="text-sm font-medium flex items-center gap-2">
{x.ok ? (
<CheckCircle2 className="h-4 w-4 text-emerald-600" />
) : (
<AlertTriangle className="h-4 w-4 text-amber-600" />
)}
{x.title}
</CardTitle>
</CardHeader>
<CardContent className="py-2">
<div className="text-xs text-slate-500">{x.ok ? "Працює стабільно" : "Черга задач > p95"}</div>
</CardContent>
</Card>
))}
</div>
);
}
function OrchestratorChat() {
return (
<Card className="rounded-2xl h-full flex flex-col">
<CardHeader className="py-3">
<CardTitle className="text-sm">Чат з Оркестратором</CardTitle>
</CardHeader>
<CardContent className="flex-1 flex flex-col gap-3">
<div className="flex-1 rounded-xl border bg-slate-50 p-3 text-sm overflow-auto">
<div className="text-slate-500">Вітаю. Чим допомогти? Наприклад: "Розбери PDF та створй короткий бріф у Проєктах".</div>
</div>
<div className="flex items-center gap-2">
<Textarea placeholder="Запитайте або використайте @agent" className="min-h-[44px]" />
<Button className="whitespace-nowrap">Надіслати</Button>
</div>
</CardContent>
</Card>
);
}
function ActivityFeed() {
const rows = [
{ t: "ingest.completed", d: "USDO готово", ts: "09:15" },
{ t: "message.created", d: "#general", ts: "09:12" },
{ t: "vote.finalized", d: "Постанова #12", ts: "08:55" },
{ t: "payment.sent", d: "Reward 2.5 μUTIL", ts: "08:40" },
];
return (
<Card className="rounded-2xl h-full">
<CardHeader className="py-3"><CardTitle className="text-sm">Стрічка подій</CardTitle></CardHeader>
<CardContent>
<ul className="space-y-2 text-sm">
{rows.map((r, i) => (
<li key={i} className="flex items-center gap-2">
<Activity className="h-4 w-4 text-slate-500"/>
<span className="font-medium">{r.t}</span>
<span className="text-slate-500"> {r.d}</span>
<span className="ml-auto text-xs text-slate-400">{r.ts}</span>
</li>
))}
</ul>
</CardContent>
</Card>
);
}
function TasksTable() {
const rows = [
{ agent: "parser-agent", task: "PDF→USDO", status: "running", eta: "2m" },
{ agent: "project-agent", task: "Бріф + Kanban", status: "queued", eta: "—" },
{ agent: "wallet-agent", task: "Нарахувати винагороду", status: "completed", eta: "0" },
];
const color = (s: string) => (s === "completed" ? "text-emerald-600" : s === "running" ? "text-amber-600" : "text-slate-500");
return (
<Card className="rounded-2xl h-full">
<CardHeader className="py-3"><CardTitle className="text-sm">Поточні задачі агентів</CardTitle></CardHeader>
<CardContent>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="text-left text-slate-500">
<tr>
<th className="py-2 pr-4">Агент</th>
<th className="py-2 pr-4">Задача</th>
<th className="py-2 pr-4">Статус</th>
<th className="py-2">ETA</th>
</tr>
</thead>
<tbody>
{rows.map((r, i) => (
<tr key={i} className="border-t">
<td className="py-2 pr-4 font-mono text-xs">{r.agent}</td>
<td className="py-2 pr-4">{r.task}</td>
<td className={`py-2 pr-4 ${color(r.status)}`}>{r.status}</td>
<td className="py-2">{r.eta}</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
);
}
function AgentGraph() {
// Minimal static SVG graph placeholder
return (
<Card className="rounded-2xl h-full">
<CardHeader className="py-3"><CardTitle className="text-sm">Граф агентів</CardTitle></CardHeader>
<CardContent>
<svg viewBox="0 0 400 220" className="w-full h-48">
<defs>
<filter id="shadow" x="-50%" y="-50%" width="200%" height="200%">
<feDropShadow dx="0" dy="1" stdDeviation="2" floodOpacity="0.2" />
</filter>
</defs>
<g filter="url(#shadow)">
<circle cx="200" cy="110" r="28" className="fill-slate-900" />
<text x="200" y="115" textAnchor="middle" className="fill-white text-xs">Orch</text>
{[
{ x: 80, y: 40, t: "Parser" },
{ x: 320, y: 40, t: "KB" },
{ x: 80, y: 180, t: "Wallet" },
{ x: 320, y: 180, t: "DAO" },
].map((n, i) => (
<g key={i}>
<line x1="200" y1="110" x2={n.x} y2={n.y} stroke="#94a3b8" />
<circle cx={n.x} cy={n.y} r="22" className="fill-white stroke-slate-300" />
<text x={n.x} y={n.y + 4} textAnchor="middle" className="fill-slate-700 text-xs">{n.t}</text>
</g>
))}
</g>
</svg>
</CardContent>
</Card>
);
}
function HomeOrchestrator() {
return (
<div className="p-4 space-y-4">
<HealthGrid />
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
<OrchestratorChat />
<div className="grid grid-rows-2 gap-4">
<ActivityFeed />
<TasksTable />
</div>
</div>
<AgentGraph />
</div>
);
}
function Placeholder({ title }: { title: string }) {
return (
<div className="p-6 text-sm text-slate-600">
<p className="mb-2">Розділ: <span className="font-medium">{title}</span></p>
<p>Тут буде детальний екран модуля. Структура готова до підключення реальних даних та маршрутів.</p>
</div>
);
}
function StartScreen({ onCreateDAO, onJoinDAO, onSolo, agentReady, onInstallAgent, installing, progress, modelProfile, setModelProfile, indexSize, setIndexSize, recommendedProfile, onPickCustomIndex }: { onCreateDAO: () => void; onJoinDAO: () => void; onSolo: () => void; agentReady: boolean; onInstallAgent: () => void; installing: boolean; progress: number; modelProfile: string; setModelProfile: (v: string) => void; indexSize: string; setIndexSize: (v: string) => void; recommendedProfile: string; onPickCustomIndex: () => void; }) {
return (
<div className="h-full flex items-center justify-center p-8">
<div className="max-w-3xl w-full grid grid-cols-1 lg:grid-cols-3 gap-4">
<Card className="rounded-2xl lg:col-span-2">
<CardHeader>
<CardTitle>Ласкаво просимо до MicroDAO</CardTitle>
</CardHeader>
<CardContent className="space-y-4 text-sm">
<p className="text-slate-600">Почніть зі створення спільноти DAO, приєднайтесь за інвайтом або спробуйте Solo-режим з локальним агентом.</p>
<div className="grid sm:grid-cols-3 gap-2">
<Button onClick={onCreateDAO} className="rounded-xl">Створити DAO</Button>
<Button variant="secondary" onClick={onJoinDAO} className="rounded-xl">Приєднатись</Button>
<Button variant="outline" onClick={onSolo} className="rounded-xl">Solo-режим</Button>
</div>
<div className="grid gap-3 md:grid-cols-2">
<div className="space-y-2">
<div className="text-xs uppercase text-slate-500">Профіль моделі</div>
<div className="flex flex-wrap gap-2">
{['Lite','Base','Plus','Pro'].map(p => (
<Button key={p} variant={modelProfile===p? 'default':'outline'} size="sm" className="rounded-xl" onClick={() => setModelProfile(p)}>
{p}{p===recommendedProfile && <Badge className="ml-2" variant="secondary">рекоменд.</Badge>}
</Button>
))}
</div>
<div className="text-xs text-slate-500">Автовибір за можливостями пристрою. Можна змінити пізніше.</div>
</div>
<div className="space-y-2">
<div className="text-xs uppercase text-slate-500">Локальний індекс</div>
<div className="flex flex-wrap gap-2">
{['200MB','500MB','1GB','5GB','20GB'].map(s => (
<Button key={s} variant={indexSize===s? 'default':'outline'} size="sm" className="rounded-xl" onClick={() => setIndexSize(s)}>{s}</Button>
))}
<Button variant="outline" size="sm" className="rounded-xl" onClick={onPickCustomIndex}>Власний шлях</Button>
</div>
<div className="text-xs text-slate-500">int8 квантування заощаджує ×34 місця.</div>
</div>
</div>
<div className="text-xs text-slate-500">Можна змінити вибір пізніше у Налаштуваннях.</div>
</CardContent>
</Card>
<Card className="rounded-2xl">
<CardHeader className="pb-2">
<CardTitle className="text-sm flex items-center gap-2"><Bot className="h-4 w-4"/>Локальний агент</CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm">
{agentReady ? (
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-emerald-600"/>Готовий до роботи</div>
) : (
<>
<div className="flex items-center gap-2">
{installing ? (
<AlertTriangle className="h-4 w-4 text-amber-600" />
) : (
<AlertTriangle className="h-4 w-4 text-amber-600" />
)}
{installing ? 'Встановлення моделі…' : 'Ще не встановлено'}
</div>
<Button size="sm" disabled={installing} onClick={onInstallAgent} className="rounded-xl w-full mt-1">
{installing ? 'Встановлення…' : 'Встановити агента'}
</Button>
{installing && (
<div className="space-y-1">
<Progress value={progress} />
<div className="text-xs text-slate-500">Завантаження моделі: {Math.round(progress)}%</div>
</div>
)}
<div className="text-xs text-slate-500">Обсяг моделі підбирається автоматично за можливостями пристрою.</div>
</>
)}
</CardContent>
</Card>
</div>
</div>
);
}
// --- helpers ---
async function pingHealthz(url: string, timeoutMs = 3000): Promise<boolean> {
try {
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), timeoutMs);
const res = await fetch(url, { signal: ctrl.signal });
clearTimeout(t);
return res.ok;
} catch {
return false;
}
}
async function recommendModelProfile(): Promise<"Lite"|"Base"|"Plus"|"Pro"> {
// Heuristics: deviceMemory (GB), CPU cores, simple UA.
// @ts-ignore
const dm = (navigator as any).deviceMemory || 4;
const cores = navigator.hardwareConcurrency || 4;
const isMobile = /iPhone|Android/i.test(navigator.userAgent);
if (dm >= 24 && cores >= 8 && !isMobile) return "Pro";
if (dm >= 12 && cores >= 8) return "Plus";
if (dm >= 6) return "Base";
return "Lite";
}
export default function OrchestratorLayout() {
const [active, setActive] = React.useState<ModuleKey>("home");
const [orgId, setOrgId] = React.useState<string | null>(null); // null = ще немає DAO/спільноти
const [agentReady, setAgentReady] = React.useState<boolean>(false);
const [installing, setInstalling] = React.useState<boolean>(false);
const [progress, setProgress] = React.useState<number>(0);
const [modelProfile, setModelProfile] = React.useState<string>('Base');
const [recommendedProfile, setRecommendedProfile] = React.useState<string>('Base');
const [indexSize, setIndexSize] = React.useState<string>('500MB');
const [netOnline, setNetOnline] = React.useState<boolean>(typeof navigator !== 'undefined' ? navigator.onLine : true);
const [orchOk, setOrchOk] = React.useState<boolean>(true);
React.useEffect(() => {
const on = () => setNetOnline(true);
const off = () => setNetOnline(false);
window.addEventListener('online', on);
window.addEventListener('offline', off);
let mounted = true;
// real /healthz ping every 10s
const tick = async () => {
const ok = await pingHealthz('/healthz');
if (mounted) setOrchOk(ok);
};
tick();
const id = setInterval(tick, 10000);
// model profile recommendation
recommendModelProfile().then(p => { if (mounted) { setRecommendedProfile(p); setModelProfile(p); } });
// restore custom index path if saved
const savedPath = localStorage.getItem('microdao.indexPath');
if (savedPath) setIndexSize(`custom:${savedPath}`);
return () => { mounted = false; window.removeEventListener('online', on); window.removeEventListener('offline', off); clearInterval(id); };
}, []);
// Симуляція інсталяції агента/моделі
const handleInstallAgent = () => {
if (installing || agentReady) return;
setInstalling(true);
setProgress(0);
const timer = setInterval(() => {
setProgress((p) => {
const next = Math.min(100, p + Math.random() * 18 + 5);
if (next >= 100) {
clearInterval(timer);
setInstalling(false);
setAgentReady(true);
}
return next;
});
}, 400);
};
// Роутінг після вибору режиму — встановлюємо orgId і відкриваємо домашню
const goReady = (id: string) => { setOrgId(id); setActive('home'); };
const content = () => {
if (!orgId) {
return (
<StartScreen
onCreateDAO={() => goReady('dao-created')}
onJoinDAO={() => goReady('dao-joined')}
onSolo={() => goReady('solo-mode')}
agentReady={agentReady}
onInstallAgent={handleInstallAgent}
installing={installing}
progress={progress}
modelProfile={modelProfile}
setModelProfile={setModelProfile}
indexSize={indexSize}
setIndexSize={setIndexSize}
recommendedProfile={recommendedProfile}
onPickCustomIndex={handlePickCustomIndex}
/>
);
}
switch (active) {
case "home":
return <HomeOrchestrator />;
case "messenger":
return <Placeholder title="Чати та канали" />;
case "projects":
return <Placeholder title="Проєкти" />;
case "memory":
return <Placeholder title="База знань" />;
case "parser":
return <Placeholder title="Парсер документів" />;
case "graph":
return <Placeholder title="Граф знань" />;
case "meetings":
return <Placeholder title="Зустрічі" />;
case "agents":
return <Placeholder title="Агенти" />;
case "training":
return <Placeholder title="Навчальний кабінет" />;
case "dao":
return <Placeholder title="Голосування / DAO" />;
case "treasury":
return <Placeholder title="Фінанси / Казна" />;
case "integrations":
return <Placeholder title="Інтеграції" />;
case "market":
return <Placeholder title="Маркетплейс" />;
case "analytics":
return <Placeholder title="Аналітика" />;
case "notifications":
return <Placeholder title="Сповіщення" />;
case "admin":
return <Placeholder title="Адмін-панель" />;
case "creative":
return <Placeholder title="Креативна студія" />;
case "security":
return <Placeholder title="Безпека / Аудит" />;
case "profile":
return <Placeholder title="Профіль" />;
}
};
// Choose custom directory for local index and persist label
const handlePickCustomIndex = async () => {
// File System Access API if available
// @ts-ignore
if (window.showDirectoryPicker) {
try {
// @ts-ignore
const dir = await window.showDirectoryPicker();
const name = dir.name || 'custom-index';
localStorage.setItem('microdao.indexPath', name);
setIndexSize(`custom:${name}`);
return;
} catch {}
}
// Fallback via input directory (webkitdirectory)
const input = document.createElement('input');
// @ts-ignore
input.webkitdirectory = true; input.type = 'file';
input.onchange = () => {
const files = input.files || [] as any;
const any = files.length ? (files[0] as any) : null;
const guess = any?.webkitRelativePath?.split('/')?.[0] || 'custom-index';
localStorage.setItem('microdao.indexPath', guess);
setIndexSize(`custom:${guess}`);
};
input.click();
};
return (
<div className="h-[100vh] w-full grid grid-cols-[18rem_1fr] bg-slate-50 text-slate-900">
<Sidebar active={active} onSelect={setActive} />
<main className="flex flex-col">
<Topbar netOnline={netOnline} orchOk={orchOk} />
<div className="flex-1 overflow-auto">{content()}</div>
</main>
</div>
);
}

View File

@@ -0,0 +1,576 @@
import React from "react";
import {
Activity,
AlertTriangle,
CheckCircle2,
Cpu,
Database,
GitBranch,
LineChart,
MessageSquare,
Settings,
ShieldCheck,
User,
Vote,
Wallet,
Zap,
Bot,
Boxes,
BookOpen,
FileText,
Network,
Video,
Palette,
Bell,
Store,
Plug,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Badge } from "@/components/ui/badge";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import { Progress } from "@/components/ui/progress";
// ---------- Utility types ----------
type ModuleKey =
| "home"
| "messenger"
| "projects"
| "memory"
| "parser"
| "graph"
| "meetings"
| "agents"
| "training"
| "dao"
| "treasury"
| "integrations"
| "market"
| "analytics"
| "notifications"
| "admin"
| "creative"
| "security"
| "profile";
const MODULES: Array<{
key: ModuleKey;
label: string;
icon: React.ElementType;
}> = [
{ key: "home", label: "Головна / Оркестратор", icon: Cpu },
{ key: "messenger", label: "Чати та канали", icon: MessageSquare },
{ key: "projects", label: "Проєкти", icon: Boxes },
{ key: "memory", label: "База знань", icon: BookOpen },
{ key: "parser", label: "Парсер документів", icon: FileText },
{ key: "graph", label: "Граф знань", icon: Network },
{ key: "meetings", label: "Зустрічі", icon: Video },
{ key: "agents", label: "Агенти", icon: Bot },
{ key: "training", label: "Навчальний кабінет", icon: GitBranch },
{ key: "dao", label: "Голосування / DAO", icon: Vote },
{ key: "treasury", label: "Фінанси / Казна", icon: Wallet },
{ key: "integrations", label: "Інтеграції", icon: Plug },
{ key: "market", label: "Маркетплейс", icon: Store },
{ key: "analytics", label: "Аналітика", icon: LineChart },
{ key: "notifications", label: "Сповіщення", icon: Bell },
{ key: "admin", label: "Адмін-панель", icon: Settings },
{ key: "creative", label: "Креативна студія", icon: Palette },
{ key: "security", label: "Безпека / Аудит", icon: ShieldCheck },
{ key: "profile", label: "Профіль", icon: User },
];
// ---------- Reusable UI blocks ----------
function Sidebar({ active, onSelect }: { active: ModuleKey; onSelect: (k: ModuleKey) => void }) {
return (
<aside className="h-full w-72 border-r bg-white/60 backdrop-blur-sm">
<div className="p-4 border-b flex items-center gap-2">
<Cpu className="h-5 w-5" />
<span className="font-semibold">MicroDAO</span>
<Badge variant="secondary" className="ml-auto">orchestrator</Badge>
</div>
<nav className="p-2 space-y-1 overflow-y-auto h-[calc(100%-3.5rem)]">
{MODULES.map((m) => (
<button
key={m.key}
onClick={() => onSelect(m.key)}
className={`w-full flex items-center gap-3 rounded-xl px-3 py-2 hover:bg-slate-100 transition ${
active === m.key ? "bg-slate-900 text-white hover:bg-slate-900" : ""
}`}
aria-current={active === m.key}
>
<m.icon className="h-4 w-4" />
<span className="text-sm text-left truncate">{m.label}</span>
{m.key === "notifications" && (
<Badge className={`ml-auto ${active === m.key ? "bg-white text-slate-900" : ""}`}>3</Badge>
)}
</button>
))}
</nav>
</aside>
);
}
function Topbar({ netOnline, orchOk }: { netOnline: boolean; orchOk: boolean }) {
return (
<div className="h-14 border-b flex items-center gap-3 px-4 bg-white/70 backdrop-blur">
<Input placeholder="Пошук по всіх модулях" className="max-w-md" />
<Tabs defaultValue="dao" className="ml-2">
<TabsList>
<TabsTrigger value="private">Private</TabsTrigger>
<TabsTrigger value="dao">DAO</TabsTrigger>
<TabsTrigger value="public">Public</TabsTrigger>
</TabsList>
</Tabs>
<div className="ml-auto flex items-center gap-2">
<Badge variant="outline" className="flex items-center gap-1">
<Zap className={`h-4 w-4 ${netOnline ? 'text-emerald-600' : 'text-amber-600'}`}/>
{netOnline ? 'Online' : 'Offline'}
</Badge>
<Badge variant="outline" className="flex items-center gap-1">
<Database className={`h-4 w-4 ${orchOk ? 'text-emerald-600' : 'text-amber-600'}`}/>
Orchestrator {orchOk ? 'ok' : 'unreachable'}
</Badge>
<Button variant="secondary" size="sm" className="gap-2"><Settings className="h-4 w-4"/>Налаштування</Button>
</div>
</div>
);
}
function HealthGrid() {
const items = [
{ title: "Messenger", ok: true },
{ title: "Parser", ok: false },
{ title: "KB Core", ok: true },
{ title: "RAG", ok: true },
{ title: "Wallet", ok: true },
{ title: "DAO", ok: true },
];
return (
<div className="grid grid-cols-2 lg:grid-cols-3 gap-3">
{items.map((x) => (
<Card key={x.title} className="rounded-2xl">
<CardHeader className="py-3">
<CardTitle className="text-sm font-medium flex items-center gap-2">
{x.ok ? (
<CheckCircle2 className="h-4 w-4 text-emerald-600" />
) : (
<AlertTriangle className="h-4 w-4 text-amber-600" />
)}
{x.title}
</CardTitle>
</CardHeader>
<CardContent className="py-2">
<div className="text-xs text-slate-500">{x.ok ? "Працює стабільно" : "Черга задач > p95"}</div>
</CardContent>
</Card>
))}
</div>
);
}
function OrchestratorChat() {
return (
<Card className="rounded-2xl h-full flex flex-col">
<CardHeader className="py-3">
<CardTitle className="text-sm">Чат з Оркестратором</CardTitle>
</CardHeader>
<CardContent className="flex-1 flex flex-col gap-3">
<div className="flex-1 rounded-xl border bg-slate-50 p-3 text-sm overflow-auto">
<div className="text-slate-500">Вітаю. Чим допомогти? Наприклад: "Розбери PDF та створй короткий бріф у Проєктах".</div>
</div>
<div className="flex items-center gap-2">
<Textarea placeholder="Запитайте або використайте @agent" className="min-h-[44px]" />
<Button className="whitespace-nowrap">Надіслати</Button>
</div>
</CardContent>
</Card>
);
}
function ActivityFeed() {
const rows = [
{ t: "ingest.completed", d: "USDO готово", ts: "09:15" },
{ t: "message.created", d: "#general", ts: "09:12" },
{ t: "vote.finalized", d: "Постанова #12", ts: "08:55" },
{ t: "payment.sent", d: "Reward 2.5 μUTIL", ts: "08:40" },
];
return (
<Card className="rounded-2xl h-full">
<CardHeader className="py-3"><CardTitle className="text-sm">Стрічка подій</CardTitle></CardHeader>
<CardContent>
<ul className="space-y-2 text-sm">
{rows.map((r, i) => (
<li key={i} className="flex items-center gap-2">
<Activity className="h-4 w-4 text-slate-500"/>
<span className="font-medium">{r.t}</span>
<span className="text-slate-500"> {r.d}</span>
<span className="ml-auto text-xs text-slate-400">{r.ts}</span>
</li>
))}
</ul>
</CardContent>
</Card>
);
}
function TasksTable() {
const rows = [
{ agent: "parser-agent", task: "PDF→USDO", status: "running", eta: "2m" },
{ agent: "project-agent", task: "Бріф + Kanban", status: "queued", eta: "—" },
{ agent: "wallet-agent", task: "Нарахувати винагороду", status: "completed", eta: "0" },
];
const color = (s: string) => (s === "completed" ? "text-emerald-600" : s === "running" ? "text-amber-600" : "text-slate-500");
return (
<Card className="rounded-2xl h-full">
<CardHeader className="py-3"><CardTitle className="text-sm">Поточні задачі агентів</CardTitle></CardHeader>
<CardContent>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="text-left text-slate-500">
<tr>
<th className="py-2 pr-4">Агент</th>
<th className="py-2 pr-4">Задача</th>
<th className="py-2 pr-4">Статус</th>
<th className="py-2">ETA</th>
</tr>
</thead>
<tbody>
{rows.map((r, i) => (
<tr key={i} className="border-t">
<td className="py-2 pr-4 font-mono text-xs">{r.agent}</td>
<td className="py-2 pr-4">{r.task}</td>
<td className={`py-2 pr-4 ${color(r.status)}`}>{r.status}</td>
<td className="py-2">{r.eta}</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
);
}
function AgentGraph() {
// Minimal static SVG graph placeholder
return (
<Card className="rounded-2xl h-full">
<CardHeader className="py-3"><CardTitle className="text-sm">Граф агентів</CardTitle></CardHeader>
<CardContent>
<svg viewBox="0 0 400 220" className="w-full h-48">
<defs>
<filter id="shadow" x="-50%" y="-50%" width="200%" height="200%">
<feDropShadow dx="0" dy="1" stdDeviation="2" floodOpacity="0.2" />
</filter>
</defs>
<g filter="url(#shadow)">
<circle cx="200" cy="110" r="28" className="fill-slate-900" />
<text x="200" y="115" textAnchor="middle" className="fill-white text-xs">Orch</text>
{[
{ x: 80, y: 40, t: "Parser" },
{ x: 320, y: 40, t: "KB" },
{ x: 80, y: 180, t: "Wallet" },
{ x: 320, y: 180, t: "DAO" },
].map((n, i) => (
<g key={i}>
<line x1="200" y1="110" x2={n.x} y2={n.y} stroke="#94a3b8" />
<circle cx={n.x} cy={n.y} r="22" className="fill-white stroke-slate-300" />
<text x={n.x} y={n.y + 4} textAnchor="middle" className="fill-slate-700 text-xs">{n.t}</text>
</g>
))}
</g>
</svg>
</CardContent>
</Card>
);
}
function HomeOrchestrator() {
return (
<div className="p-4 space-y-4">
<HealthGrid />
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
<OrchestratorChat />
<div className="grid grid-rows-2 gap-4">
<ActivityFeed />
<TasksTable />
</div>
</div>
<AgentGraph />
</div>
);
}
function Placeholder({ title }: { title: string }) {
return (
<div className="p-6 text-sm text-slate-600">
<p className="mb-2">Розділ: <span className="font-medium">{title}</span></p>
<p>Тут буде детальний екран модуля. Структура готова до підключення реальних даних та маршрутів.</p>
</div>
);
}
function StartScreen({ onCreateDAO, onJoinDAO, onSolo, agentReady, onInstallAgent, installing, progress, modelProfile, setModelProfile, indexSize, setIndexSize, recommendedProfile, onPickCustomIndex }: { onCreateDAO: () => void; onJoinDAO: () => void; onSolo: () => void; agentReady: boolean; onInstallAgent: () => void; installing: boolean; progress: number; modelProfile: string; setModelProfile: (v: string) => void; indexSize: string; setIndexSize: (v: string) => void; recommendedProfile: string; onPickCustomIndex: () => void; }) {
return (
<div className="h-full flex items-center justify-center p-8">
<div className="max-w-3xl w-full grid grid-cols-1 lg:grid-cols-3 gap-4">
<Card className="rounded-2xl lg:col-span-2">
<CardHeader>
<CardTitle>Ласкаво просимо до MicroDAO</CardTitle>
</CardHeader>
<CardContent className="space-y-4 text-sm">
<p className="text-slate-600">Почніть зі створення спільноти DAO, приєднайтесь за інвайтом або спробуйте Solo-режим з локальним агентом.</p>
<div className="grid sm:grid-cols-3 gap-2">
<Button onClick={onCreateDAO} className="rounded-xl">Створити DAO</Button>
<Button variant="secondary" onClick={onJoinDAO} className="rounded-xl">Приєднатись</Button>
<Button variant="outline" onClick={onSolo} className="rounded-xl">Solo-режим</Button>
</div>
<div className="grid gap-3 md:grid-cols-2">
<div className="space-y-2">
<div className="text-xs uppercase text-slate-500">Профіль моделі</div>
<div className="flex flex-wrap gap-2">
{['Lite','Base','Plus','Pro'].map(p => (
<Button key={p} variant={modelProfile===p? 'default':'outline'} size="sm" className="rounded-xl" onClick={() => setModelProfile(p)}>
{p}{p===recommendedProfile && <Badge className="ml-2" variant="secondary">рекоменд.</Badge>}
</Button>
))}
</div>
<div className="text-xs text-slate-500">Автовибір за можливостями пристрою. Можна змінити пізніше.</div>
</div>
<div className="space-y-2">
<div className="text-xs uppercase text-slate-500">Локальний індекс</div>
<div className="flex flex-wrap gap-2">
{['200MB','500MB','1GB','5GB','20GB'].map(s => (
<Button key={s} variant={indexSize===s? 'default':'outline'} size="sm" className="rounded-xl" onClick={() => setIndexSize(s)}>{s}</Button>
))}
<Button variant="outline" size="sm" className="rounded-xl" onClick={onPickCustomIndex}>Власний шлях</Button>
</div>
<div className="text-xs text-slate-500">int8 квантування заощаджує ×34 місця.</div>
</div>
</div>
<div className="text-xs text-slate-500">Можна змінити вибір пізніше у Налаштуваннях.</div>
</CardContent>
</Card>
<Card className="rounded-2xl">
<CardHeader className="pb-2">
<CardTitle className="text-sm flex items-center gap-2"><Bot className="h-4 w-4"/>Локальний агент</CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm">
{agentReady ? (
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-emerald-600"/>Готовий до роботи</div>
) : (
<>
<div className="flex items-center gap-2">
{installing ? (
<AlertTriangle className="h-4 w-4 text-amber-600" />
) : (
<AlertTriangle className="h-4 w-4 text-amber-600" />
)}
{installing ? 'Встановлення моделі…' : 'Ще не встановлено'}
</div>
<Button size="sm" disabled={installing} onClick={onInstallAgent} className="rounded-xl w-full mt-1">
{installing ? 'Встановлення…' : 'Встановити агента'}
</Button>
{installing && (
<div className="space-y-1">
<Progress value={progress} />
<div className="text-xs text-slate-500">Завантаження моделі: {Math.round(progress)}%</div>
</div>
)}
<div className="text-xs text-slate-500">Обсяг моделі підбирається автоматично за можливостями пристрою.</div>
</>
)}
</CardContent>
</Card>
</div>
</div>
);
}
// --- helpers ---
async function pingHealthz(url: string, timeoutMs = 3000): Promise<boolean> {
try {
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), timeoutMs);
const res = await fetch(url, { signal: ctrl.signal });
clearTimeout(t);
return res.ok;
} catch {
return false;
}
}
async function recommendModelProfile(): Promise<"Lite"|"Base"|"Plus"|"Pro"> {
// Heuristics: deviceMemory (GB), CPU cores, simple UA.
// @ts-ignore
const dm = (navigator as any).deviceMemory || 4;
const cores = navigator.hardwareConcurrency || 4;
const isMobile = /iPhone|Android/i.test(navigator.userAgent);
if (dm >= 24 && cores >= 8 && !isMobile) return "Pro";
if (dm >= 12 && cores >= 8) return "Plus";
if (dm >= 6) return "Base";
return "Lite";
}
export default function OrchestratorLayout() {
const [active, setActive] = React.useState<ModuleKey>("home");
const [orgId, setOrgId] = React.useState<string | null>(null); // null = ще немає DAO/спільноти
const [agentReady, setAgentReady] = React.useState<boolean>(false);
const [installing, setInstalling] = React.useState<boolean>(false);
const [progress, setProgress] = React.useState<number>(0);
const [modelProfile, setModelProfile] = React.useState<string>('Base');
const [recommendedProfile, setRecommendedProfile] = React.useState<string>('Base');
const [indexSize, setIndexSize] = React.useState<string>('500MB');
const [netOnline, setNetOnline] = React.useState<boolean>(typeof navigator !== 'undefined' ? navigator.onLine : true);
const [orchOk, setOrchOk] = React.useState<boolean>(true);
React.useEffect(() => {
const on = () => setNetOnline(true);
const off = () => setNetOnline(false);
window.addEventListener('online', on);
window.addEventListener('offline', off);
let mounted = true;
// real /healthz ping every 10s
const tick = async () => {
const ok = await pingHealthz('/healthz');
if (mounted) setOrchOk(ok);
};
tick();
const id = setInterval(tick, 10000);
// model profile recommendation
recommendModelProfile().then(p => { if (mounted) { setRecommendedProfile(p); setModelProfile(p); } });
// restore custom index path if saved
const savedPath = localStorage.getItem('microdao.indexPath');
if (savedPath) setIndexSize(`custom:${savedPath}`);
return () => { mounted = false; window.removeEventListener('online', on); window.removeEventListener('offline', off); clearInterval(id); };
}, []);
// Симуляція інсталяції агента/моделі
const handleInstallAgent = () => {
if (installing || agentReady) return;
setInstalling(true);
setProgress(0);
const timer = setInterval(() => {
setProgress((p) => {
const next = Math.min(100, p + Math.random() * 18 + 5);
if (next >= 100) {
clearInterval(timer);
setInstalling(false);
setAgentReady(true);
}
return next;
});
}, 400);
};
// Роутінг після вибору режиму — встановлюємо orgId і відкриваємо домашню
const goReady = (id: string) => { setOrgId(id); setActive('home'); };
const content = () => {
if (!orgId) {
return (
<StartScreen
onCreateDAO={() => goReady('dao-created')}
onJoinDAO={() => goReady('dao-joined')}
onSolo={() => goReady('solo-mode')}
agentReady={agentReady}
onInstallAgent={handleInstallAgent}
installing={installing}
progress={progress}
modelProfile={modelProfile}
setModelProfile={setModelProfile}
indexSize={indexSize}
setIndexSize={setIndexSize}
recommendedProfile={recommendedProfile}
onPickCustomIndex={handlePickCustomIndex}
/>
);
}
switch (active) {
case "home":
return <HomeOrchestrator />;
case "messenger":
return <Placeholder title="Чати та канали" />;
case "projects":
return <Placeholder title="Проєкти" />;
case "memory":
return <Placeholder title="База знань" />;
case "parser":
return <Placeholder title="Парсер документів" />;
case "graph":
return <Placeholder title="Граф знань" />;
case "meetings":
return <Placeholder title="Зустрічі" />;
case "agents":
return <Placeholder title="Агенти" />;
case "training":
return <Placeholder title="Навчальний кабінет" />;
case "dao":
return <Placeholder title="Голосування / DAO" />;
case "treasury":
return <Placeholder title="Фінанси / Казна" />;
case "integrations":
return <Placeholder title="Інтеграції" />;
case "market":
return <Placeholder title="Маркетплейс" />;
case "analytics":
return <Placeholder title="Аналітика" />;
case "notifications":
return <Placeholder title="Сповіщення" />;
case "admin":
return <Placeholder title="Адмін-панель" />;
case "creative":
return <Placeholder title="Креативна студія" />;
case "security":
return <Placeholder title="Безпека / Аудит" />;
case "profile":
return <Placeholder title="Профіль" />;
}
};
// Choose custom directory for local index and persist label
const handlePickCustomIndex = async () => {
// File System Access API if available
// @ts-ignore
if (window.showDirectoryPicker) {
try {
// @ts-ignore
const dir = await window.showDirectoryPicker();
const name = dir.name || 'custom-index';
localStorage.setItem('microdao.indexPath', name);
setIndexSize(`custom:${name}`);
return;
} catch {}
}
// Fallback via input directory (webkitdirectory)
const input = document.createElement('input');
// @ts-ignore
input.webkitdirectory = true; input.type = 'file';
input.onchange = () => {
const files = input.files || [] as any;
const any = files.length ? (files[0] as any) : null;
const guess = any?.webkitRelativePath?.split('/')?.[0] || 'custom-index';
localStorage.setItem('microdao.indexPath', guess);
setIndexSize(`custom:${guess}`);
};
input.click();
};
return (
<div className="h-[100vh] w-full grid grid-cols-[18rem_1fr] bg-slate-50 text-slate-900">
<Sidebar active={active} onSelect={setActive} />
<main className="flex flex-col">
<Topbar netOnline={netOnline} orchOk={orchOk} />
<div className="flex-1 overflow-auto">{content()}</div>
</main>
</div>
);
}

View File

@@ -0,0 +1,363 @@
import React from "react";
import {
Activity,
AlertTriangle,
CheckCircle2,
Cpu,
Database,
GitBranch,
LineChart,
MessageSquare,
Settings,
ShieldCheck,
User,
Vote,
Wallet,
Zap,
Bot,
Boxes,
BookOpen,
FileText,
Network,
Video,
Palette,
Bell,
Store,
Plug,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Badge } from "@/components/ui/badge";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
// ---------- Utility types ----------
type ModuleKey =
| "home"
| "messenger"
| "projects"
| "memory"
| "parser"
| "graph"
| "meetings"
| "agents"
| "training"
| "dao"
| "treasury"
| "integrations"
| "market"
| "analytics"
| "notifications"
| "admin"
| "creative"
| "security"
| "profile";
const MODULES: Array<{
key: ModuleKey;
label: string;
icon: React.ElementType;
}> = [
{ key: "home", label: "Головна / Оркестратор", icon: Cpu },
{ key: "messenger", label: "Чати та канали", icon: MessageSquare },
{ key: "projects", label: "Проєкти", icon: Boxes },
{ key: "memory", label: "База знань", icon: BookOpen },
{ key: "parser", label: "Парсер документів", icon: FileText },
{ key: "graph", label: "Граф знань", icon: Network },
{ key: "meetings", label: "Зустрічі", icon: Video },
{ key: "agents", label: "Агенти", icon: Bot },
{ key: "training", label: "Навчальний кабінет", icon: GitBranch },
{ key: "dao", label: "Голосування / DAO", icon: Vote },
{ key: "treasury", label: "Фінанси / Казна", icon: Wallet },
{ key: "integrations", label: "Інтеграції", icon: Plug },
{ key: "market", label: "Маркетплейс", icon: Store },
{ key: "analytics", label: "Аналітика", icon: LineChart },
{ key: "notifications", label: "Сповіщення", icon: Bell },
{ key: "admin", label: "Адмін-панель", icon: Settings },
{ key: "creative", label: "Креативна студія", icon: Palette },
{ key: "security", label: "Безпека / Аудит", icon: ShieldCheck },
{ key: "profile", label: "Профіль", icon: User },
];
// ---------- Reusable UI blocks ----------
function Sidebar({ active, onSelect }: { active: ModuleKey; onSelect: (k: ModuleKey) => void }) {
return (
<aside className="h-full w-72 border-r bg-white/60 backdrop-blur-sm">
<div className="p-4 border-b flex items-center gap-2">
<Cpu className="h-5 w-5" />
<span className="font-semibold">MicroDAO</span>
<Badge variant="secondary" className="ml-auto">orchestrator</Badge>
</div>
<nav className="p-2 space-y-1 overflow-y-auto h-[calc(100%-3.5rem)]">
{MODULES.map((m) => (
<button
key={m.key}
onClick={() => onSelect(m.key)}
className={`w-full flex items-center gap-3 rounded-xl px-3 py-2 hover:bg-slate-100 transition ${
active === m.key ? "bg-slate-900 text-white hover:bg-slate-900" : ""
}`}
aria-current={active === m.key}
>
<m.icon className="h-4 w-4" />
<span className="text-sm text-left truncate">{m.label}</span>
{m.key === "notifications" && (
<Badge className={`ml-auto ${active === m.key ? "bg-white text-slate-900" : ""}`}>3</Badge>
)}
</button>
))}
</nav>
</aside>
);
}
function Topbar() {
return (
<div className="h-14 border-b flex items-center gap-3 px-4 bg-white/70 backdrop-blur">
<Input placeholder="Пошук по всіх модулях" className="max-w-md" />
<Tabs defaultValue="dao" className="ml-2">
<TabsList>
<TabsTrigger value="private">Private</TabsTrigger>
<TabsTrigger value="dao">DAO</TabsTrigger>
<TabsTrigger value="public">Public</TabsTrigger>
</TabsList>
</Tabs>
<div className="ml-auto flex items-center gap-2">
<Badge variant="outline" className="flex items-center gap-1"><Database className="h-4 w-4"/>DB ok</Badge>
<Badge variant="outline" className="flex items-center gap-1"><Zap className="h-4 w-4"/>DAGI sync</Badge>
<Button variant="secondary" size="sm" className="gap-2"><Settings className="h-4 w-4"/>Налаштування</Button>
</div>
</div>
);
}
function HealthGrid() {
const items = [
{ title: "Messenger", ok: true },
{ title: "Parser", ok: false },
{ title: "KB Core", ok: true },
{ title: "RAG", ok: true },
{ title: "Wallet", ok: true },
{ title: "DAO", ok: true },
];
return (
<div className="grid grid-cols-2 lg:grid-cols-3 gap-3">
{items.map((x) => (
<Card key={x.title} className="rounded-2xl">
<CardHeader className="py-3">
<CardTitle className="text-sm font-medium flex items-center gap-2">
{x.ok ? (
<CheckCircle2 className="h-4 w-4 text-emerald-600" />
) : (
<AlertTriangle className="h-4 w-4 text-amber-600" />
)}
{x.title}
</CardTitle>
</CardHeader>
<CardContent className="py-2">
<div className="text-xs text-slate-500">{x.ok ? "Працює стабільно" : "Черга задач > p95"}</div>
</CardContent>
</Card>
))}
</div>
);
}
function OrchestratorChat() {
return (
<Card className="rounded-2xl h-full flex flex-col">
<CardHeader className="py-3">
<CardTitle className="text-sm">Чат з Оркестратором</CardTitle>
</CardHeader>
<CardContent className="flex-1 flex flex-col gap-3">
<div className="flex-1 rounded-xl border bg-slate-50 p-3 text-sm overflow-auto">
<div className="text-slate-500">Вітаю. Чим допомогти? Наприклад: "Розбери PDF та створй короткий бріф у Проєктах".</div>
</div>
<div className="flex items-center gap-2">
<Textarea placeholder="Запитайте або використайте @agent" className="min-h-[44px]" />
<Button className="whitespace-nowrap">Надіслати</Button>
</div>
</CardContent>
</Card>
);
}
function ActivityFeed() {
const rows = [
{ t: "ingest.completed", d: "USDO готово", ts: "09:15" },
{ t: "message.created", d: "#general", ts: "09:12" },
{ t: "vote.finalized", d: "Постанова #12", ts: "08:55" },
{ t: "payment.sent", d: "Reward 2.5 μUTIL", ts: "08:40" },
];
return (
<Card className="rounded-2xl h-full">
<CardHeader className="py-3"><CardTitle className="text-sm">Стрічка подій</CardTitle></CardHeader>
<CardContent>
<ul className="space-y-2 text-sm">
{rows.map((r, i) => (
<li key={i} className="flex items-center gap-2">
<Activity className="h-4 w-4 text-slate-500"/>
<span className="font-medium">{r.t}</span>
<span className="text-slate-500"> {r.d}</span>
<span className="ml-auto text-xs text-slate-400">{r.ts}</span>
</li>
))}
</ul>
</CardContent>
</Card>
);
}
function TasksTable() {
const rows = [
{ agent: "parser-agent", task: "PDF→USDO", status: "running", eta: "2m" },
{ agent: "project-agent", task: "Бріф + Kanban", status: "queued", eta: "—" },
{ agent: "wallet-agent", task: "Нарахувати винагороду", status: "completed", eta: "0" },
];
const color = (s: string) => (s === "completed" ? "text-emerald-600" : s === "running" ? "text-amber-600" : "text-slate-500");
return (
<Card className="rounded-2xl h-full">
<CardHeader className="py-3"><CardTitle className="text-sm">Поточні задачі агентів</CardTitle></CardHeader>
<CardContent>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="text-left text-slate-500">
<tr>
<th className="py-2 pr-4">Агент</th>
<th className="py-2 pr-4">Задача</th>
<th className="py-2 pr-4">Статус</th>
<th className="py-2">ETA</th>
</tr>
</thead>
<tbody>
{rows.map((r, i) => (
<tr key={i} className="border-t">
<td className="py-2 pr-4 font-mono text-xs">{r.agent}</td>
<td className="py-2 pr-4">{r.task}</td>
<td className={`py-2 pr-4 ${color(r.status)}`}>{r.status}</td>
<td className="py-2">{r.eta}</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
);
}
function AgentGraph() {
// Minimal static SVG graph placeholder
return (
<Card className="rounded-2xl h-full">
<CardHeader className="py-3"><CardTitle className="text-sm">Граф агентів</CardTitle></CardHeader>
<CardContent>
<svg viewBox="0 0 400 220" className="w-full h-48">
<defs>
<filter id="shadow" x="-50%" y="-50%" width="200%" height="200%">
<feDropShadow dx="0" dy="1" stdDeviation="2" floodOpacity="0.2" />
</filter>
</defs>
<g filter="url(#shadow)">
<circle cx="200" cy="110" r="28" className="fill-slate-900" />
<text x="200" y="115" textAnchor="middle" className="fill-white text-xs">Orch</text>
{[
{ x: 80, y: 40, t: "Parser" },
{ x: 320, y: 40, t: "KB" },
{ x: 80, y: 180, t: "Wallet" },
{ x: 320, y: 180, t: "DAO" },
].map((n, i) => (
<g key={i}>
<line x1="200" y1="110" x2={n.x} y2={n.y} stroke="#94a3b8" />
<circle cx={n.x} cy={n.y} r="22" className="fill-white stroke-slate-300" />
<text x={n.x} y={n.y + 4} textAnchor="middle" className="fill-slate-700 text-xs">{n.t}</text>
</g>
))}
</g>
</svg>
</CardContent>
</Card>
);
}
function HomeOrchestrator() {
return (
<div className="p-4 space-y-4">
<HealthGrid />
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
<OrchestratorChat />
<div className="grid grid-rows-2 gap-4">
<ActivityFeed />
<TasksTable />
</div>
</div>
<AgentGraph />
</div>
);
}
function Placeholder({ title }: { title: string }) {
return (
<div className="p-6 text-sm text-slate-600">
<p className="mb-2">Розділ: <span className="font-medium">{title}</span></p>
<p>Тут буде детальний екран модуля. Структура готова до підключення реальних даних та маршрутів.</p>
</div>
);
}
export default function OrchestratorLayout() {
const [active, setActive] = React.useState<ModuleKey>("home");
const content = () => {
switch (active) {
case "home":
return <HomeOrchestrator />;
case "messenger":
return <Placeholder title="Чати та канали" />;
case "projects":
return <Placeholder title="Проєкти" />;
case "memory":
return <Placeholder title="База знань" />;
case "parser":
return <Placeholder title="Парсер документів" />;
case "graph":
return <Placeholder title="Граф знань" />;
case "meetings":
return <Placeholder title="Зустрічі" />;
case "agents":
return <Placeholder title="Агенти" />;
case "training":
return <Placeholder title="Навчальний кабінет" />;
case "dao":
return <Placeholder title="Голосування / DAO" />;
case "treasury":
return <Placeholder title="Фінанси / Казна" />;
case "integrations":
return <Placeholder title="Інтеграції" />;
case "market":
return <Placeholder title="Маркетплейс" />;
case "analytics":
return <Placeholder title="Аналітика" />;
case "notifications":
return <Placeholder title="Сповіщення" />;
case "admin":
return <Placeholder title="Адмін-панель" />;
case "creative":
return <Placeholder title="Креативна студія" />;
case "security":
return <Placeholder title="Безпека / Аудит" />;
case "profile":
return <Placeholder title="Профіль" />;
}
};
return (
<div className="h-[100vh] w-full grid grid-cols-[18rem_1fr] bg-slate-50 text-slate-900">
<Sidebar active={active} onSelect={setActive} />
<main className="flex flex-col">
<Topbar />
<div className="flex-1 overflow-auto">{content()}</div>
</main>
</div>
);
}

View File

@@ -0,0 +1,201 @@
# microdao_dependency_matrix.yaml
version: 1
stages:
- { id: P0, name: Foundation, description: "IAM, Gateway, Data, Policy, Events, Observability, UI shell" }
- { id: P1, name: Intelligence, description: "KB Core, Parser, SLM Agent" }
- { id: P2, name: Comms, description: "Messenger, Notifications, Meeting Agent" }
- { id: P3, name: Org/Econ, description: "Project Manager, DAO Governance, Wallet/Finance" }
- { id: P4, name: Extensions, description: "Integration Hub, Marketplace, Training Lab, Security Audit" }
environments:
- { id: dev, url: "https://dev.microdao.local", require_approvals: false, replicas_factor: 1 }
- { id: stage, url: "https://stage.microdao.app", require_approvals: true, replicas_factor: 1 }
- { id: prod, url: "https://microdao.app", require_approvals: true, replicas_factor: 3 }
globals:
readiness_gates:
- { name: db-ready, check: http+json, endpoint: "/readyz", expect: { deps: { db: ok } } }
- { name: policy-ready, check: http+json, endpoint: "/readyz", expect: { deps: { policy: ok } } }
rollout:
strategy: canary
canary_traffic_steps: [5, 25, 50, 100]
abort_on: ["5xx_rate > 0.5%", "p95_latency_ms > 800"]
feature_flags:
- { name: rag_in_chat, default: true }
- { name: parser_wasm, default: false }
- { name: dao_quadratic_vote, default: false }
services:
- id: iam
stage: P0
env_vars: [JWT_PUBLIC_KEYS, OAUTH_CLIENTS]
depends_on: []
healthz: /healthz
readyz: /readyz
ownership: { team: core-platform, oncall: "#oncall-core" }
alerts: [{ name: auth-5xx, metric: http_5xx_rate, threshold: 0.5 }]
- id: gateway
stage: P0
env_vars: [HMAC_SECRET, RATE_LIMITS]
depends_on: [iam, policy]
healthz: /healthz
readyz: /readyz
ownership: { team: edge, oncall: "#oncall-edge" }
alerts: [{ name: ratelimit-shed, metric: http_429_rate, threshold: 5.0 }]
- id: data-plane
stage: P0
kind: postgres+blob+vector
env_vars: [PG_URL, BLOB_BUCKET, VECTOR_INDEX]
depends_on: []
healthz: /healthz
readyz: /readyz
ownership: { team: data, oncall: "#oncall-data" }
- id: policy
stage: P0
env_vars: [POLICY_BACKEND, DEFAULT_POLICIES]
depends_on: [data-plane]
healthz: /healthz
readyz: /readyz
ownership: { team: security, oncall: "#oncall-security" }
- id: events
stage: P0
kind: sse+webhooks
env_vars: [WEBHOOK_SECRET, QUEUE_URL]
depends_on: [gateway]
healthz: /healthz
readyz: /readyz
ownership: { team: platform, oncall: "#oncall-platform" }
- id: observability
stage: P0
kind: metrics+logs+traces
env_vars: [OTLP_COLLECTOR, LOG_SINK]
depends_on: []
healthz: /healthz
readyz: /readyz
ownership: { team: sre, oncall: "#oncall-sre" }
- id: ui-shell
stage: P0
kind: frontend
env_vars: [GATEWAY_URL, FEATURE_FLAGS]
depends_on: [gateway]
healthz: /healthz
readyz: /readyz
ownership: { team: frontend, oncall: "#oncall-frontend" }
- id: kb-core
stage: P1
kind: rag+kg
env_vars: [PG_URL, VECTOR_INDEX, RAG_MODEL]
depends_on: [data-plane, policy]
healthz: /healthz
readyz: /readyz
gates: [db-ready, policy-ready]
ownership: { team: intelligence, oncall: "#oncall-ml" }
- id: parser
stage: P1
kind: ingest/usdo
env_vars: [PG_URL, OBJECT_BUCKET, PARSER_MODELS]
depends_on: [kb-core, policy, events]
healthz: /healthz
readyz: /readyz
ownership: { team: ingest, oncall: "#oncall-ingest" }
- id: slm-agent
stage: P1
kind: inference
env_vars: [SLM_MODEL_PATH, WEBNN_ENABLE]
depends_on: [kb-core, policy]
healthz: /healthz
readyz: /readyz
ownership: { team: intelligence, oncall: "#oncall-ml" }
- id: messenger
stage: P2
env_vars: [PG_URL, WS_BROKER]
depends_on: [gateway, events, policy, data-plane]
healthz: /healthz
readyz: /readyz
ownership: { team: messaging, oncall: "#oncall-messaging" }
- id: meeting-agent
stage: P2
env_vars: [MEDIA_RECORDER, STT_MODEL]
depends_on: [messenger, events]
healthz: /healthz
readyz: /readyz
feature_flags: [meeting_agent_beta]
ownership: { team: comms, oncall: "#oncall-comms" }
- id: project-manager
stage: P3
env_vars: [PG_URL]
depends_on: [slm-agent, messenger, events]
healthz: /healthz
readyz: /readyz
ownership: { team: productivity, oncall: "#oncall-pm" }
- id: wallet
stage: P3
env_vars: [WALLET_MNEMONIC, CHAIN_RPC]
depends_on: [gateway, policy]
healthz: /healthz
readyz: /readyz
ownership: { team: finance, oncall: "#oncall-finance" }
- id: dao
stage: P3
env_vars: [WALLET_RPC, TOKEN_ADDR]
depends_on: [policy, wallet, events]
healthz: /healthz
readyz: /readyz
ownership: { team: governance, oncall: "#oncall-governance" }
- id: integration-hub
stage: P4
env_vars: [CONNECTOR_KEYS, TELEGRAM_TOKEN, GITHUB_TOKEN]
depends_on: [gateway, events, policy]
healthz: /healthz
readyz: /readyz
ownership: { team: platform, oncall: "#oncall-platform" }
- id: marketplace
stage: P4
env_vars: [MARKET_FEE_BPS, AMM_POOL_ADDR]
depends_on: [wallet, dao, policy]
healthz: /healthz
readyz: /readyz
ownership: { team: economy, oncall: "#oncall-economy" }
- id: training-lab
stage: P4
env_vars: [FEEDBACK_BUCKET]
depends_on: [slm-agent, kb-core, events]
healthz: /healthz
readyz: /readyz
ownership: { team: mlops, oncall: "#oncall-mlops" }
- id: security-audit
stage: P4
env_vars: [AUDIT_SINK]
depends_on: [gateway, policy]
healthz: /healthz
readyz: /readyz
ownership: { team: security, oncall: "#oncall-security" }
pipelines:
deploy:
order: [P0, P1, P2, P3, P4]
env_sequence: [dev, stage, prod]
gates:
- smoke: "http 200 on /healthz for all services in stage"
- load: "p95 < 800ms for kb-core, messenger in stage"
- error_budget: "< 1% over last 24h before prod"
notifications:
slack_channels: ["#deployments", "#oncall"]

248
microdao_schema.sql Normal file
View File

@@ -0,0 +1,248 @@
-- MicroDAO core schema (DDL)
-- Requires: PostgreSQL 14+, extensions pgcrypto and pgvector
-- Safe to run multiple times (uses IF NOT EXISTS where possible)
-- 0) Extensions
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE EXTENSION IF NOT EXISTS vector;
-- 1) Enum types
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'org_type') THEN
CREATE TYPE org_type AS ENUM ('solo', 'dao');
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'visibility') THEN
CREATE TYPE visibility AS ENUM ('private', 'dao', 'public');
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'notification_status') THEN
CREATE TYPE notification_status AS ENUM ('queued', 'sent', 'failed', 'read');
END IF;
END $$;
-- 2) Helpers
CREATE OR REPLACE FUNCTION set_updated_at()
RETURNS TRIGGER LANGUAGE plpgsql AS $$
BEGIN
NEW.updated_at := NOW();
RETURN NEW;
END $$;
-- 3) Users and Orgs
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT UNIQUE NOT NULL,
name TEXT,
plan TEXT DEFAULT 'free',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TRIGGER trg_users_updated_at
BEFORE UPDATE ON users
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
CREATE TABLE IF NOT EXISTS orgs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
type org_type NOT NULL DEFAULT 'solo',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TRIGGER trg_orgs_updated_at
BEFORE UPDATE ON orgs
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
-- (optional) membership table for future use
CREATE TABLE IF NOT EXISTS org_members (
org_id UUID NOT NULL REFERENCES orgs(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role TEXT NOT NULL DEFAULT 'member',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (org_id, user_id)
);
-- 4) Channels and Messages
CREATE TABLE IF NOT EXISTS channels (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL REFERENCES orgs(id) ON DELETE CASCADE,
name TEXT NOT NULL,
visibility visibility NOT NULL DEFAULT 'dao',
meta JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_channels_org ON channels(org_id);
CREATE INDEX IF NOT EXISTS idx_channels_meta_gin ON channels USING GIN (meta);
CREATE TRIGGER trg_channels_updated_at
BEFORE UPDATE ON channels
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
CREATE TABLE IF NOT EXISTS messages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
author_id UUID NOT NULL REFERENCES users(id) ON DELETE SET NULL,
content TEXT NOT NULL,
meta JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_messages_channel_created ON messages(channel_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_messages_meta_gin ON messages USING GIN (meta);
-- 5) Documents and USDO entities
CREATE TABLE IF NOT EXISTS documents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
source TEXT NOT NULL, -- e.g., 'upload', 'ipfs://cid', 'arxiv:XXXX'
owner_type TEXT NOT NULL CHECK (owner_type IN ('user','org')),
owner_id UUID NOT NULL,
format TEXT NOT NULL, -- 'pdf','html','image','md'
meta JSONB NOT NULL DEFAULT '{}', -- e.g., filename, hashes, policyId
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT fk_documents_owner_user
FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED
-- If owner_type='org', create a partial FK via trigger or accept soft constraint
);
-- Optional soft constraint: ensure owner exists in either table (implemented via trigger in app-layer).
CREATE INDEX IF NOT EXISTS idx_documents_owner ON documents(owner_id);
CREATE INDEX IF NOT EXISTS idx_documents_meta_gin ON documents USING GIN (meta);
CREATE TRIGGER trg_documents_updated_at
BEFORE UPDATE ON documents
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
CREATE TABLE IF NOT EXISTS usdo_entities (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
doc_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
kind TEXT NOT NULL, -- 'section','equation','table','figure','code','reference', etc.
payload JSONB NOT NULL, -- normalized USDO fragment
meta JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_usdo_doc_kind ON usdo_entities(doc_id, kind);
CREATE INDEX IF NOT EXISTS idx_usdo_payload_gin ON usdo_entities USING GIN (payload);
CREATE INDEX IF NOT EXISTS idx_usdo_meta_gin ON usdo_entities USING GIN (meta);
-- 6) Chunks and RAG index
-- pgvector storage for text/image embeddings; adjust dimensions to your model
CREATE TABLE IF NOT EXISTS chunks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
doc_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
text TEXT NOT NULL,
meta JSONB NOT NULL DEFAULT '{}', -- offsets, section ref, citations, policy, etc.
acl visibility NOT NULL DEFAULT 'dao',
vec VECTOR(1536), -- set to your embedding dimension
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_chunks_doc ON chunks(doc_id);
CREATE INDEX IF NOT EXISTS idx_chunks_acl ON chunks(acl);
CREATE INDEX IF NOT EXISTS idx_chunks_meta_gin ON chunks USING GIN (meta);
-- IVF index for ANN search; tune lists per dataset size
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_indexes WHERE schemaname = 'public' AND indexname = 'idx_chunks_vec_ivfflat'
) THEN
CREATE INDEX idx_chunks_vec_ivfflat ON chunks USING ivfflat (vec vector_l2_ops) WITH (lists = 100);
END IF;
END $$;
CREATE TABLE IF NOT EXISTS rag_index (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
chunk_id UUID NOT NULL UNIQUE REFERENCES chunks(id) ON DELETE CASCADE,
space visibility NOT NULL, -- private/dao/public space
meta JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_rag_space ON rag_index(space);
CREATE INDEX IF NOT EXISTS idx_rag_meta_gin ON rag_index USING GIN (meta);
-- 7) Agent events, Policies, Rewards, Notifications, Audit
CREATE TABLE IF NOT EXISTS agent_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
run_id UUID NOT NULL,
type TEXT NOT NULL, -- e.g., 'run.started','run.completed','run.failed','policy.blocked'
payload JSONB NOT NULL DEFAULT '{}',
ts TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_agent_events_run ON agent_events(run_id, ts DESC);
CREATE INDEX IF NOT EXISTS idx_agent_events_type ON agent_events(type);
CREATE INDEX IF NOT EXISTS idx_agent_events_payload_gin ON agent_events USING GIN (payload);
CREATE TABLE IF NOT EXISTS policies (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
scope TEXT NOT NULL, -- e.g., 'org:{id}', 'channel:{id}', 'global'
rules JSONB NOT NULL, -- structured policy rules
meta JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_policies_scope ON policies(scope);
CREATE INDEX IF NOT EXISTS idx_policies_rules_gin ON policies USING GIN (rules);
CREATE TRIGGER trg_policies_updated_at
BEFORE UPDATE ON policies
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
CREATE TABLE IF NOT EXISTS rewards (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
actor_id UUID NOT NULL REFERENCES users(id) ON DELETE SET NULL,
kind TEXT NOT NULL, -- e.g., 'ingest','compute','contribution'
amount NUMERIC(18,6) NOT NULL CHECK (amount >= 0),
meta JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_rewards_actor ON rewards(actor_id);
CREATE INDEX IF NOT EXISTS idx_rewards_kind ON rewards(kind);
CREATE INDEX IF NOT EXISTS idx_rewards_meta_gin ON rewards USING GIN (meta);
CREATE TABLE IF NOT EXISTS notifications (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
kind TEXT NOT NULL, -- e.g., 'ingest.completed','quota.exceeded'
payload JSONB NOT NULL DEFAULT '{}',
status notification_status NOT NULL DEFAULT 'queued',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
sent_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_notifications_user_status ON notifications(user_id, status);
CREATE INDEX IF NOT EXISTS idx_notifications_kind ON notifications(kind);
CREATE TABLE IF NOT EXISTS audit_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
actor UUID, -- user id or service principal
action TEXT NOT NULL, -- e.g., 'policy.update','channel.visibility.changed'
target JSONB NOT NULL DEFAULT '{}', -- embeds ids, kinds
ts TIMESTAMPTZ NOT NULL DEFAULT NOW(),
ip INET
);
CREATE INDEX IF NOT EXISTS idx_audit_ts ON audit_logs(ts DESC);
CREATE INDEX IF NOT EXISTS idx_audit_action ON audit_logs(action);
CREATE INDEX IF NOT EXISTS idx_audit_target_gin ON audit_logs USING GIN (target);
-- 8) Basic RLS scaffolding (disabled by default)
-- Enable and tailor at the application stage. Left commented intentionally.
-- ALTER TABLE users ENABLE ROW LEVEL SECURITY;
-- ALTER TABLE orgs ENABLE ROW LEVEL SECURITY;
-- ALTER TABLE channels ENABLE ROW LEVEL SECURITY;
-- ALTER TABLE messages ENABLE ROW LEVEL SECURITY;
-- ALTER TABLE documents ENABLE ROW LEVEL SECURITY;
-- ALTER TABLE chunks ENABLE ROW LEVEL SECURITY;
-- ALTER TABLE rag_index ENABLE ROW LEVEL SECURITY;
-- Example policy patterns (to customize):
-- CREATE POLICY org_member_can_read_channels ON channels
-- FOR SELECT USING (EXISTS (SELECT 1 FROM org_members m WHERE m.org_id = channels.org_id AND m.user_id = auth.uid()));
-- CREATE POLICY author_can_edit_message ON messages
-- FOR UPDATE USING (author_id = auth.uid());
-- 9) Comments for maintainability
COMMENT ON TABLE users IS 'End users of MicroDAO. Plan used for quotas.';
COMMENT ON TABLE orgs IS 'Organizations/DAOs.';
COMMENT ON TABLE channels IS 'Chat channels per org with visibility.';
COMMENT ON TABLE messages IS 'Messages in channels with JSONB meta for mentions, citations.';
COMMENT ON TABLE documents IS 'Ingested sources. USDO is derived from here.';
COMMENT ON TABLE usdo_entities IS 'USDO fragments: sections, equations, tables, figures, code, references.';
COMMENT ON TABLE chunks IS 'RAG chunks with embeddings and ACL.';
COMMENT ON COLUMN chunks.vec IS 'Embedding vector; dimension must match your model.';
COMMENT ON TABLE rag_index IS 'Space mapping for chunks (private/dao/public).';
COMMENT ON TABLE agent_events IS 'Operational events for agent runs.';
COMMENT ON TABLE policies IS 'Policy documents and metadata.';
COMMENT ON TABLE rewards IS 'Rewards issued to actors for contributions/compute.';
COMMENT ON TABLE notifications IS 'User notifications across channels.';
COMMENT ON TABLE audit_logs IS 'Immutable audit trail for compliance.';

51
src/README.md Normal file
View File

@@ -0,0 +1,51 @@
# MicroDAO Frontend - Структура проекту
## Структура каталогів
```
src/
api/ # API клієнти та типи
client.ts # Базовий API клієнт
auth.ts # Авторизація
teams.ts # Спільноти
channels.ts # Канали
agents.ts # Агенти
components/ # React компоненти
onboarding/ # Компоненти онбордингу
OnboardingStepper.tsx
StepWelcome.tsx
StepCreateTeam.tsx
StepSelectMode.tsx
StepCreateChannel.tsx
StepAgentSettings.tsx
StepInvite.tsx
hooks/ # React hooks
useOnboarding.ts
pages/ # Сторінки
OnboardingPage.tsx
types/ # TypeScript типи
api.ts
```
## Онбординг
Онбординг реалізовано як багатокроковий процес з 6 кроками:
1. **Ласкаво просимо** - привітальний екран
2. **Створити спільноту** - форма з назвою та описом
3. **Режим приватності** - вибір Public/Confidential
4. **Перший канал** - створення каналу
5. **Агент та пам'ять** - налаштування агента
6. **Запросити команду** - посилання-запрошення
## API Інтеграція
Всі API виклики типізовані та обробляють помилки. Базовий URL налаштовується через змінну середовища `VITE_API_URL` (за замовчуванням `https://api.microdao.xyz`).
## Наступні кроки
- Додати сторінку налаштувань (Settings)
- Реалізувати чат інтерфейс
- Додати публічний канал landing page
- Інтегрувати WebSocket для real-time оновлень

11
src/api/agents.ts Normal file
View File

@@ -0,0 +1,11 @@
import { apiGet, apiPost } from './client';
import type { Agent, CreateAgentRequest } from '../types/api';
export async function getAgents(): Promise<{ agents: Agent[] }> {
return apiGet<{ agents: Agent[] }>('/agents');
}
export async function createAgent(data: CreateAgentRequest): Promise<Agent> {
return apiPost<Agent>('/agents', data);
}

16
src/api/auth.ts Normal file
View File

@@ -0,0 +1,16 @@
import { apiPost } from './client';
import type { AuthResponse, LoginEmailRequest, ExchangeCodeRequest } from '../types/api';
export async function loginEmail(data: LoginEmailRequest): Promise<{ success: boolean; message: string }> {
return apiPost<{ success: boolean; message: string }>('/auth/login-email', data);
}
export async function exchangeCode(data: ExchangeCodeRequest): Promise<AuthResponse> {
const response = await apiPost<AuthResponse>('/auth/exchange', data);
// Зберігаємо токен
if (response.token) {
localStorage.setItem('auth_token', response.token);
}
return response;
}

28
src/api/channels.ts Normal file
View File

@@ -0,0 +1,28 @@
import { apiGet, apiPost } from './client';
import type { Channel, CreateChannelRequest, Message } from '../types/api';
export async function createChannel(data: CreateChannelRequest): Promise<Channel> {
return apiPost<Channel>('/channels', data);
}
export async function getChannels(teamId: string): Promise<{ channels: Channel[] }> {
return apiGet<{ channels: Channel[] }>(`/channels?team_id=${teamId}`);
}
export async function getChannelMessages(
channelId: string,
cursor?: string,
limit = 50
): Promise<{ messages: Message[]; next_cursor?: string }> {
const params = new URLSearchParams();
if (cursor) params.set('cursor', cursor);
params.set('limit', limit.toString());
return apiGet<{ messages: Message[]; next_cursor?: string }>(
`/channels/${channelId}/messages?${params.toString()}`
);
}
export async function getPublicChannel(slug: string): Promise<Channel> {
return apiGet<Channel>(`/channels/public/${slug}`);
}

83
src/api/client.ts Normal file
View File

@@ -0,0 +1,83 @@
// API Client для MicroDAO
const API_BASE_URL = import.meta.env.VITE_API_URL || 'https://api.microdao.xyz';
export class ApiError extends Error {
constructor(
message: string,
public status: number,
public data?: unknown
) {
super(message);
this.name = 'ApiError';
}
}
async function getAuthToken(): Promise<string | null> {
// Перевіряємо localStorage або httpOnly cookie
return localStorage.getItem('auth_token');
}
async function fetchApi(
endpoint: string,
options: RequestInit = {}
): Promise<Response> {
const token = await getAuthToken();
const headers: HeadersInit = {
'Content-Type': 'application/json',
...options.headers,
};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
const url = endpoint.startsWith('http') ? endpoint : `${API_BASE_URL}${endpoint}`;
const response = await fetch(url, {
...options,
headers,
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new ApiError(
errorData.message || `HTTP ${response.status}: ${response.statusText}`,
response.status,
errorData
);
}
return response;
}
export async function apiGet<T>(endpoint: string): Promise<T> {
const response = await fetchApi(endpoint, { method: 'GET' });
return response.json();
}
export async function apiPost<T>(endpoint: string, data?: unknown): Promise<T> {
const response = await fetchApi(endpoint, {
method: 'POST',
body: data ? JSON.stringify(data) : undefined,
});
return response.json();
}
export async function apiPatch<T>(endpoint: string, data: unknown): Promise<T> {
const response = await fetchApi(endpoint, {
method: 'PATCH',
body: JSON.stringify(data),
});
return response.json();
}
export async function apiDelete<T>(endpoint: string): Promise<T> {
const response = await fetchApi(endpoint, { method: 'DELETE' });
if (response.status === 204) {
return {} as T;
}
return response.json();
}

19
src/api/teams.ts Normal file
View File

@@ -0,0 +1,19 @@
import { apiGet, apiPost, apiPatch } from './client';
import type { Team, CreateTeamRequest, UpdateTeamRequest } from '../types/api';
export async function createTeam(data: CreateTeamRequest): Promise<Team> {
return apiPost<Team>('/teams', data);
}
export async function getTeams(): Promise<{ teams: Team[] }> {
return apiGet<{ teams: Team[] }>('/teams');
}
export async function getTeam(teamId: string): Promise<Team> {
return apiGet<Team>(`/teams/${teamId}`);
}
export async function updateTeam(teamId: string, data: UpdateTeamRequest): Promise<Team> {
return apiPatch<Team>(`/teams/${teamId}`, data);
}

View File

@@ -0,0 +1,79 @@
import React from 'react';
import { Check } from 'lucide-react';
interface Step {
number: number;
title: string;
}
interface OnboardingStepperProps {
currentStep: number;
totalSteps: number;
}
const steps: Step[] = [
{ number: 1, title: 'Ласкаво просимо' },
{ number: 2, title: 'Створити спільноту' },
{ number: 3, title: 'Режим приватності' },
{ number: 4, title: 'Перший канал' },
{ number: 5, title: 'Агент та пам\'ять' },
{ number: 6, title: 'Запросити команду' },
];
export function OnboardingStepper({ currentStep, totalSteps }: OnboardingStepperProps) {
return (
<div className="w-full max-w-4xl mx-auto mb-8">
<div className="flex items-center justify-between">
{steps.map((step, index) => {
const isCompleted = step.number < currentStep;
const isCurrent = step.number === currentStep;
const isLast = index === steps.length - 1;
return (
<React.Fragment key={step.number}>
<div className="flex flex-col items-center flex-1">
<div className="flex items-center w-full">
{/* Step Circle */}
<div
className={`flex items-center justify-center w-10 h-10 rounded-full border-2 transition-colors ${
isCompleted
? 'bg-emerald-600 border-emerald-600 text-white'
: isCurrent
? 'bg-slate-900 border-slate-900 text-white'
: 'bg-white border-slate-300 text-slate-400'
}`}
>
{isCompleted ? (
<Check className="w-5 h-5" />
) : (
<span className="text-sm font-semibold">{step.number}</span>
)}
</div>
{/* Step Title */}
<div className="ml-3 flex-1">
<div
className={`text-sm font-medium ${
isCurrent ? 'text-slate-900' : isCompleted ? 'text-slate-600' : 'text-slate-400'
}`}
>
{step.title}
</div>
</div>
</div>
</div>
{/* Connector Line */}
{!isLast && (
<div
className={`h-0.5 flex-1 mx-4 ${
isCompleted ? 'bg-emerald-600' : 'bg-slate-200'
}`}
/>
)}
</React.Fragment>
);
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,199 @@
import React, { useState } from 'react';
import { Loader2 } from 'lucide-react';
import { createAgent } from '../../api/agents';
import { ApiError } from '../../api/client';
import type { Team, Agent } from '../../types/api';
interface StepAgentSettingsProps {
team: Team;
agentEnabled: boolean;
agentLanguage: 'uk' | 'en';
agentFocus: 'general' | 'business' | 'it' | 'creative';
useCoMemory: boolean;
onUpdate: (updates: {
agentEnabled: boolean;
agentLanguage: 'uk' | 'en';
agentFocus: 'general' | 'business' | 'it' | 'creative';
useCoMemory: boolean;
}) => void;
onNext: (agent: Agent | null) => void;
}
export function StepAgentSettings({
team,
agentEnabled,
agentLanguage,
agentFocus,
useCoMemory,
onUpdate,
onNext,
}: StepAgentSettingsProps) {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = async () => {
setError(null);
if (!agentEnabled) {
onNext(null);
return;
}
setLoading(true);
try {
const agent = await createAgent({
team_id: team.id,
name: 'Team Assistant',
role: agentFocus,
language: agentLanguage,
focus: agentFocus,
use_co_memory: useCoMemory,
});
onNext(agent);
} catch (err) {
if (err instanceof ApiError) {
setError(err.message || 'Не вдалося створити агента. Спробуйте ще раз.');
} else {
setError('Сталася помилка. Спробуйте ще раз.');
}
setLoading(false);
}
};
return (
<div className="max-w-2xl mx-auto px-4">
<h2 className="text-2xl font-bold text-slate-900 mb-2">
Агенти та пам'ять
</h2>
<p className="text-slate-600 mb-6">
Налаштуй свого приватного агента для цієї спільноти.
</p>
<div className="space-y-6">
{/* Enable Agent */}
<div className="flex items-start gap-3 p-4 border border-slate-200 rounded-lg">
<input
type="checkbox"
id="agentEnabled"
checked={agentEnabled}
onChange={(e) => onUpdate({
agentEnabled: e.target.checked,
agentLanguage,
agentFocus,
useCoMemory,
})}
className="mt-1"
disabled={loading}
/>
<label htmlFor="agentEnabled" className="flex-1 cursor-pointer">
<div className="font-medium text-slate-900">Увімкнути мого приватного агента в цій спільноті</div>
</label>
</div>
{agentEnabled && (
<>
{/* Language & Focus */}
<div className="space-y-4">
<div>
<label htmlFor="agentLanguage" className="block text-sm font-medium text-slate-700 mb-2">
Мова
</label>
<select
id="agentLanguage"
value={agentLanguage}
onChange={(e) => onUpdate({
agentEnabled,
agentLanguage: e.target.value as 'uk' | 'en',
agentFocus,
useCoMemory,
})}
className="w-full px-4 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-slate-900 focus:border-transparent"
disabled={loading}
>
<option value="uk">Українська</option>
<option value="en">English</option>
</select>
</div>
<div>
<label htmlFor="agentFocus" className="block text-sm font-medium text-slate-700 mb-2">
Фокус агента
</label>
<select
id="agentFocus"
value={agentFocus}
onChange={(e) => onUpdate({
agentEnabled,
agentLanguage,
agentFocus: e.target.value as 'general' | 'business' | 'it' | 'creative',
useCoMemory,
})}
className="w-full px-4 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-slate-900 focus:border-transparent"
disabled={loading}
>
<option value="general">Загальний</option>
<option value="business">Бізнес</option>
<option value="it">IT</option>
<option value="creative">Креатив</option>
</select>
</div>
</div>
{/* Co-Memory Toggle */}
<div className="flex items-start gap-3 p-4 border border-slate-200 rounded-lg">
<input
type="checkbox"
id="useCoMemory"
checked={useCoMemory}
onChange={(e) => onUpdate({
agentEnabled,
agentLanguage,
agentFocus,
useCoMemory: e.target.checked,
})}
className="mt-1"
disabled={loading}
/>
<label htmlFor="useCoMemory" className="flex-1 cursor-pointer">
<div className="font-medium text-slate-900">
Дозволити агенту використовувати Co-Memory цієї спільноти для відповідей
</div>
<div className="text-sm text-slate-600 mt-1">
Co-Memory = файли, посилання, wiki, які команда додає
</div>
</label>
</div>
{/* Explanation */}
<div className="p-4 bg-slate-50 rounded-lg">
<p className="text-sm text-slate-600">
Агент допомагає в чатах, читає контекст та Co-Memory, пропонує підсумки та фоллоу-апи.
</p>
<p className="text-sm text-slate-600 mt-2">
У майбутньому ти зможеш навчати агента й заробляти токени 1T.
</p>
</div>
</>
)}
{error && (
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm">
{error}
</div>
)}
<div className="flex justify-end pt-4">
<button
onClick={handleSubmit}
disabled={loading}
className="px-6 py-2 bg-slate-900 text-white rounded-lg font-medium hover:bg-slate-800 transition-colors focus:outline-none focus:ring-2 focus:ring-slate-900 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
{loading && <Loader2 className="w-4 h-4 animate-spin" />}
Готово
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,141 @@
import React, { useState } from 'react';
import { Loader2 } from 'lucide-react';
import { createChannel } from '../../api/channels';
import { ApiError } from '../../api/client';
import type { Team, Channel } from '../../types/api';
interface StepCreateChannelProps {
team: Team;
channelName: string;
channelType: 'public' | 'group';
onUpdate: (updates: { channelName: string; channelType: 'public' | 'group' }) => void;
onNext: (channel: Channel) => void;
}
export function StepCreateChannel({
team,
channelName,
channelType,
onUpdate,
onNext,
}: StepCreateChannelProps) {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
if (!channelName.trim()) {
setError('Назва каналу обов\'язкова');
return;
}
setLoading(true);
try {
const channel = await createChannel({
team_id: team.id,
name: channelName.trim().replace(/^#/, ''), // Remove # if user added it
type: channelType,
});
onNext(channel);
} catch (err) {
if (err instanceof ApiError) {
setError(err.message || 'Не вдалося створити канал. Спробуйте ще раз.');
} else {
setError('Сталася помилка. Спробуйте ще раз.');
}
} finally {
setLoading(false);
}
};
return (
<div className="max-w-2xl mx-auto px-4">
<h2 className="text-2xl font-bold text-slate-900 mb-2">
Перший канал
</h2>
<p className="text-slate-600 mb-6">
Створи свій перший канал для спілкування в спільноті.
</p>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="channelName" className="block text-sm font-medium text-slate-700 mb-2">
Назва каналу <span className="text-red-500">*</span>
</label>
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400">#</span>
<input
id="channelName"
type="text"
value={channelName}
onChange={(e) => onUpdate({ channelName: e.target.value, channelType })}
placeholder="general"
className="w-full pl-8 pr-4 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-slate-900 focus:border-transparent"
disabled={loading}
required
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-3">
Тип каналу
</label>
<div className="space-y-3">
<label className="flex items-start gap-3 p-4 border-2 rounded-lg cursor-pointer hover:bg-slate-50 transition-colors">
<input
type="radio"
name="channelType"
value="public"
checked={channelType === 'public'}
onChange={(e) => onUpdate({ channelName, channelType: e.target.value as 'public' | 'group' })}
className="mt-1"
disabled={loading}
/>
<div>
<div className="font-medium text-slate-900">Публічний канал спільноти</div>
<div className="text-sm text-slate-600">Як landing / стрічка для всіх учасників</div>
</div>
</label>
<label className="flex items-start gap-3 p-4 border-2 rounded-lg cursor-pointer hover:bg-slate-50 transition-colors">
<input
type="radio"
name="channelType"
value="group"
checked={channelType === 'group'}
onChange={(e) => onUpdate({ channelName, channelType: e.target.value as 'public' | 'group' })}
className="mt-1"
disabled={loading}
/>
<div>
<div className="font-medium text-slate-900">Приватна кімната команди</div>
<div className="text-sm text-slate-600">Тільки для запрошених учасників</div>
</div>
</label>
</div>
</div>
{error && (
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm">
{error}
</div>
)}
<div className="flex justify-end pt-4">
<button
type="submit"
disabled={loading || !channelName.trim()}
className="px-6 py-2 bg-slate-900 text-white rounded-lg font-medium hover:bg-slate-800 transition-colors focus:outline-none focus:ring-2 focus:ring-slate-900 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
{loading && <Loader2 className="w-4 h-4 animate-spin" />}
Створити канал
</button>
</div>
</form>
</div>
);
}

View File

@@ -0,0 +1,111 @@
import React, { useState } from 'react';
import { Loader2 } from 'lucide-react';
import { createTeam } from '../../api/teams';
import { ApiError } from '../../api/client';
import type { Team } from '../../types/api';
interface StepCreateTeamProps {
teamName: string;
teamDescription: string;
onUpdate: (updates: { teamName: string; teamDescription: string }) => void;
onNext: (team: Team) => void;
}
export function StepCreateTeam({
teamName,
teamDescription,
onUpdate,
onNext,
}: StepCreateTeamProps) {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
if (!teamName.trim()) {
setError('Назва спільноти обов\'язкова');
return;
}
setLoading(true);
try {
const team = await createTeam({
name: teamName.trim(),
description: teamDescription.trim() || undefined,
});
onNext(team);
} catch (err) {
if (err instanceof ApiError) {
setError(err.message || 'Не вдалося створити спільноту. Спробуйте ще раз.');
} else {
setError('Сталася помилка. Спробуйте ще раз.');
}
} finally {
setLoading(false);
}
};
return (
<div className="max-w-2xl mx-auto px-4">
<h2 className="text-2xl font-bold text-slate-900 mb-2">
Створити спільноту
</h2>
<p className="text-slate-600 mb-6">
Спільнота = твоя команда, клуб чи проект. Для кожної спільноти автоматично створюється micro-DAO з власним управлінням і агентами.
</p>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="teamName" className="block text-sm font-medium text-slate-700 mb-2">
Назва спільноти <span className="text-red-500">*</span>
</label>
<input
id="teamName"
type="text"
value={teamName}
onChange={(e) => onUpdate({ teamName: e.target.value, teamDescription })}
placeholder="Наприклад: Моя команда"
className="w-full px-4 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-slate-900 focus:border-transparent"
disabled={loading}
required
/>
</div>
<div>
<label htmlFor="teamDescription" className="block text-sm font-medium text-slate-700 mb-2">
Короткий опис <span className="text-slate-400 text-xs">(опційно)</span>
</label>
<textarea
id="teamDescription"
value={teamDescription}
onChange={(e) => onUpdate({ teamName, teamDescription: e.target.value })}
placeholder="Опиши свою спільноту..."
rows={3}
className="w-full px-4 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-slate-900 focus:border-transparent resize-none"
disabled={loading}
/>
</div>
{error && (
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm">
{error}
</div>
)}
<div className="flex justify-end pt-4">
<button
type="submit"
disabled={loading || !teamName.trim()}
className="px-6 py-2 bg-slate-900 text-white rounded-lg font-medium hover:bg-slate-800 transition-colors focus:outline-none focus:ring-2 focus:ring-slate-900 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
{loading && <Loader2 className="w-4 h-4 animate-spin" />}
Продовжити
</button>
</div>
</form>
</div>
);
}

View File

@@ -0,0 +1,97 @@
import React, { useState } from 'react';
import { Copy, Check, QrCode } from 'lucide-react';
import type { Team, Channel } from '../../types/api';
interface StepInviteProps {
team: Team;
channel: Channel;
onComplete: () => void;
}
export function StepInvite({ team, channel, onComplete }: StepInviteProps) {
const [copied, setCopied] = useState(false);
// TODO: Отримати реальне посилання-запрошення з API
const inviteLink = `${window.location.origin}/invite/${team.id}`;
const handleCopyLink = async () => {
try {
await navigator.clipboard.writeText(inviteLink);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (err) {
console.error('Failed to copy link:', err);
}
};
return (
<div className="max-w-2xl mx-auto px-4">
<h2 className="text-2xl font-bold text-slate-900 mb-2">
Все готово!
</h2>
<p className="text-slate-600 mb-6">
Залишилось запросити команду в твою спільноту.
</p>
<div className="space-y-4 mb-6">
{/* Invite Link */}
<div className="p-4 border border-slate-200 rounded-lg bg-slate-50">
<label className="block text-sm font-medium text-slate-700 mb-2">
Посилання-запрошення
</label>
<div className="flex items-center gap-2">
<input
type="text"
value={inviteLink}
readOnly
className="flex-1 px-3 py-2 bg-white border border-slate-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-slate-900"
/>
<button
onClick={handleCopyLink}
className="px-4 py-2 bg-slate-900 text-white rounded-lg hover:bg-slate-800 transition-colors focus:outline-none focus:ring-2 focus:ring-slate-900 focus:ring-offset-2 flex items-center gap-2"
>
{copied ? (
<>
<Check className="w-4 h-4" />
Скопійовано
</>
) : (
<>
<Copy className="w-4 h-4" />
Копіювати
</>
)}
</button>
</div>
</div>
{/* Actions */}
<div className="flex flex-col sm:flex-row gap-3">
<button
onClick={() => {
// TODO: Показати QR код (модалка)
alert('QR код буде доступний пізніше');
}}
className="flex-1 px-4 py-2 border border-slate-300 rounded-lg hover:bg-slate-50 transition-colors focus:outline-none focus:ring-2 focus:ring-slate-900 focus:ring-offset-2 flex items-center justify-center gap-2"
>
<QrCode className="w-4 h-4" />
Показати QR
</button>
<button
onClick={onComplete}
className="flex-1 px-4 py-2 bg-slate-900 text-white rounded-lg hover:bg-slate-800 transition-colors focus:outline-none focus:ring-2 focus:ring-slate-900 focus:ring-offset-2"
>
Перейти в чат
</button>
</div>
</div>
<div className="p-4 bg-emerald-50 border border-emerald-200 rounded-lg">
<p className="text-sm text-emerald-800">
<strong>Спільнота "{team.name}"</strong> створена успішно! Тепер ти можеш почати спілкування в каналі <strong>#{channel.name}</strong>.
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,147 @@
import React from 'react';
import { Globe, Lock } from 'lucide-react';
import { updateTeam } from '../../api/teams';
import { ApiError } from '../../api/client';
import { useState } from 'react';
import { Loader2 } from 'lucide-react';
import type { Team } from '../../types/api';
interface StepSelectModeProps {
team: Team;
selectedMode: 'public' | 'confidential';
onUpdate: (mode: 'public' | 'confidential') => void;
onNext: (team: Team) => void;
}
export function StepSelectMode({
team,
selectedMode,
onUpdate,
onNext,
}: StepSelectModeProps) {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = async () => {
if (selectedMode === team.mode) {
onNext(team);
return;
}
setError(null);
setLoading(true);
try {
const updatedTeam = await updateTeam(team.id, { mode: selectedMode });
onNext(updatedTeam);
} catch (err) {
if (err instanceof ApiError) {
setError(err.message || 'Не вдалося оновити режим спільноти.');
} else {
setError('Сталася помилка. Спробуйте ще раз.');
}
} finally {
setLoading(false);
}
};
return (
<div className="max-w-2xl mx-auto px-4">
<h2 className="text-2xl font-bold text-slate-900 mb-2">
Режим приватності
</h2>
<p className="text-slate-600 mb-6">
Обери, як твоя спільнота буде доступна для інших.
</p>
<div className="space-y-4 mb-6">
{/* Public Option */}
<button
type="button"
onClick={() => onUpdate('public')}
disabled={loading}
className={`w-full p-6 border-2 rounded-xl text-left transition-all ${
selectedMode === 'public'
? 'border-slate-900 bg-slate-50'
: 'border-slate-200 hover:border-slate-300'
} disabled:opacity-50`}
>
<div className="flex items-start gap-4">
<div
className={`flex-shrink-0 w-6 h-6 rounded-full border-2 flex items-center justify-center mt-1 ${
selectedMode === 'public'
? 'border-slate-900 bg-slate-900'
: 'border-slate-300'
}`}
>
{selectedMode === 'public' && (
<div className="w-2 h-2 rounded-full bg-white" />
)}
</div>
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<Globe className="w-5 h-5 text-slate-600" />
<h3 className="text-lg font-semibold text-slate-900">Public</h3>
</div>
<p className="text-sm text-slate-600">
Є публічний канал, гості можуть читати і реєструватися як глядачі (viewer-type).
</p>
</div>
</div>
</button>
{/* Confidential Option */}
<button
type="button"
onClick={() => onUpdate('confidential')}
disabled={loading}
className={`w-full p-6 border-2 rounded-xl text-left transition-all ${
selectedMode === 'confidential'
? 'border-slate-900 bg-slate-50'
: 'border-slate-200 hover:border-slate-300'
} disabled:opacity-50`}
>
<div className="flex items-start gap-4">
<div
className={`flex-shrink-0 w-6 h-6 rounded-full border-2 flex items-center justify-center mt-1 ${
selectedMode === 'confidential'
? 'border-slate-900 bg-slate-900'
: 'border-slate-300'
}`}
>
{selectedMode === 'confidential' && (
<div className="w-2 h-2 rounded-full bg-white" />
)}
</div>
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<Lock className="w-5 h-5 text-slate-600" />
<h3 className="text-lg font-semibold text-slate-900">Confidential</h3>
</div>
<p className="text-sm text-slate-600">
Тільки запрошені учасники, E2EE для чатів, без публічного індексування.
</p>
</div>
</div>
</button>
</div>
{error && (
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm mb-4">
{error}
</div>
)}
<div className="flex justify-end pt-4">
<button
onClick={handleSubmit}
disabled={loading}
className="px-6 py-2 bg-slate-900 text-white rounded-lg font-medium hover:bg-slate-800 transition-colors focus:outline-none focus:ring-2 focus:ring-slate-900 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
{loading && <Loader2 className="w-4 h-4 animate-spin" />}
Продовжити
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,30 @@
import React from 'react';
interface StepWelcomeProps {
onNext: () => void;
}
export function StepWelcome({ onNext }: StepWelcomeProps) {
return (
<div className="flex flex-col items-center justify-center min-h-[60vh] text-center px-4">
<div className="max-w-2xl">
<h1 className="text-4xl font-bold text-slate-900 mb-6">
Ласкаво просимо до MicroDAO
</h1>
<p className="text-lg text-slate-600 mb-4 leading-relaxed">
MicroDAO приватна мережа ШІ-агентів для малих спільнот.
</p>
<p className="text-lg text-slate-600 mb-8 leading-relaxed">
За 3 кроки ти створиш власну micro-DAO: спільноту, перший канал і свого агента.
</p>
<button
onClick={onNext}
className="px-8 py-3 bg-slate-900 text-white rounded-lg font-medium hover:bg-slate-800 transition-colors focus:outline-none focus:ring-2 focus:ring-slate-900 focus:ring-offset-2"
>
Почати
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,58 @@
import { useState } from 'react';
import type { Team, Channel, Agent } from '../types/api';
export interface OnboardingState {
// Step 2: Create Team
teamName: string;
teamDescription: string;
team: Team | null;
// Step 3: Select Mode
teamMode: 'public' | 'confidential';
// Step 4: Create Channel
channelName: string;
channelType: 'public' | 'group';
channel: Channel | null;
// Step 5: Agent Settings
agentEnabled: boolean;
agentLanguage: 'uk' | 'en';
agentFocus: 'general' | 'business' | 'it' | 'creative';
useCoMemory: boolean;
agent: Agent | null;
}
const initialState: OnboardingState = {
teamName: '',
teamDescription: '',
team: null,
teamMode: 'public',
channelName: '',
channelType: 'public',
channel: null,
agentEnabled: false,
agentLanguage: 'uk',
agentFocus: 'general',
useCoMemory: false,
agent: null,
};
export function useOnboarding() {
const [state, setState] = useState<OnboardingState>(initialState);
const updateState = (updates: Partial<OnboardingState>) => {
setState((prev) => ({ ...prev, ...updates }));
};
const reset = () => {
setState(initialState);
};
return {
state,
updateState,
reset,
};
}

View File

@@ -0,0 +1,151 @@
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { OnboardingStepper } from '../components/onboarding/OnboardingStepper';
import { StepWelcome } from '../components/onboarding/StepWelcome';
import { StepCreateTeam } from '../components/onboarding/StepCreateTeam';
import { StepSelectMode } from '../components/onboarding/StepSelectMode';
import { StepCreateChannel } from '../components/onboarding/StepCreateChannel';
import { StepAgentSettings } from '../components/onboarding/StepAgentSettings';
import { StepInvite } from '../components/onboarding/StepInvite';
import { useOnboarding } from '../hooks/useOnboarding';
import type { Team, Channel, Agent } from '../types/api';
const TOTAL_STEPS = 6;
export function OnboardingPage() {
const navigate = useNavigate();
const { state, updateState } = useOnboarding();
const [currentStep, setCurrentStep] = useState(1);
const handleNext = () => {
if (currentStep < TOTAL_STEPS) {
setCurrentStep(currentStep + 1);
}
};
const handleStep2Complete = (team: Team) => {
updateState({ team, teamName: team.name, teamDescription: team.description || '' });
setCurrentStep(3);
};
const handleStep3Complete = (team: Team) => {
updateState({ team, teamMode: team.mode });
setCurrentStep(4);
};
const handleStep4Complete = (channel: Channel) => {
updateState({ channel, channelName: channel.name, channelType: channel.type });
setCurrentStep(5);
};
const handleStep5Complete = (agent: Agent | null) => {
updateState({ agent });
setCurrentStep(6);
};
const handleComplete = () => {
// Перенаправляємо на головну сторінку чату з створеним каналом
if (state.channel) {
navigate(`/teams/${state.team?.id}/channels/${state.channel.id}`);
} else if (state.team) {
navigate(`/teams/${state.team.id}`);
} else {
navigate('/');
}
};
const renderStep = () => {
switch (currentStep) {
case 1:
return <StepWelcome onNext={handleNext} />;
case 2:
return (
<StepCreateTeam
teamName={state.teamName}
teamDescription={state.teamDescription}
onUpdate={(updates) => updateState(updates)}
onNext={handleStep2Complete}
/>
);
case 3:
if (!state.team) {
// Якщо команда не створена, повертаємось на крок 2
setCurrentStep(2);
return null;
}
return (
<StepSelectMode
team={state.team}
selectedMode={state.teamMode}
onUpdate={(mode) => updateState({ teamMode: mode })}
onNext={handleStep3Complete}
/>
);
case 4:
if (!state.team) {
setCurrentStep(2);
return null;
}
return (
<StepCreateChannel
team={state.team}
channelName={state.channelName}
channelType={state.channelType}
onUpdate={(updates) => updateState(updates)}
onNext={handleStep4Complete}
/>
);
case 5:
if (!state.team) {
setCurrentStep(2);
return null;
}
return (
<StepAgentSettings
team={state.team}
agentEnabled={state.agentEnabled}
agentLanguage={state.agentLanguage}
agentFocus={state.agentFocus}
useCoMemory={state.useCoMemory}
onUpdate={(updates) => updateState(updates)}
onNext={handleStep5Complete}
/>
);
case 6:
if (!state.team || !state.channel) {
setCurrentStep(2);
return null;
}
return (
<StepInvite
team={state.team}
channel={state.channel}
onComplete={handleComplete}
/>
);
default:
return null;
}
};
return (
<div className="min-h-screen bg-slate-50">
<div className="container mx-auto px-4 py-8">
{/* Stepper */}
{currentStep > 1 && (
<OnboardingStepper currentStep={currentStep} totalSteps={TOTAL_STEPS} />
)}
{/* Step Content */}
<div className="mt-8">{renderStep()}</div>
</div>
</div>
);
}

95
src/types/api.ts Normal file
View File

@@ -0,0 +1,95 @@
// API Types для MicroDAO
export interface User {
id: string;
email: string;
name: string | null;
plan: string;
created_at: string;
updated_at: string;
}
export interface Team {
id: string;
name: string;
description: string | null;
mode: 'public' | 'confidential';
created_at: string;
updated_at: string;
}
export interface CreateTeamRequest {
name: string;
description?: string;
}
export interface UpdateTeamRequest {
mode?: 'public' | 'confidential';
name?: string;
description?: string;
}
export interface Channel {
id: string;
team_id: string;
name: string;
type: 'public' | 'group';
slug: string;
created_at: string;
}
export interface CreateChannelRequest {
team_id: string;
name: string;
type: 'public' | 'group';
}
export interface Message {
id: string;
channel_id: string;
user_id: string;
content: string;
created_at: string;
updated_at: string;
user: User;
}
export interface CreateMessageRequest {
content: string;
}
export interface Agent {
id: string;
team_id: string;
name: string;
role: 'general' | 'business' | 'it' | 'creative';
language: 'uk' | 'en';
focus: 'general' | 'business' | 'it' | 'creative';
use_co_memory: boolean;
enabled: boolean;
created_at: string;
}
export interface CreateAgentRequest {
team_id: string;
name: string;
role: 'general' | 'business' | 'it' | 'creative';
language: 'uk' | 'en';
focus: 'general' | 'business' | 'it' | 'creative';
use_co_memory: boolean;
}
export interface AuthResponse {
token: string;
user: User;
}
export interface LoginEmailRequest {
email: string;
}
export interface ExchangeCodeRequest {
code: string;
email: string;
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 16 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 26 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 14 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 31 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 27 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 26 KiB