Initial commit: MVP structure + Cursor documentation + Onboarding components
51
.gitignore
vendored
Normal 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
|
||||
|
||||
1
DAARION DAO Ecosystem- Повна система DAGI.svg
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
DAGI Science Parser +.pdf
Normal file
1
MVP ФЛОУ (Приклад- R&D команда 7 осіб).svg
Normal file
|
After Width: | Height: | Size: 30 KiB |
@@ -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 квантування заощаджує ×3–4 місця.</div>`
|
||||
`+ <div className="text-xs text-slate-500">`
|
||||
`+ int8 квантування заощаджує ×3–4 місця. {estChunks && <span className="ml-1">Орієнтовно: {estChunks} чанків (int8).</span>}`
|
||||
`+ </div>`
|
||||
|
||||
Після цих замін у вас буде:
|
||||
|
||||
* `/healthz` з URL із конфіга і CORS-статусом `unknown`.
|
||||
|
||||
* Автовибір профілю моделі та збереження вибору.
|
||||
|
||||
* Оцінка місткості індексу в чанках (int8).
|
||||
|
||||
* Тристатусний бейдж оркестратора.
|
||||
|
||||
BIN
Micro Dao Orchestrator Ui — React Layout (shell)· typescript.pdf
Normal file
1
MicroDAO Architecture.svg
Normal file
|
After Width: | Height: | Size: 17 KiB |
1
MicroDAO Demo Flow.svg
Normal file
|
After Width: | Height: | Size: 18 KiB |
1
MicroDAO KB Architecture (Гаджет користувача).svg
Normal file
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 25 KiB |
1
MicroDAO Modules- Детальний Stack.svg
Normal file
|
After Width: | Height: | Size: 28 KiB |
1
MicroDAO- Повна модульна архітектура (v2.0)
Normal file
|
After Width: | Height: | Size: 23 KiB |
1
MicroDAO- Творчі Ніші 2025+.svg
Normal file
|
After Width: | Height: | Size: 19 KiB |
1
MicroDAO- Футуристичні Ніші 2030+.svg
Normal file
|
After Width: | Height: | Size: 19 KiB |
52
deploy-matrix.yml
Normal 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"
|
||||
33
docs/cursor/00_overview_microdao.md
Normal 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` — Чеклист тестування
|
||||
|
||||
154
docs/cursor/01_product_brief_mvp.md
Normal file
@@ -0,0 +1,154 @@
|
||||
# Product Brief - MVP
|
||||
|
||||
## 1. Мета
|
||||
|
||||
Дати першим користувачам (фаунерам спільнот та їхнім командам) простий спосіб:
|
||||
|
||||
- створити свою micro-DAO (спільноту),
|
||||
- налаштувати базову приватність (public / confidential),
|
||||
- почати працювати в чаті з каналами,
|
||||
- керувати простими задачами в проєктах,
|
||||
- спробувати приватного AI-агента всередині спільноти.
|
||||
|
||||
Ціль: **реальний робочий простір для 1–2 команд**, а не демо-скріншоти.
|
||||
|
||||
## 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. Нефункціональні вимоги
|
||||
|
||||
- Стабільність під 10–50 активних користувачів.
|
||||
- Чат відповідає ≤ 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)
|
||||
|
||||
- 1–2 живі спільноти (5–20 людей), що:
|
||||
- щодня використовують чат,
|
||||
- створюють проєкти й задачі,
|
||||
- користуються хоча б одним агентом.
|
||||
- Мінімум 3–5 сесій на користувача на тиждень.
|
||||
- Нуль критичних блокерів:
|
||||
- логін завжди працює,
|
||||
- повідомлення не губляться,
|
||||
- онбординг можна пройти від початку до кінця без допомоги девів.
|
||||
|
||||
## 7. Примітки для розробників
|
||||
|
||||
- Цей brief — **орієнтир, а не жорсткий контракт**.
|
||||
- Якщо функція не потрібна для основних флоу (описаних вище) — її можна перенести в наступні ітерації.
|
||||
- Головний пріоритет: **простий, стабільний досвід для перших реальних користувачів**, навіть ціною урізаного функціоналу.
|
||||
239
docs/cursor/02_architecture_basics.md
Normal 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).
|
||||
- Одночасно 10–50 активних користувачів.
|
||||
- Стабільна робота мобільної версії (мінімально).
|
||||
- Стійкий логін, без циклів і моклих лінків.
|
||||
|
||||
## 10. Для Cursor
|
||||
|
||||
Цей документ дає базу для:
|
||||
|
||||
- генерації React-компонентів,
|
||||
- створення нового маршруту `/onboarding`,
|
||||
- реалізації каналів і чатів,
|
||||
- інтеграції базового агента,
|
||||
- роботи з API без необхідності читати всю специфікацію.
|
||||
414
docs/cursor/03_api_core_snapshot.md
Normal 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` (1–200)
|
||||
|
||||
**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 і адаптований для:
|
||||
|
||||
* автоматичної генерації типів,
|
||||
* швидкої розробки фронтенду,
|
||||
* мінімізації зайвих деталей.
|
||||
278
docs/cursor/04_ui_ux_onboarding_chat.md
Normal 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.
|
||||
241
docs/cursor/05_coding_standards.md
Normal 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.
|
||||
|
||||
Він гарантує:
|
||||
|
||||
* узгоджений стиль,
|
||||
* передбачуваний код,
|
||||
* мінімум помилок,
|
||||
* легку підтримку,
|
||||
* зрозумілість структури для нових девелоперів.
|
||||
332
docs/cursor/06_tasks_onboarding_mvp.md
Normal 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 з актуальною позначкою (1–5).
|
||||
- Немає реальних 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.
|
||||
273
docs/cursor/07_testing_checklist_mvp.md
Normal 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
@@ -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
|
After Width: | Height: | Size: 17 KiB |
1
mermaid-diagram.svg
Normal file
|
After Width: | Height: | Size: 16 KiB |
417
micro_dao_orchestrator_ui_react_layout_shell (1).jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
454
micro_dao_orchestrator_ui_react_layout_shell (2).jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
525
micro_dao_orchestrator_ui_react_layout_shell (3).jsx
Normal 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 квантування заощаджує ×3–4 місця.</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>
|
||||
);
|
||||
}
|
||||
525
micro_dao_orchestrator_ui_react_layout_shell (4).jsx
Normal 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 квантування заощаджує ×3–4 місця.</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>
|
||||
);
|
||||
}
|
||||
576
micro_dao_orchestrator_ui_react_layout_shell (5).jsx
Normal 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 квантування заощаджує ×3–4 місця.</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>
|
||||
);
|
||||
}
|
||||
576
micro_dao_orchestrator_ui_react_layout_shell (6).jsx
Normal 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 квантування заощаджує ×3–4 місця.</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>
|
||||
);
|
||||
}
|
||||
363
micro_dao_orchestrator_ui_react_layout_shell.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
201
microdao_dependency_matrix.yaml
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
79
src/components/onboarding/OnboardingStepper.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
199
src/components/onboarding/StepAgentSettings.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
141
src/components/onboarding/StepCreateChannel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
111
src/components/onboarding/StepCreateTeam.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
97
src/components/onboarding/StepInvite.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
147
src/components/onboarding/StepSelectMode.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
30
src/components/onboarding/StepWelcome.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
58
src/hooks/useOnboarding.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
||||
151
src/pages/OnboardingPage.tsx
Normal 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
@@ -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;
|
||||
}
|
||||
|
||||
1
μGOV Tokenomics- Ліквідний Ключ Доступу.svg
Normal file
|
After Width: | Height: | Size: 16 KiB |
1
ЖИТТЄВИЙ ЦИКЛ μGOV.svg
Normal file
|
After Width: | Height: | Size: 26 KiB |
1
Ліквідність μGOV ключів (Private MicroDAO).svg
Normal file
|
After Width: | Height: | Size: 14 KiB |
1
ОНБОРДИНГ MICRODAO- μGOV ЯК КЛЮЧ ВХОДУ (PRIVATE).svg
Normal file
|
After Width: | Height: | Size: 31 KiB |
1
ПОТОК ДАНИХ (Повний цикл).svg
Normal file
|
After Width: | Height: | Size: 27 KiB |
1
ФЛОУ ВХОДУ В MICRODAO.svg
Normal file
|
After Width: | Height: | Size: 26 KiB |