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 |