@peonai/swarm 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/.dockerignore +6 -0
  2. package/Dockerfile +9 -0
  3. package/README.md +29 -0
  4. package/app/api/health/route.ts +2 -0
  5. package/app/api/v1/admin/agents/[id]/route.ts +12 -0
  6. package/app/api/v1/admin/agents/route.ts +31 -0
  7. package/app/api/v1/admin/audit/route.ts +23 -0
  8. package/app/api/v1/admin/cleanup/route.ts +21 -0
  9. package/app/api/v1/admin/export/route.ts +15 -0
  10. package/app/api/v1/admin/history/route.ts +23 -0
  11. package/app/api/v1/admin/profile/route.ts +23 -0
  12. package/app/api/v1/admin/settings/route.ts +44 -0
  13. package/app/api/v1/auth/route.ts +5 -0
  14. package/app/api/v1/memory/route.ts +105 -0
  15. package/app/api/v1/persona/[agentId]/route.ts +13 -0
  16. package/app/api/v1/persona/me/route.ts +12 -0
  17. package/app/api/v1/profile/observe/route.ts +34 -0
  18. package/app/api/v1/profile/route.ts +72 -0
  19. package/app/api/v1/reflect/route.ts +96 -0
  20. package/app/globals.css +190 -0
  21. package/app/i18n.ts +161 -0
  22. package/app/layout.tsx +12 -0
  23. package/app/page.tsx +561 -0
  24. package/docker-compose.yml +34 -0
  25. package/docs/DEBATE-ROUND1.md +244 -0
  26. package/docs/DEBATE-ROUND2.md +158 -0
  27. package/docs/REQUIREMENTS.md +162 -0
  28. package/docs/docs.html +272 -0
  29. package/docs/index.html +228 -0
  30. package/docs/script.js +103 -0
  31. package/docs/style.css +418 -0
  32. package/lib/auth.ts +63 -0
  33. package/lib/db.ts +63 -0
  34. package/lib/embedding.ts +29 -0
  35. package/lib/schema.ts +134 -0
  36. package/mcp-server.ts +56 -0
  37. package/next-env.d.ts +6 -0
  38. package/next.config.ts +5 -0
  39. package/package.json +34 -0
  40. package/packages/cli/README.md +33 -0
  41. package/packages/cli/bin/swarm.mjs +274 -0
  42. package/packages/cli/package.json +10 -0
  43. package/postcss.config.mjs +2 -0
  44. package/skill/CLAUDE.md +40 -0
  45. package/skill/CODEX.md +43 -0
  46. package/skill/GEMINI.md +38 -0
  47. package/skill/IFLOW.md +38 -0
  48. package/skill/OPENCODE.md +38 -0
  49. package/skill/swarm-ai-skill/SKILL.md +74 -0
  50. package/skill/swarm-ai-skill/env.sh +4 -0
  51. package/skill/swarm-ai-skill/scripts/bootstrap.sh +21 -0
  52. package/skill/swarm-ai-skill/scripts/env.sh +4 -0
  53. package/skill/swarm-ai-skill/scripts/env.sh.example +3 -0
  54. package/skill/swarm-ai-skill/scripts/memory-read.sh +8 -0
  55. package/skill/swarm-ai-skill/scripts/memory-write.sh +10 -0
  56. package/skill/swarm-ai-skill/scripts/observe.sh +9 -0
  57. package/skill/swarm-ai-skill/scripts/profile-read.sh +9 -0
  58. package/skill/swarm-ai-skill/scripts/profile-update.sh +9 -0
  59. package/skill/swarm-ai-skill/scripts/session-start.sh +19 -0
  60. package/tsconfig.json +21 -0
  61. package/tsconfig.tsbuildinfo +1 -0
package/app/page.tsx ADDED
@@ -0,0 +1,561 @@
1
+ 'use client';
2
+ import { useState, useEffect, useCallback, useRef } from 'react';
3
+ import { locales, type Locale } from './i18n';
4
+
5
+ const ADMIN = 'swarm-admin-dev';
6
+ function H(): Record<string, string> {
7
+ return { 'Content-Type': 'application/json', 'X-Admin-Token': ADMIN };
8
+ }
9
+
10
+ type Tab = 'overview' | 'profile' | 'agents' | 'memory' | 'audit' | 'settings';
11
+
12
+ const PALETTE = ['#f0a830','#60a5fa','#34d399','#f87171','#a78bfa','#fb923c','#38bdf8','#4ade80'];
13
+ const AC: Record<string, string> = {};
14
+ function agentColor(id: string) {
15
+ if (!id) return '#9898a8';
16
+ return AC[id] ??= PALETTE[Object.keys(AC).length % PALETTE.length];
17
+ }
18
+
19
+ function Badge({ text, color }: { text: string; color?: string }) {
20
+ const c = color || agentColor(text);
21
+ return (
22
+ <span className="badge" style={{ background: `${c}18`, color: c, border: `1px solid ${c}30` }}>
23
+ <svg width="8" height="8" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3">
24
+ <path d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z"/>
25
+ </svg>
26
+ {text}
27
+ </span>
28
+ );
29
+ }
30
+
31
+ function Icon({ d, size = 18 }: { d: string; size?: number }) {
32
+ return <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d={d}/></svg>;
33
+ }
34
+
35
+ const NAV: { id: Tab; icon: string; }[] = [
36
+ { id: 'overview', icon: 'M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-4 0h4' },
37
+ { id: 'profile', icon: 'M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z' },
38
+ { id: 'agents', icon: 'M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z' },
39
+ { id: 'memory', icon: 'M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2' },
40
+ { id: 'audit', icon: 'M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z' },
41
+ { id: 'settings', icon: 'M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z M15 12a3 3 0 11-6 0 3 3 0 016 0z' },
42
+ ];
43
+
44
+ /* ══════════════════════════════════════════
45
+ MAIN DASHBOARD
46
+ ══════════════════════════════════════════ */
47
+ export default function Dashboard() {
48
+ const [lang, setLang] = useState<Locale>('en');
49
+ const t = locales[lang];
50
+ const [tab, setTab] = useState<Tab>('overview');
51
+ const [profiles, setProfiles] = useState<any[]>([]);
52
+ const [agents, setAgents] = useState<any[]>([]);
53
+ const [memories, setMemories] = useState<any[]>([]);
54
+ const [auditLogs, setAuditLogs] = useState<any[]>([]);
55
+ const [profileHistory, setProfileHistory] = useState<any[]>([]);
56
+ const [health, setHealth] = useState<any>(null);
57
+ const [newAgent, setNewAgent] = useState({ id: '', name: '' });
58
+ const [newMemory, setNewMemory] = useState({ content: '', tags: '', type: 'observation' });
59
+
60
+ const load = useCallback(async () => {
61
+ const h = H();
62
+ const [p, a, m, hh, al, ph] = await Promise.all([
63
+ fetch('/api/v1/admin/profile', { headers: h }).then(r => r.json()).catch(() => []),
64
+ fetch('/api/v1/admin/agents', { headers: h }).then(r => r.json()).catch(() => []),
65
+ fetch('/api/v1/memory?limit=50', { headers: h }).then(r => r.json()).catch(() => []),
66
+ fetch('/api/health').then(r => r.json()).catch(() => null),
67
+ fetch('/api/v1/admin/audit?limit=50', { headers: h }).then(r => r.json()).catch(() => []),
68
+ fetch('/api/v1/admin/history?limit=50', { headers: h }).then(r => r.json()).catch(() => []),
69
+ ]);
70
+ setProfiles(Array.isArray(p) ? p : []); setAgents(Array.isArray(a) ? a : []);
71
+ setMemories(Array.isArray(m) ? m : []); setHealth(hh);
72
+ setAuditLogs(Array.isArray(al) ? al : []); setProfileHistory(Array.isArray(ph) ? ph : []);
73
+ }, []);
74
+
75
+ useEffect(() => { load(); }, [load]);
76
+ useEffect(() => { document.title = t.auth.title; }, [t]);
77
+
78
+ const addAgent = async () => {
79
+ if (!newAgent.id) return;
80
+ const res = await fetch('/api/v1/admin/agents', { method: 'POST', headers: H(), body: JSON.stringify(newAgent) });
81
+ const data = await res.json();
82
+ alert(t.agents.keyAlert(data.apiKey));
83
+ setNewAgent({ id: '', name: '' }); load();
84
+ };
85
+ const deleteAgent = async (id: string) => {
86
+ if (!confirm(t.agents.confirmDelete(id))) return;
87
+ await fetch(`/api/v1/admin/agents/${id}`, { method: 'DELETE', headers: H() }); load();
88
+ };
89
+ const addMemory = async () => {
90
+ if (!newMemory.content) return;
91
+ await fetch('/api/v1/memory', {
92
+ method: 'POST', headers: H(),
93
+ body: JSON.stringify({ content: newMemory.content, tags: newMemory.tags ? newMemory.tags.split(',').map(t => t.trim()) : [] }),
94
+ });
95
+ setNewMemory({ content: '', tags: '', type: 'observation' }); load();
96
+ };
97
+ const handleExport = async () => {
98
+ const data = await fetch('/api/v1/admin/export', { headers: H() }).then(r => r.json());
99
+ const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
100
+ Object.assign(document.createElement('a'), { href: URL.createObjectURL(blob), download: 'swarm-export.json' }).click();
101
+ };
102
+
103
+ const layers = profiles.reduce((acc: Record<string, any[]>, p: any) => {
104
+ (acc[p.layer] = acc[p.layer] || []).push(p); return acc;
105
+ }, {});
106
+
107
+ return (
108
+ <>
109
+ <div className="hex-bg" />
110
+ <div className="flex min-h-screen relative z-10">
111
+ {/* Sidebar */}
112
+ <aside className="sidebar w-52 flex-shrink-0 flex flex-col">
113
+ <div className="p-4 flex items-center gap-2.5 border-b" style={{ borderColor: 'var(--border)' }}>
114
+ <svg className="logo-hex" width="26" height="26" viewBox="0 0 100 100" fill="none">
115
+ <path d="M50 5L93.3 27.5V72.5L50 95L6.7 72.5V27.5L50 5Z" stroke="var(--amber)" strokeWidth="4" fill="none"/>
116
+ <path d="M50 25L72.5 37.5V62.5L50 75L27.5 62.5V37.5L50 25Z" stroke="var(--amber)" strokeWidth="3" fill="none" opacity="0.5"/>
117
+ <circle cx="50" cy="50" r="6" fill="var(--amber)" opacity="0.8"/>
118
+ </svg>
119
+ <div>
120
+ <div className="font-semibold text-xs tracking-wide">{t.brand}</div>
121
+ <div className="text-[10px]" style={{ color: 'var(--text2)' }}>{t.dashboard}</div>
122
+ </div>
123
+ </div>
124
+ <nav className="flex-1 p-2 space-y-0.5">
125
+ {NAV.map(n => (
126
+ <button key={n.id} onClick={() => setTab(n.id)}
127
+ className={`sidebar-link w-full flex items-center gap-2.5 px-3 py-2 rounded-lg text-xs transition-all ${tab === n.id ? 'active' : ''}`}
128
+ style={{
129
+ background: tab === n.id ? 'var(--amber-glow)' : 'transparent',
130
+ color: tab === n.id ? 'var(--amber)' : 'var(--text2)',
131
+ }}>
132
+ <Icon d={n.icon} size={16} />
133
+ {t.nav[n.id]}
134
+ </button>
135
+ ))}
136
+ </nav>
137
+ <div className="p-3 border-t text-[10px] space-y-2" style={{ borderColor: 'var(--border)', color: 'var(--text2)' }}>
138
+ <div className="flex items-center gap-1.5">
139
+ {health
140
+ ? <><span className="w-1.5 h-1.5 rounded-full bg-green-400 animate-pulse" /> {t.status.running}</>
141
+ : <><span className="w-1.5 h-1.5 rounded-full bg-red-400" /> {t.status.offline}</>}
142
+ <span className="ml-auto font-mono">v0.1</span>
143
+ </div>
144
+ <div className="flex gap-1.5">
145
+ <button onClick={() => setLang(l => l === 'en' ? 'zh' : 'en')} className="px-1.5 py-0.5 rounded"
146
+ style={{ border: '1px solid var(--border)' }}>{lang === 'en' ? '中' : 'EN'}</button>
147
+ <button onClick={handleExport} className="px-1.5 py-0.5 rounded"
148
+ style={{ border: '1px solid var(--border)' }}>{t.auth.export}</button>
149
+ </div>
150
+ </div>
151
+ </aside>
152
+
153
+ {/* Main */}
154
+ <main className="flex-1 p-6 overflow-auto">
155
+ <div className="tab-content" key={tab}>
156
+ {tab === 'overview' && <Overview t={t} profiles={profiles} agents={agents} memories={memories} health={health} setTab={setTab} />}
157
+ {tab === 'profile' && <ProfileView t={t} layers={layers} />}
158
+ {tab === 'agents' && <AgentsView t={t} agents={agents} newAgent={newAgent} setNewAgent={setNewAgent} addAgent={addAgent} deleteAgent={deleteAgent} />}
159
+ {tab === 'memory' && <MemoryView t={t} memories={memories} newMemory={newMemory} setNewMemory={setNewMemory} addMemory={addMemory} />}
160
+ {tab === 'audit' && <AuditView t={t} auditLogs={auditLogs} profileHistory={profileHistory} />}
161
+ {tab === 'settings' && <SettingsView t={t} />}
162
+ </div>
163
+ </main>
164
+ </div>
165
+ </>
166
+ );
167
+ }
168
+
169
+ /* ── Stat Card ── */
170
+ function Stat({ label, value, icon, onClick }: { label: string; value: string | number; icon: string; onClick?: () => void }) {
171
+ const ref = useRef<HTMLButtonElement>(null);
172
+ const handleMove = (e: React.MouseEvent) => {
173
+ const r = ref.current?.getBoundingClientRect();
174
+ if (r) {
175
+ ref.current!.style.setProperty('--mx', `${((e.clientX - r.left) / r.width) * 100}%`);
176
+ ref.current!.style.setProperty('--my', `${((e.clientY - r.top) / r.height) * 100}%`);
177
+ }
178
+ };
179
+ return (
180
+ <button ref={ref} onClick={onClick} onMouseMove={handleMove} className="stat-card text-left">
181
+ <div className="flex items-center justify-between mb-2 relative z-10">
182
+ <span className="text-[10px] uppercase tracking-widest" style={{ color: 'var(--text2)' }}>{label}</span>
183
+ <span style={{ color: 'var(--amber)', opacity: 0.6 }}><Icon d={icon} size={16} /></span>
184
+ </div>
185
+ <div className="stat-value relative z-10">{value}</div>
186
+ </button>
187
+ );
188
+ }
189
+
190
+ /* ── Overview ── */
191
+ function Overview({ t, profiles, agents, memories, health, setTab }: any) {
192
+ const layerCount = new Set(profiles.map((p: any) => p.layer)).size;
193
+ return (
194
+ <div>
195
+ <div className="mb-6">
196
+ <h1 className="text-xl font-bold">{t.overview.title}</h1>
197
+ <p className="text-xs" style={{ color: 'var(--text2)' }}>{t.overview.subtitle}</p>
198
+ </div>
199
+ <div className="grid grid-cols-4 gap-3 mb-8 stagger-in">
200
+ <Stat label={t.overview.agents} value={agents.length} icon={NAV[2].icon} onClick={() => setTab('agents')} />
201
+ <Stat label={t.overview.profiles} value={profiles.length} icon={NAV[1].icon} onClick={() => setTab('profile')} />
202
+ <Stat label={t.overview.layers} value={layerCount} icon="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" onClick={() => setTab('profile')} />
203
+ <Stat label={t.overview.memories} value={memories.length} icon={NAV[3].icon} onClick={() => setTab('memory')} />
204
+ </div>
205
+ <h2 className="text-sm font-semibold mb-3">{t.overview.recent}</h2>
206
+ <div className="space-y-1">
207
+ {profiles.slice(0, 8).map((p: any, i: number) => (
208
+ <div key={i} className="data-row text-xs">
209
+ <span className="badge" style={{ background: 'var(--amber-glow)', color: 'var(--amber)' }}>{p.layer}</span>
210
+ <span className="font-mono" style={{ color: 'var(--blue)' }}>{p.key}</span>
211
+ <span className="flex-1 truncate font-mono" style={{ color: 'var(--text2)', fontSize: '0.65rem' }}>{JSON.stringify(p.value)}</span>
212
+ {p.source && <Badge text={p.source} />}
213
+ </div>
214
+ ))}
215
+ {profiles.length === 0 && <p className="text-xs" style={{ color: 'var(--text2)' }}>{t.overview.noData}</p>}
216
+ </div>
217
+ </div>
218
+ );
219
+ }
220
+
221
+ /* ── Profile View ── */
222
+ function ProfileView({ t, layers }: { t: any; layers: Record<string, any[]> }) {
223
+ const [open, setOpen] = useState<Record<string, boolean>>({});
224
+ const toggle = (l: string) => setOpen(p => ({ ...p, [l]: !p[l] }));
225
+ const keys = Object.keys(layers);
226
+
227
+ return (
228
+ <div>
229
+ <div className="mb-6">
230
+ <h1 className="text-xl font-bold">{t.profile.title}</h1>
231
+ <p className="text-xs" style={{ color: 'var(--text2)' }}>{t.profile.subtitle}</p>
232
+ </div>
233
+ {keys.length === 0 && <div className="empty-state"><p>{t.profile.noData}</p></div>}
234
+ <div className="space-y-2 stagger-in">
235
+ {keys.map(layer => (
236
+ <div key={layer} className="rounded-xl overflow-hidden" style={{ border: '1px solid var(--border)' }}>
237
+ <button onClick={() => toggle(layer)} className="w-full flex items-center justify-between px-4 py-2.5 text-left transition-colors hover:bg-[rgba(240,168,48,0.04)]"
238
+ style={{ background: 'rgba(240,168,48,0.03)' }}>
239
+ <div className="flex items-center gap-2">
240
+ <span className="font-mono text-xs font-medium" style={{ color: 'var(--amber)' }}>{layer}</span>
241
+ <span className="text-[10px] px-1.5 py-0.5 rounded-full" style={{ background: 'var(--surface)', color: 'var(--text2)' }}>
242
+ {layers[layer].length}
243
+ </span>
244
+ </div>
245
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--text2)" strokeWidth="2"
246
+ style={{ transform: open[layer] !== false ? 'rotate(180deg)' : '', transition: 'transform 0.2s' }}>
247
+ <path d="M19 9l-7 7-7-7"/>
248
+ </svg>
249
+ </button>
250
+ <div className={`layer-content ${open[layer] !== false ? 'open' : ''}`}>
251
+ <div>
252
+ {layers[layer].map((p: any, i: number) => (
253
+ <div key={i} className="data-row text-xs" style={{ borderTop: '1px solid var(--border)' }}>
254
+ <span className="font-mono min-w-[100px]" style={{ color: 'var(--blue)' }}>{p.key}</span>
255
+ <span className="flex-1 font-mono truncate" style={{ color: 'var(--green)', fontSize: '0.65rem' }}>{JSON.stringify(p.value)}</span>
256
+ {p.confidence != null && <span className="text-[10px]" style={{ color: 'var(--text2)' }}>conf:{p.confidence}</span>}
257
+ {p.source && <Badge text={p.source} />}
258
+ </div>
259
+ ))}
260
+ </div>
261
+ </div>
262
+ </div>
263
+ ))}
264
+ </div>
265
+ </div>
266
+ );
267
+ }
268
+
269
+ /* ── Agents View ── */
270
+ function AgentsView({ t, agents, newAgent, setNewAgent, addAgent, deleteAgent }: any) {
271
+ const [editing, setEditing] = useState<string | null>(null);
272
+ const [personaText, setPersonaText] = useState('');
273
+ const startEdit = (a: any) => {
274
+ setEditing(a.id);
275
+ setPersonaText(a.persona ? JSON.stringify(a.persona, null, 2) : '{\n "personality": "",\n "instructions": ""\n}');
276
+ };
277
+ const savePersona = async (id: string) => {
278
+ try {
279
+ await fetch('/api/v1/admin/agents', { method: 'PATCH', headers: H(), body: JSON.stringify({ id, persona: JSON.parse(personaText) }) });
280
+ setEditing(null); window.location.reload();
281
+ } catch { alert('Invalid JSON'); }
282
+ };
283
+
284
+ return (
285
+ <div>
286
+ <div className="mb-6">
287
+ <h1 className="text-xl font-bold">{t.agents.title}</h1>
288
+ <p className="text-xs" style={{ color: 'var(--text2)' }}>{t.agents.subtitle}</p>
289
+ </div>
290
+ <div className="flex gap-2 mb-6">
291
+ <input placeholder={t.agents.idPlaceholder} value={newAgent.id} onChange={e => setNewAgent({ ...newAgent, id: e.target.value })}
292
+ className="flex-1 rounded-lg px-3 py-2 text-xs" style={{ background: 'var(--surface)', border: '1px solid var(--border)', color: 'var(--text)' }} />
293
+ <input placeholder={t.agents.namePlaceholder} value={newAgent.name} onChange={e => setNewAgent({ ...newAgent, name: e.target.value })}
294
+ className="flex-1 rounded-lg px-3 py-2 text-xs" style={{ background: 'var(--surface)', border: '1px solid var(--border)', color: 'var(--text)' }} />
295
+ <button onClick={addAgent} className="btn-amber px-4 py-2 text-xs">{t.agents.add}</button>
296
+ </div>
297
+ <div className="space-y-2 stagger-in">
298
+ {agents.map((a: any) => (
299
+ <div key={a.id} className="glow-card" style={{ border: '1px solid var(--border)' }}>
300
+ <div className="flex items-center justify-between px-4 py-3">
301
+ <div className="flex items-center gap-3">
302
+ <div className="w-8 h-8 rounded-lg flex items-center justify-center text-xs font-bold"
303
+ style={{ background: `${agentColor(a.id)}18`, color: agentColor(a.id) }}>
304
+ {(a.name || a.id).charAt(0).toUpperCase()}
305
+ </div>
306
+ <div>
307
+ <div className="font-semibold text-xs">{a.name || a.id}</div>
308
+ <div className="font-mono text-[10px]" style={{ color: 'var(--text2)' }}>{a.id}</div>
309
+ </div>
310
+ </div>
311
+ <div className="flex items-center gap-2">
312
+ <span className="badge" style={{ background: 'rgba(52,211,153,0.1)', color: 'var(--green)' }}>{a.permissions || 'read,write'}</span>
313
+ <button onClick={() => editing === a.id ? setEditing(null) : startEdit(a)} className="text-[10px] px-2 py-1 rounded-md"
314
+ style={{ background: 'var(--amber-glow)', color: 'var(--amber)' }}>{t.agents.editPersona}</button>
315
+ <button onClick={() => deleteAgent(a.id)} className="text-[10px] px-2 py-1 rounded-md"
316
+ style={{ background: 'rgba(248,113,113,0.1)', color: 'var(--red)' }}>{t.agents.delete}</button>
317
+ </div>
318
+ </div>
319
+ {editing === a.id && (
320
+ <div className="px-4 pb-3">
321
+ <textarea value={personaText} onChange={e => setPersonaText(e.target.value)} rows={5}
322
+ className="w-full rounded-lg px-3 py-2 text-[11px] font-mono resize-none mb-2"
323
+ style={{ background: 'var(--bg)', border: '1px solid var(--border)', color: 'var(--text)' }} />
324
+ <div className="flex gap-2">
325
+ <button onClick={() => savePersona(a.id)} className="btn-amber px-3 py-1.5 text-[10px]">{t.agents.save}</button>
326
+ <button onClick={() => setEditing(null)} className="text-[10px] px-3 py-1.5" style={{ color: 'var(--text2)' }}>{t.agents.cancel}</button>
327
+ </div>
328
+ </div>
329
+ )}
330
+ {a.persona && editing !== a.id && (
331
+ <div className="px-4 pb-2 text-[10px] font-mono truncate" style={{ color: 'var(--text2)' }}>{JSON.stringify(a.persona)}</div>
332
+ )}
333
+ </div>
334
+ ))}
335
+ {agents.length === 0 && <div className="empty-state"><p className="text-xs">{t.agents.noAgents}</p></div>}
336
+ </div>
337
+ </div>
338
+ );
339
+ }
340
+
341
+ /* ── Memory View ── */
342
+ function MemoryView({ t, memories, newMemory, setNewMemory, addMemory }: any) {
343
+ const [search, setSearch] = useState('');
344
+ const [results, setResults] = useState<any[] | null>(null);
345
+ const doSearch = async () => {
346
+ if (!search.trim()) { setResults(null); return; }
347
+ const r = await fetch(`/api/v1/memory?q=${encodeURIComponent(search)}`, { headers: H() }).then(r => r.json()).catch(() => []);
348
+ setResults(Array.isArray(r) ? r : []);
349
+ };
350
+ const TYPE_C: Record<string, string> = { fact: 'var(--blue)', preference: 'var(--amber)', experience: 'var(--green)', observation: 'var(--text2)' };
351
+ const display = results ?? memories;
352
+
353
+ return (
354
+ <div>
355
+ <div className="mb-6">
356
+ <h1 className="text-xl font-bold">{t.memory.title}</h1>
357
+ <p className="text-xs" style={{ color: 'var(--text2)' }}>{t.memory.subtitle}</p>
358
+ </div>
359
+ <div className="flex gap-2 mb-4">
360
+ <input placeholder={t.memory.searchPlaceholder} value={search}
361
+ onChange={e => setSearch(e.target.value)} onKeyDown={e => e.key === 'Enter' && doSearch()}
362
+ className="flex-1 rounded-lg px-3 py-2 text-xs" style={{ background: 'var(--surface)', border: '1px solid var(--border)', color: 'var(--text)' }} />
363
+ <button onClick={doSearch} className="px-3 py-2 rounded-lg text-xs font-medium"
364
+ style={{ background: 'var(--amber-glow)', color: 'var(--amber)', border: '1px solid rgba(240,168,48,0.2)' }}>{t.memory.search}</button>
365
+ {results && <button onClick={() => { setResults(null); setSearch(''); }} className="px-2 py-2 text-[10px]" style={{ color: 'var(--text2)' }}>{t.memory.clear}</button>}
366
+ </div>
367
+ <div className="glow-card p-4 mb-6" style={{ border: '1px solid var(--border)' }}>
368
+ <textarea placeholder={t.memory.contentPlaceholder} value={newMemory.content} onChange={e => setNewMemory({ ...newMemory, content: e.target.value })}
369
+ rows={2} className="w-full rounded-lg px-3 py-2 text-xs resize-none mb-2"
370
+ style={{ background: 'var(--bg)', border: '1px solid var(--border)', color: 'var(--text)' }} />
371
+ <div className="flex gap-2">
372
+ <input placeholder={t.memory.tagsPlaceholder} value={newMemory.tags} onChange={e => setNewMemory({ ...newMemory, tags: e.target.value })}
373
+ className="flex-1 rounded-lg px-3 py-2 text-xs" style={{ background: 'var(--bg)', border: '1px solid var(--border)', color: 'var(--text)' }} />
374
+ <select value={newMemory.type} onChange={e => setNewMemory({ ...newMemory, type: e.target.value })}
375
+ className="rounded-lg px-2 py-2 text-xs" style={{ background: 'var(--bg)', border: '1px solid var(--border)', color: 'var(--text)' }}>
376
+ <option value="observation">{t.memory.types.observation}</option>
377
+ <option value="fact">{t.memory.types.fact}</option>
378
+ <option value="preference">{t.memory.types.preference}</option>
379
+ <option value="experience">{t.memory.types.experience}</option>
380
+ </select>
381
+ <button onClick={addMemory} className="btn-amber px-4 py-2 text-xs">{t.memory.write}</button>
382
+ </div>
383
+ </div>
384
+ {results && <p className="text-[10px] mb-2" style={{ color: 'var(--text2)' }}>{t.memory.results(results.length)}</p>}
385
+ <div className="space-y-2 stagger-in">
386
+ {display.map((m: any, i: number) => (
387
+ <div key={m.id || i} className="glow-card px-4 py-3" style={{ border: '1px solid var(--border)' }}>
388
+ <div className="flex items-center gap-1.5 mb-1.5">
389
+ {m.type && <span className="badge" style={{ background: `${TYPE_C[m.type] || 'var(--text2)'}18`, color: TYPE_C[m.type] || 'var(--text2)' }}>{m.type}</span>}
390
+ {m.importance != null && m.importance !== 0.5 && <span className="text-[10px]" style={{ color: 'var(--text2)' }}>imp:{m.importance}</span>}
391
+ </div>
392
+ <p className="text-xs mb-1.5">{m.content}</p>
393
+ <div className="flex items-center gap-1.5 flex-wrap text-[10px]" style={{ color: 'var(--text2)' }}>
394
+ {(Array.isArray(m.tags) ? m.tags : []).map((tag: string, j: number) => (
395
+ <span key={j} className="badge" style={{ background: 'var(--amber-glow)', color: 'var(--amber)' }}>{tag}</span>
396
+ ))}
397
+ <span className="ml-auto"><Badge text={m.agent_id || m.source || ''} /></span>
398
+ {m.created_at && <span>{new Date(m.created_at).toLocaleString()}</span>}
399
+ </div>
400
+ </div>
401
+ ))}
402
+ {display.length === 0 && <div className="empty-state"><p className="text-xs">{results ? t.memory.noMatch : t.memory.noMemory}</p></div>}
403
+ </div>
404
+ </div>
405
+ );
406
+ }
407
+
408
+ /* ── Audit View ── */
409
+ function AuditView({ t, auditLogs, profileHistory }: { t: any; auditLogs: any[]; profileHistory: any[] }) {
410
+ return (
411
+ <div>
412
+ <div className="mb-6">
413
+ <h1 className="text-xl font-bold">{t.audit.title}</h1>
414
+ <p className="text-xs" style={{ color: 'var(--text2)' }}>{t.audit.subtitle}</p>
415
+ </div>
416
+ <div className="rounded-xl overflow-hidden mb-8" style={{ background: 'var(--surface)', border: '1px solid var(--border)' }}>
417
+ <div className="overflow-x-auto">
418
+ <table className="w-full text-xs">
419
+ <thead>
420
+ <tr style={{ borderBottom: '1px solid var(--border)' }}>
421
+ <th className="text-left px-3 py-2 text-[10px] font-medium" style={{ color: 'var(--text2)' }}>{t.audit.action}</th>
422
+ <th className="text-left px-3 py-2 text-[10px] font-medium" style={{ color: 'var(--text2)' }}>{t.audit.agent}</th>
423
+ <th className="text-left px-3 py-2 text-[10px] font-medium" style={{ color: 'var(--text2)' }}>{t.audit.target}</th>
424
+ <th className="text-left px-3 py-2 text-[10px] font-medium" style={{ color: 'var(--text2)' }}>{t.audit.detail}</th>
425
+ <th className="text-left px-3 py-2 text-[10px] font-medium" style={{ color: 'var(--text2)' }}>{t.audit.time}</th>
426
+ </tr>
427
+ </thead>
428
+ <tbody>
429
+ {auditLogs.map((log: any, i: number) => (
430
+ <tr key={log.id || i} className="transition-colors hover:bg-[rgba(240,168,48,0.03)]" style={{ borderBottom: '1px solid var(--border)' }}>
431
+ <td className="px-3 py-2"><span className="badge" style={{ background: 'var(--amber-glow)', color: 'var(--amber)' }}>{log.action}</span></td>
432
+ <td className="px-3 py-2"><Badge text={log.agent_id || ''} /></td>
433
+ <td className="px-3 py-2 font-mono text-[10px]" style={{ color: 'var(--text2)' }}>{log.target_type}{log.target_id ? `:${log.target_id}` : ''}</td>
434
+ <td className="px-3 py-2 text-[10px] truncate max-w-[180px]" style={{ color: 'var(--text2)' }}>{log.detail}</td>
435
+ <td className="px-3 py-2 text-[10px]" style={{ color: 'var(--text2)' }}>{log.created_at && new Date(log.created_at).toLocaleString()}</td>
436
+ </tr>
437
+ ))}
438
+ </tbody>
439
+ </table>
440
+ </div>
441
+ {auditLogs.length === 0 && <div className="empty-state py-8"><p className="text-xs">{t.audit.noData}</p></div>}
442
+ </div>
443
+
444
+ <h2 className="text-sm font-semibold mb-3">{t.audit.historyTitle}</h2>
445
+ <div className="space-y-2 stagger-in">
446
+ {profileHistory.map((h: any, i: number) => (
447
+ <div key={h.id || i} className="glow-card px-4 py-3" style={{ border: '1px solid var(--border)' }}>
448
+ <div className="flex items-center gap-2 mb-2">
449
+ <span className="badge" style={{ background: 'var(--amber-glow)', color: 'var(--amber)' }}>{h.layer}</span>
450
+ <span className="font-mono text-xs" style={{ color: 'var(--blue)' }}>{h.key}</span>
451
+ {h.source && <Badge text={h.source} />}
452
+ <span className="ml-auto text-[10px]" style={{ color: 'var(--text2)' }}>{h.created_at && new Date(h.created_at).toLocaleString()}</span>
453
+ </div>
454
+ <div className="grid grid-cols-2 gap-3 text-[10px]">
455
+ <div>
456
+ <span style={{ color: 'var(--text2)' }}>{t.audit.oldValue}:</span>
457
+ <pre className="mt-1 p-2 rounded overflow-x-auto font-mono" style={{ background: 'var(--bg)', color: 'rgba(248,113,113,0.7)', fontSize: '0.6rem' }}>{h.old_value || '—'}</pre>
458
+ </div>
459
+ <div>
460
+ <span style={{ color: 'var(--text2)' }}>{t.audit.newValue}:</span>
461
+ <pre className="mt-1 p-2 rounded overflow-x-auto font-mono" style={{ background: 'var(--bg)', color: 'rgba(52,211,153,0.7)', fontSize: '0.6rem' }}>{h.new_value}</pre>
462
+ </div>
463
+ </div>
464
+ </div>
465
+ ))}
466
+ {profileHistory.length === 0 && <div className="empty-state"><p className="text-xs">{t.audit.noData}</p></div>}
467
+ </div>
468
+ </div>
469
+ );
470
+ }
471
+
472
+ /* ── Settings View ── */
473
+ function SettingsView({ t }: { t: any }) {
474
+ const [cfg, setCfg] = useState({ url: '', key: '', model: '' });
475
+ const [enabled, setEnabled] = useState(false);
476
+ const [msg, setMsg] = useState('');
477
+ const [testing, setTesting] = useState(false);
478
+
479
+ useEffect(() => {
480
+ fetch('/api/v1/admin/settings', { headers: H() }).then(r => r.json()).then(d => {
481
+ if (d.embedding) {
482
+ setCfg({ url: d.embedding.url || '', key: '', model: d.embedding.model || '' });
483
+ setEnabled(d.embedding.enabled);
484
+ }
485
+ }).catch(() => {});
486
+ }, []);
487
+
488
+ const save = async () => {
489
+ await fetch('/api/v1/admin/settings', {
490
+ method: 'PATCH', headers: H(),
491
+ body: JSON.stringify({ embedding: { url: cfg.url, key: cfg.key || undefined, model: cfg.model } }),
492
+ });
493
+ setMsg(t.settings.saved);
494
+ setTimeout(() => setMsg(''), 4000);
495
+ };
496
+
497
+ const test = async () => {
498
+ setTesting(true); setMsg('');
499
+ try {
500
+ const res = await fetch(cfg.url, {
501
+ method: 'POST',
502
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${cfg.key}` },
503
+ body: JSON.stringify({ model: cfg.model || 'text-embedding-3-small', input: 'test' }),
504
+ });
505
+ setMsg(res.ok ? t.settings.testOk : `${t.settings.testFail}: ${res.status}`);
506
+ } catch (e: any) { setMsg(`${t.settings.testFail}: ${e.message}`); }
507
+ setTesting(false);
508
+ };
509
+
510
+ return (
511
+ <div>
512
+ <div className="mb-6">
513
+ <h1 className="text-xl font-bold">{t.settings.title}</h1>
514
+ <p className="text-xs" style={{ color: 'var(--text2)' }}>{t.settings.subtitle}</p>
515
+ </div>
516
+ <div className="glow-card p-5" style={{ border: '1px solid var(--border)' }}>
517
+ <div className="flex items-center gap-2 mb-4">
518
+ <h2 className="text-sm font-semibold">{t.settings.embedding}</h2>
519
+ <span className="badge" style={{
520
+ background: enabled ? 'rgba(52,211,153,0.1)' : 'rgba(248,113,113,0.1)',
521
+ color: enabled ? 'var(--green)' : 'var(--red)',
522
+ }}>{enabled ? t.settings.enabled : t.settings.disabled}</span>
523
+ </div>
524
+ <p className="text-xs mb-4" style={{ color: 'var(--text2)' }}>{t.settings.embedDesc}</p>
525
+ <div className="space-y-3">
526
+ <div>
527
+ <label className="text-[10px] uppercase tracking-wider mb-1 block" style={{ color: 'var(--text2)' }}>{t.settings.url}</label>
528
+ <input value={cfg.url} onChange={e => setCfg({ ...cfg, url: e.target.value })}
529
+ placeholder={t.settings.urlPlaceholder}
530
+ className="w-full rounded-lg px-3 py-2 text-xs font-mono"
531
+ style={{ background: 'var(--bg)', border: '1px solid var(--border)', color: 'var(--text)' }} />
532
+ </div>
533
+ <div>
534
+ <label className="text-[10px] uppercase tracking-wider mb-1 block" style={{ color: 'var(--text2)' }}>{t.settings.key}</label>
535
+ <input type="password" value={cfg.key} onChange={e => setCfg({ ...cfg, key: e.target.value })}
536
+ placeholder={t.settings.keyPlaceholder}
537
+ className="w-full rounded-lg px-3 py-2 text-xs font-mono"
538
+ style={{ background: 'var(--bg)', border: '1px solid var(--border)', color: 'var(--text)' }} />
539
+ </div>
540
+ <div>
541
+ <label className="text-[10px] uppercase tracking-wider mb-1 block" style={{ color: 'var(--text2)' }}>{t.settings.model}</label>
542
+ <input value={cfg.model} onChange={e => setCfg({ ...cfg, model: e.target.value })}
543
+ placeholder={t.settings.modelPlaceholder}
544
+ className="w-full rounded-lg px-3 py-2 text-xs font-mono"
545
+ style={{ background: 'var(--bg)', border: '1px solid var(--border)', color: 'var(--text)' }} />
546
+ </div>
547
+ </div>
548
+ <div className="flex items-center gap-2 mt-4">
549
+ <button onClick={save} className="btn-amber px-4 py-2 text-xs">{t.settings.save}</button>
550
+ {cfg.url && cfg.key && (
551
+ <button onClick={test} disabled={testing} className="px-3 py-2 rounded-lg text-xs"
552
+ style={{ background: 'var(--amber-glow)', color: 'var(--amber)', border: '1px solid rgba(240,168,48,0.2)' }}>
553
+ {testing ? t.settings.testing : t.settings.test}
554
+ </button>
555
+ )}
556
+ {msg && <span className="text-xs ml-2" style={{ color: msg.includes('OK') || msg.includes('保存') || msg.includes('Saved') || msg.includes('成功') ? 'var(--green)' : 'var(--red)' }}>{msg}</span>}
557
+ </div>
558
+ </div>
559
+ </div>
560
+ );
561
+ }
@@ -0,0 +1,34 @@
1
+ services:
2
+ swarm:
3
+ build: .
4
+ ports:
5
+ - "3777:3777"
6
+ volumes:
7
+ - swarm-data:/app/data
8
+ environment:
9
+ - SWARM_ADMIN_TOKEN=${SWARM_ADMIN_TOKEN:-swarm-admin-dev}
10
+ - SWARM_JWT_SECRET=${SWARM_JWT_SECRET:-change-me-in-production}
11
+ - DATABASE_URL=${DATABASE_URL:-}
12
+ depends_on:
13
+ postgres:
14
+ condition: service_healthy
15
+ restart: unless-stopped
16
+
17
+ postgres:
18
+ image: postgres:17-alpine
19
+ environment:
20
+ POSTGRES_DB: swarm
21
+ POSTGRES_USER: swarm
22
+ POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-swarm-dev}
23
+ volumes:
24
+ - pg-data:/var/lib/postgresql/data
25
+ healthcheck:
26
+ test: ["CMD-SHELL", "pg_isready -U swarm"]
27
+ interval: 5s
28
+ timeout: 3s
29
+ retries: 5
30
+ restart: unless-stopped
31
+
32
+ volumes:
33
+ swarm-data:
34
+ pg-data: