kyber-chat 1.0.0__py3-none-any.whl

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 (71) hide show
  1. kyber/__init__.py +6 -0
  2. kyber/__main__.py +8 -0
  3. kyber/agent/__init__.py +8 -0
  4. kyber/agent/context.py +224 -0
  5. kyber/agent/loop.py +687 -0
  6. kyber/agent/memory.py +109 -0
  7. kyber/agent/skills.py +244 -0
  8. kyber/agent/subagent.py +379 -0
  9. kyber/agent/tools/__init__.py +6 -0
  10. kyber/agent/tools/base.py +102 -0
  11. kyber/agent/tools/filesystem.py +191 -0
  12. kyber/agent/tools/message.py +86 -0
  13. kyber/agent/tools/registry.py +73 -0
  14. kyber/agent/tools/shell.py +141 -0
  15. kyber/agent/tools/spawn.py +65 -0
  16. kyber/agent/tools/task_status.py +53 -0
  17. kyber/agent/tools/web.py +163 -0
  18. kyber/bridge/package.json +26 -0
  19. kyber/bridge/src/index.ts +50 -0
  20. kyber/bridge/src/server.ts +104 -0
  21. kyber/bridge/src/types.d.ts +3 -0
  22. kyber/bridge/src/whatsapp.ts +185 -0
  23. kyber/bridge/tsconfig.json +16 -0
  24. kyber/bus/__init__.py +6 -0
  25. kyber/bus/events.py +37 -0
  26. kyber/bus/queue.py +81 -0
  27. kyber/channels/__init__.py +6 -0
  28. kyber/channels/base.py +121 -0
  29. kyber/channels/discord.py +304 -0
  30. kyber/channels/feishu.py +263 -0
  31. kyber/channels/manager.py +161 -0
  32. kyber/channels/telegram.py +302 -0
  33. kyber/channels/whatsapp.py +141 -0
  34. kyber/cli/__init__.py +1 -0
  35. kyber/cli/commands.py +736 -0
  36. kyber/config/__init__.py +6 -0
  37. kyber/config/loader.py +95 -0
  38. kyber/config/schema.py +205 -0
  39. kyber/cron/__init__.py +6 -0
  40. kyber/cron/service.py +346 -0
  41. kyber/cron/types.py +59 -0
  42. kyber/dashboard/__init__.py +5 -0
  43. kyber/dashboard/server.py +122 -0
  44. kyber/dashboard/static/app.js +458 -0
  45. kyber/dashboard/static/favicon.png +0 -0
  46. kyber/dashboard/static/index.html +107 -0
  47. kyber/dashboard/static/kyber_logo.png +0 -0
  48. kyber/dashboard/static/styles.css +608 -0
  49. kyber/heartbeat/__init__.py +5 -0
  50. kyber/heartbeat/service.py +130 -0
  51. kyber/providers/__init__.py +6 -0
  52. kyber/providers/base.py +69 -0
  53. kyber/providers/litellm_provider.py +227 -0
  54. kyber/providers/transcription.py +65 -0
  55. kyber/session/__init__.py +5 -0
  56. kyber/session/manager.py +202 -0
  57. kyber/skills/README.md +47 -0
  58. kyber/skills/github/SKILL.md +48 -0
  59. kyber/skills/skill-creator/SKILL.md +371 -0
  60. kyber/skills/summarize/SKILL.md +67 -0
  61. kyber/skills/tmux/SKILL.md +121 -0
  62. kyber/skills/tmux/scripts/find-sessions.sh +112 -0
  63. kyber/skills/tmux/scripts/wait-for-text.sh +83 -0
  64. kyber/skills/weather/SKILL.md +49 -0
  65. kyber/utils/__init__.py +5 -0
  66. kyber/utils/helpers.py +91 -0
  67. kyber_chat-1.0.0.dist-info/METADATA +35 -0
  68. kyber_chat-1.0.0.dist-info/RECORD +71 -0
  69. kyber_chat-1.0.0.dist-info/WHEEL +4 -0
  70. kyber_chat-1.0.0.dist-info/entry_points.txt +2 -0
  71. kyber_chat-1.0.0.dist-info/licenses/LICENSE +21 -0
kyber/cron/types.py ADDED
@@ -0,0 +1,59 @@
1
+ """Cron types."""
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Literal
5
+
6
+
7
+ @dataclass
8
+ class CronSchedule:
9
+ """Schedule definition for a cron job."""
10
+ kind: Literal["at", "every", "cron"]
11
+ # For "at": timestamp in ms
12
+ at_ms: int | None = None
13
+ # For "every": interval in ms
14
+ every_ms: int | None = None
15
+ # For "cron": cron expression (e.g. "0 9 * * *")
16
+ expr: str | None = None
17
+ # Timezone for cron expressions
18
+ tz: str | None = None
19
+
20
+
21
+ @dataclass
22
+ class CronPayload:
23
+ """What to do when the job runs."""
24
+ kind: Literal["system_event", "agent_turn"] = "agent_turn"
25
+ message: str = ""
26
+ # Deliver response to channel
27
+ deliver: bool = False
28
+ channel: str | None = None # e.g. "whatsapp"
29
+ to: str | None = None # e.g. phone number
30
+
31
+
32
+ @dataclass
33
+ class CronJobState:
34
+ """Runtime state of a job."""
35
+ next_run_at_ms: int | None = None
36
+ last_run_at_ms: int | None = None
37
+ last_status: Literal["ok", "error", "skipped"] | None = None
38
+ last_error: str | None = None
39
+
40
+
41
+ @dataclass
42
+ class CronJob:
43
+ """A scheduled job."""
44
+ id: str
45
+ name: str
46
+ enabled: bool = True
47
+ schedule: CronSchedule = field(default_factory=lambda: CronSchedule(kind="every"))
48
+ payload: CronPayload = field(default_factory=CronPayload)
49
+ state: CronJobState = field(default_factory=CronJobState)
50
+ created_at_ms: int = 0
51
+ updated_at_ms: int = 0
52
+ delete_after_run: bool = False
53
+
54
+
55
+ @dataclass
56
+ class CronStore:
57
+ """Persistent store for cron jobs."""
58
+ version: int = 1
59
+ jobs: list[CronJob] = field(default_factory=list)
@@ -0,0 +1,5 @@
1
+ """Dashboard package for Kyber."""
2
+
3
+ from kyber.dashboard.server import create_dashboard_app
4
+
5
+ __all__ = ["create_dashboard_app"]
@@ -0,0 +1,122 @@
1
+ """Kyber web dashboard server."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import secrets
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from fastapi import Depends, FastAPI, HTTPException, Request
10
+ from fastapi.responses import FileResponse, JSONResponse
11
+ from fastapi.staticfiles import StaticFiles
12
+ from starlette.middleware.base import BaseHTTPMiddleware
13
+ from starlette.middleware.trustedhost import TrustedHostMiddleware
14
+ from starlette.status import HTTP_401_UNAUTHORIZED, HTTP_413_REQUEST_ENTITY_TOO_LARGE
15
+
16
+ from kyber.config.loader import convert_keys, convert_to_camel, load_config, save_config
17
+ from kyber.config.schema import Config
18
+
19
+ STATIC_DIR = Path(__file__).parent / "static"
20
+ MAX_BODY_BYTES = 1 * 1024 * 1024 # 1 MB
21
+ LOCAL_HOSTS = {"127.0.0.1", "localhost", "::1"}
22
+
23
+
24
+ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
25
+ async def dispatch(self, request: Request, call_next): # type: ignore[override]
26
+ response = await call_next(request)
27
+ response.headers["X-Content-Type-Options"] = "nosniff"
28
+ response.headers["X-Frame-Options"] = "DENY"
29
+ response.headers["Referrer-Policy"] = "no-referrer"
30
+ response.headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=()"
31
+ response.headers["Cache-Control"] = "no-store"
32
+ response.headers[
33
+ "Content-Security-Policy"
34
+ ] = (
35
+ "default-src 'self'; "
36
+ "img-src 'self' data:; "
37
+ "style-src 'self' https://fonts.googleapis.com; "
38
+ "font-src 'self' https://fonts.gstatic.com; "
39
+ "script-src 'self'; "
40
+ "connect-src 'self'"
41
+ )
42
+ return response
43
+
44
+
45
+ class BodyLimitMiddleware(BaseHTTPMiddleware):
46
+ async def dispatch(self, request: Request, call_next): # type: ignore[override]
47
+ if request.method in {"POST", "PUT", "PATCH"}:
48
+ length = request.headers.get("content-length")
49
+ if length:
50
+ try:
51
+ if int(length) > MAX_BODY_BYTES:
52
+ return JSONResponse(
53
+ {"error": "Payload too large"},
54
+ status_code=HTTP_413_REQUEST_ENTITY_TOO_LARGE,
55
+ )
56
+ except ValueError:
57
+ pass
58
+ return await call_next(request)
59
+
60
+
61
+ def _require_token(request: Request) -> None:
62
+ token = request.app.state.auth_token
63
+ if not token:
64
+ raise HTTPException(status_code=HTTP_401_UNAUTHORIZED, detail="Unauthorized")
65
+ auth_header = request.headers.get("Authorization", "")
66
+ if not auth_header.startswith("Bearer "):
67
+ raise HTTPException(status_code=HTTP_401_UNAUTHORIZED, detail="Unauthorized")
68
+ provided = auth_header[len("Bearer "):].strip()
69
+ if not secrets.compare_digest(provided, token):
70
+ raise HTTPException(status_code=HTTP_401_UNAUTHORIZED, detail="Unauthorized")
71
+
72
+
73
+ def _ensure_auth_token(config: Config) -> str:
74
+ token = config.dashboard.auth_token.strip()
75
+ if not token:
76
+ token = secrets.token_urlsafe(32)
77
+ config.dashboard.auth_token = token
78
+ save_config(config)
79
+ return token
80
+
81
+
82
+ def _build_allowed_hosts(config: Config) -> list[str]:
83
+ allowed = sorted(LOCAL_HOSTS | set(config.dashboard.allowed_hosts))
84
+ return allowed
85
+
86
+
87
+ def create_dashboard_app(config: Config) -> FastAPI:
88
+ app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
89
+
90
+ app.state.auth_token = _ensure_auth_token(config)
91
+ allowed_hosts = _build_allowed_hosts(config)
92
+ app.add_middleware(TrustedHostMiddleware, allowed_hosts=allowed_hosts)
93
+ app.add_middleware(SecurityHeadersMiddleware)
94
+ app.add_middleware(BodyLimitMiddleware)
95
+
96
+ app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
97
+
98
+ @app.get("/")
99
+ async def index() -> FileResponse:
100
+ return FileResponse(STATIC_DIR / "index.html")
101
+
102
+ @app.get("/api/config", dependencies=[Depends(_require_token)])
103
+ async def get_config() -> JSONResponse:
104
+ config = load_config()
105
+ payload = convert_to_camel(config.model_dump())
106
+ return JSONResponse(payload)
107
+
108
+ @app.put("/api/config", dependencies=[Depends(_require_token)])
109
+ async def update_config(body: dict[str, Any]) -> JSONResponse:
110
+ data = convert_keys(body)
111
+ config = Config.model_validate(data)
112
+
113
+ # Ensure token is not emptied accidentally
114
+ if not config.dashboard.auth_token.strip():
115
+ current = load_config()
116
+ config.dashboard.auth_token = current.dashboard.auth_token.strip() or secrets.token_urlsafe(32)
117
+
118
+ save_config(config)
119
+ payload = convert_to_camel(config.model_dump())
120
+ return JSONResponse(payload)
121
+
122
+ return app
@@ -0,0 +1,458 @@
1
+ /* ── Kyber Dashboard ── */
2
+ const API = '/api';
3
+ const TOKEN_KEY = 'kyber_dashboard_token';
4
+
5
+ // DOM refs
6
+ const $ = (s) => document.getElementById(s);
7
+ const loginModal = $('loginModal');
8
+ const tokenInput = $('tokenInput');
9
+ const tokenSubmit = $('tokenSubmit');
10
+ const statusPill = $('statusPill');
11
+ const statusText = $('statusText');
12
+ const savedAt = $('savedAt');
13
+ const pageTitle = $('pageTitle');
14
+ const pageDesc = $('pageDesc');
15
+ const contentBody = $('contentBody');
16
+ const saveBtn = $('saveBtn');
17
+ const refreshBtn = $('refreshBtn');
18
+ const toast = $('toast');
19
+
20
+ let config = null;
21
+ let configSnapshot = null;
22
+ let isDirty = false;
23
+ let activeSection = 'providers';
24
+ let toastTimer = null;
25
+
26
+ // ── Section metadata ──
27
+ const SECTIONS = {
28
+ providers: {
29
+ title: 'Providers',
30
+ desc: 'Configure your LLM provider API keys and endpoints.',
31
+ },
32
+ agents: {
33
+ title: 'Agent',
34
+ desc: 'Default model, workspace, and tool loop settings.',
35
+ },
36
+ channels: {
37
+ title: 'Channels',
38
+ desc: 'Enable and configure chat platform integrations.',
39
+ },
40
+ tools: {
41
+ title: 'Tools',
42
+ desc: 'Web search and shell execution settings.',
43
+ },
44
+ gateway: {
45
+ title: 'Gateway',
46
+ desc: 'Host and port for the Kyber gateway server.',
47
+ },
48
+ dashboard: {
49
+ title: 'Dashboard',
50
+ desc: 'Dashboard access, auth token, and allowed hosts.',
51
+ },
52
+ json: {
53
+ title: 'Raw JSON',
54
+ desc: 'View and edit the full configuration as JSON.',
55
+ },
56
+ };
57
+
58
+ // ── Helpers ──
59
+ function showToast(msg, type = 'info') {
60
+ toast.textContent = msg;
61
+ toast.className = 'toast ' + (type === 'error' ? 'error' : type === 'success' ? 'success' : '');
62
+ clearTimeout(toastTimer);
63
+ toastTimer = setTimeout(() => toast.classList.add('hidden'), 2500);
64
+ }
65
+
66
+ function getToken() { return sessionStorage.getItem(TOKEN_KEY) || ''; }
67
+ function setToken(t) { sessionStorage.setItem(TOKEN_KEY, t); }
68
+
69
+ function humanize(key) {
70
+ return key
71
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
72
+ .replace(/_/g, ' ')
73
+ .replace(/\b\w/g, (c) => c.toUpperCase());
74
+ }
75
+
76
+ function setPath(obj, path, val) {
77
+ let t = obj;
78
+ for (let i = 0; i < path.length - 1; i++) t = t[path[i]];
79
+ t[path[path.length - 1]] = val;
80
+ }
81
+
82
+ function isObj(v) { return v && typeof v === 'object' && !Array.isArray(v); }
83
+
84
+ function isSensitive(key) {
85
+ const k = key.toLowerCase();
86
+ return k.includes('token') || k.includes('key') || k.includes('secret');
87
+ }
88
+
89
+ function markDirty() {
90
+ isDirty = true;
91
+ saveBtn.disabled = false;
92
+ saveBtn.classList.remove('disabled');
93
+ }
94
+
95
+ function markClean() {
96
+ isDirty = false;
97
+ configSnapshot = JSON.stringify(config);
98
+ saveBtn.disabled = true;
99
+ saveBtn.classList.add('disabled');
100
+ }
101
+
102
+ // ── API ──
103
+ async function apiFetch(path, opts = {}) {
104
+ const headers = { ...opts.headers };
105
+ const token = getToken();
106
+ if (token) headers['Authorization'] = `Bearer ${token}`;
107
+ if (opts.body) headers['Content-Type'] = 'application/json';
108
+ const res = await fetch(path, { ...opts, headers });
109
+ if (res.status === 401) {
110
+ statusText.textContent = 'Locked';
111
+ statusPill.className = 'status-pill error';
112
+ showLogin();
113
+ throw new Error('Unauthorized');
114
+ }
115
+ return res;
116
+ }
117
+
118
+ function showLogin() { loginModal.classList.remove('hidden'); tokenInput.value = ''; tokenInput.focus(); }
119
+ function hideLogin() { loginModal.classList.add('hidden'); }
120
+
121
+ async function loadConfig() {
122
+ try {
123
+ statusText.textContent = 'Connecting…';
124
+ statusPill.className = 'status-pill';
125
+ const res = await apiFetch(`${API}/config`);
126
+ config = await res.json();
127
+ statusText.textContent = 'Connected';
128
+ statusPill.className = 'status-pill connected';
129
+ markClean();
130
+ renderSection();
131
+ } catch (e) {
132
+ console.error(e);
133
+ }
134
+ }
135
+
136
+ async function saveConfig() {
137
+ if (!config) return;
138
+ let payload = config;
139
+
140
+ if (activeSection === 'json') {
141
+ const ta = contentBody.querySelector('.json-editor');
142
+ if (ta) {
143
+ try { payload = JSON.parse(ta.value); }
144
+ catch { showToast('Invalid JSON', 'error'); return; }
145
+ }
146
+ }
147
+
148
+ try {
149
+ const res = await apiFetch(`${API}/config`, { method: 'PUT', body: JSON.stringify(payload) });
150
+ config = await res.json();
151
+ savedAt.textContent = 'Saved ' + new Date().toLocaleTimeString();
152
+ showToast('Configuration saved', 'success');
153
+ markClean();
154
+ renderSection();
155
+ } catch {
156
+ showToast('Save failed', 'error');
157
+ }
158
+ }
159
+
160
+ // ── Navigation ──
161
+ function switchSection(section) {
162
+ activeSection = section;
163
+ document.querySelectorAll('.nav-item').forEach((btn) => {
164
+ btn.classList.toggle('active', btn.dataset.section === section);
165
+ });
166
+ const meta = SECTIONS[section] || {};
167
+ pageTitle.textContent = meta.title || humanize(section);
168
+ pageDesc.textContent = meta.desc || '';
169
+ renderSection();
170
+ }
171
+
172
+ // ── Rendering ──
173
+ function renderSection() {
174
+ if (!config) { contentBody.innerHTML = '<div class="empty-state">Loading configuration…</div>'; return; }
175
+ contentBody.innerHTML = '';
176
+
177
+ if (activeSection === 'json') {
178
+ renderJSON();
179
+ return;
180
+ }
181
+
182
+ const data = config[activeSection];
183
+ if (!data || !isObj(data)) {
184
+ contentBody.innerHTML = '<div class="empty-state">No configuration for this section.</div>';
185
+ return;
186
+ }
187
+
188
+ // Special renderers
189
+ if (activeSection === 'providers') { renderProviders(data); return; }
190
+ if (activeSection === 'channels') { renderChannels(data); return; }
191
+ if (activeSection === 'agents') { renderAgents(data); return; }
192
+ if (activeSection === 'tools') { renderTools(data); return; }
193
+ if (activeSection === 'dashboard') { renderDashboard(data); return; }
194
+
195
+ // Generic card
196
+ const card = makeCard(humanize(activeSection));
197
+ renderFields(card.body, data, [activeSection]);
198
+ contentBody.appendChild(card.el);
199
+ }
200
+
201
+ // ── Card factory ──
202
+ function makeCard(title, badge) {
203
+ const el = document.createElement('div');
204
+ el.className = 'card';
205
+
206
+ const header = document.createElement('div');
207
+ header.className = 'card-header';
208
+ const h = document.createElement('span');
209
+ h.className = 'card-title';
210
+ h.textContent = title;
211
+ header.appendChild(h);
212
+
213
+ if (badge !== undefined) {
214
+ const b = document.createElement('span');
215
+ b.className = 'card-badge' + (badge ? ' on' : '');
216
+ b.textContent = badge ? 'Enabled' : 'Disabled';
217
+ header.appendChild(b);
218
+ }
219
+
220
+ el.appendChild(header);
221
+ const body = document.createElement('div');
222
+ body.className = 'card-body';
223
+ el.appendChild(body);
224
+ contentBody.appendChild(el);
225
+ return { el, body };
226
+ }
227
+
228
+ // ── Field rendering ──
229
+ function renderFields(container, obj, path) {
230
+ for (const [key, value] of Object.entries(obj)) {
231
+ const fullPath = [...path, key];
232
+
233
+ if (isObj(value)) {
234
+ // Nested object — sub-card
235
+ const sub = document.createElement('div');
236
+ sub.className = 'card';
237
+ sub.style.marginTop = '12px';
238
+ sub.style.border = '1px solid var(--border)';
239
+ const sh = document.createElement('div');
240
+ sh.className = 'card-header';
241
+ sh.innerHTML = `<span class="card-title">${humanize(key)}</span>`;
242
+ sub.appendChild(sh);
243
+ const sb = document.createElement('div');
244
+ sb.className = 'card-body';
245
+ sub.appendChild(sb);
246
+ renderFields(sb, value, fullPath);
247
+ container.appendChild(sub);
248
+ continue;
249
+ }
250
+
251
+ if (Array.isArray(value)) {
252
+ renderArrayField(container, key, value, fullPath);
253
+ continue;
254
+ }
255
+
256
+ renderField(container, key, value, fullPath);
257
+ }
258
+ }
259
+
260
+ function renderField(container, key, value, path) {
261
+ const row = document.createElement('div');
262
+ row.className = 'field-row';
263
+
264
+ const label = document.createElement('div');
265
+ label.className = 'field-label';
266
+ label.textContent = humanize(key);
267
+ row.appendChild(label);
268
+
269
+ const inputWrap = document.createElement('div');
270
+ inputWrap.className = 'field-input';
271
+
272
+ if (typeof value === 'boolean') {
273
+ const wrap = document.createElement('div');
274
+ wrap.className = 'checkbox-wrap';
275
+ const cb = document.createElement('input');
276
+ cb.type = 'checkbox';
277
+ cb.checked = value;
278
+ cb.id = 'cb-' + path.join('-');
279
+ cb.addEventListener('change', () => {
280
+ setPath(config, path, cb.checked);
281
+ markDirty();
282
+ if (key === 'enabled') renderSection();
283
+ });
284
+ wrap.appendChild(cb);
285
+ const lbl = document.createElement('label');
286
+ lbl.className = 'checkbox-label';
287
+ lbl.htmlFor = cb.id;
288
+ lbl.textContent = value ? 'Yes' : 'No';
289
+ cb.addEventListener('change', () => { lbl.textContent = cb.checked ? 'Yes' : 'No'; });
290
+ wrap.appendChild(lbl);
291
+ inputWrap.appendChild(wrap);
292
+ } else if (typeof value === 'number') {
293
+ const inp = document.createElement('input');
294
+ inp.type = 'number';
295
+ inp.value = value;
296
+ inp.addEventListener('input', () => {
297
+ const n = Number(inp.value);
298
+ setPath(config, path, Number.isNaN(n) ? 0 : n);
299
+ markDirty();
300
+ });
301
+ inputWrap.appendChild(inp);
302
+ } else {
303
+ const inp = document.createElement('input');
304
+ inp.type = isSensitive(key) ? 'password' : 'text';
305
+ inp.value = value || '';
306
+ inp.placeholder = isSensitive(key) ? '••••••••' : '';
307
+ inp.addEventListener('input', () => { setPath(config, path, inp.value); markDirty(); });
308
+ inputWrap.appendChild(inp);
309
+ }
310
+
311
+ row.appendChild(inputWrap);
312
+ container.appendChild(row);
313
+ }
314
+
315
+ function renderArrayField(container, key, arr, path) {
316
+ const row = document.createElement('div');
317
+ row.className = 'field-row';
318
+ row.style.alignItems = 'flex-start';
319
+
320
+ const label = document.createElement('div');
321
+ label.className = 'field-label';
322
+ label.style.paddingTop = '8px';
323
+ label.textContent = humanize(key);
324
+ row.appendChild(label);
325
+
326
+ const wrap = document.createElement('div');
327
+ wrap.className = 'field-input array-field';
328
+
329
+ const rebuild = () => {
330
+ wrap.innerHTML = '';
331
+ arr.forEach((item, i) => {
332
+ const r = document.createElement('div');
333
+ r.className = 'array-row';
334
+ const inp = document.createElement('input');
335
+ inp.type = 'text';
336
+ inp.value = item;
337
+ inp.addEventListener('input', () => { arr[i] = inp.value; markDirty(); });
338
+ r.appendChild(inp);
339
+
340
+ const del = document.createElement('button');
341
+ del.className = 'btn-icon danger';
342
+ del.innerHTML = '<svg width="12" height="12" viewBox="0 0 16 16" fill="none"><path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>';
343
+ del.addEventListener('click', () => { arr.splice(i, 1); markDirty(); rebuild(); });
344
+ r.appendChild(del);
345
+ wrap.appendChild(r);
346
+ });
347
+
348
+ const add = document.createElement('button');
349
+ add.className = 'btn-add';
350
+ add.innerHTML = '<svg width="10" height="10" viewBox="0 0 16 16" fill="none"><path d="M8 2v12M2 8h12" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg> Add';
351
+ add.addEventListener('click', () => { arr.push(''); markDirty(); rebuild(); });
352
+ wrap.appendChild(add);
353
+ };
354
+
355
+ rebuild();
356
+ row.appendChild(wrap);
357
+ container.appendChild(row);
358
+ }
359
+
360
+ // ── Section-specific renderers ──
361
+
362
+ function renderProviders(data) {
363
+ const providerNames = ['anthropic', 'openai', 'openrouter', 'deepseek', 'groq', 'gemini', 'zhipu', 'vllm'];
364
+ for (const name of providerNames) {
365
+ const prov = data[name];
366
+ if (!prov) continue;
367
+ const hasKey = !!(prov.apiKey || prov.api_key);
368
+ const card = makeCard(humanize(name), hasKey);
369
+ renderFields(card.body, prov, ['providers', name]);
370
+ }
371
+ }
372
+
373
+ function renderChannels(data) {
374
+ const channelNames = ['discord', 'telegram', 'whatsapp', 'feishu'];
375
+ for (const name of channelNames) {
376
+ const ch = data[name];
377
+ if (!ch) continue;
378
+ const card = makeCard(humanize(name), ch.enabled);
379
+ renderFields(card.body, ch, ['channels', name]);
380
+ }
381
+ }
382
+
383
+ function renderAgents(data) {
384
+ if (data.defaults) {
385
+ const card = makeCard('Agent Defaults');
386
+ renderFields(card.body, data.defaults, ['agents', 'defaults']);
387
+ } else {
388
+ const card = makeCard('Agent');
389
+ renderFields(card.body, data, ['agents']);
390
+ }
391
+ }
392
+
393
+ function renderTools(data) {
394
+ if (data.web) {
395
+ if (data.web.search) {
396
+ const card = makeCard('Web Search');
397
+ renderFields(card.body, data.web.search, ['tools', 'web', 'search']);
398
+ }
399
+ }
400
+ if (data.exec) {
401
+ const card = makeCard('Shell Execution');
402
+ renderFields(card.body, data.exec, ['tools', 'exec']);
403
+ }
404
+ }
405
+
406
+ function renderDashboard(data) {
407
+ // No enabled/disabled badge — if you're viewing this, the dashboard is running
408
+ const card = makeCard('Dashboard Settings');
409
+ // Render all fields except "enabled" since it's meaningless here
410
+ const filtered = Object.fromEntries(
411
+ Object.entries(data).filter(([k]) => k !== 'enabled')
412
+ );
413
+ renderFields(card.body, filtered, ['dashboard']);
414
+ }
415
+
416
+ function renderJSON() {
417
+ const ta = document.createElement('textarea');
418
+ ta.className = 'json-editor';
419
+ ta.spellcheck = false;
420
+ ta.value = JSON.stringify(config, null, 2);
421
+ ta.addEventListener('input', () => {
422
+ markDirty();
423
+ try {
424
+ JSON.parse(ta.value);
425
+ ta.style.borderColor = '';
426
+ } catch {
427
+ ta.style.borderColor = 'var(--red)';
428
+ }
429
+ });
430
+ contentBody.appendChild(ta);
431
+ }
432
+
433
+ // ── Event listeners ──
434
+ document.getElementById('sidebarNav').addEventListener('click', (e) => {
435
+ const btn = e.target.closest('.nav-item');
436
+ if (btn && btn.dataset.section) switchSection(btn.dataset.section);
437
+ });
438
+
439
+ saveBtn.addEventListener('click', saveConfig);
440
+ refreshBtn.addEventListener('click', loadConfig);
441
+
442
+ tokenSubmit.addEventListener('click', async () => {
443
+ const t = tokenInput.value.trim();
444
+ if (!t) return;
445
+ setToken(t);
446
+ hideLogin();
447
+ await loadConfig();
448
+ });
449
+
450
+ tokenInput.addEventListener('keydown', (e) => {
451
+ if (e.key === 'Enter') tokenSubmit.click();
452
+ });
453
+
454
+ // ── Init ──
455
+ window.addEventListener('load', async () => {
456
+ if (!getToken()) { showLogin(); }
457
+ else { await loadConfig(); }
458
+ });
Binary file