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.
- kyber/__init__.py +6 -0
- kyber/__main__.py +8 -0
- kyber/agent/__init__.py +8 -0
- kyber/agent/context.py +224 -0
- kyber/agent/loop.py +687 -0
- kyber/agent/memory.py +109 -0
- kyber/agent/skills.py +244 -0
- kyber/agent/subagent.py +379 -0
- kyber/agent/tools/__init__.py +6 -0
- kyber/agent/tools/base.py +102 -0
- kyber/agent/tools/filesystem.py +191 -0
- kyber/agent/tools/message.py +86 -0
- kyber/agent/tools/registry.py +73 -0
- kyber/agent/tools/shell.py +141 -0
- kyber/agent/tools/spawn.py +65 -0
- kyber/agent/tools/task_status.py +53 -0
- kyber/agent/tools/web.py +163 -0
- kyber/bridge/package.json +26 -0
- kyber/bridge/src/index.ts +50 -0
- kyber/bridge/src/server.ts +104 -0
- kyber/bridge/src/types.d.ts +3 -0
- kyber/bridge/src/whatsapp.ts +185 -0
- kyber/bridge/tsconfig.json +16 -0
- kyber/bus/__init__.py +6 -0
- kyber/bus/events.py +37 -0
- kyber/bus/queue.py +81 -0
- kyber/channels/__init__.py +6 -0
- kyber/channels/base.py +121 -0
- kyber/channels/discord.py +304 -0
- kyber/channels/feishu.py +263 -0
- kyber/channels/manager.py +161 -0
- kyber/channels/telegram.py +302 -0
- kyber/channels/whatsapp.py +141 -0
- kyber/cli/__init__.py +1 -0
- kyber/cli/commands.py +736 -0
- kyber/config/__init__.py +6 -0
- kyber/config/loader.py +95 -0
- kyber/config/schema.py +205 -0
- kyber/cron/__init__.py +6 -0
- kyber/cron/service.py +346 -0
- kyber/cron/types.py +59 -0
- kyber/dashboard/__init__.py +5 -0
- kyber/dashboard/server.py +122 -0
- kyber/dashboard/static/app.js +458 -0
- kyber/dashboard/static/favicon.png +0 -0
- kyber/dashboard/static/index.html +107 -0
- kyber/dashboard/static/kyber_logo.png +0 -0
- kyber/dashboard/static/styles.css +608 -0
- kyber/heartbeat/__init__.py +5 -0
- kyber/heartbeat/service.py +130 -0
- kyber/providers/__init__.py +6 -0
- kyber/providers/base.py +69 -0
- kyber/providers/litellm_provider.py +227 -0
- kyber/providers/transcription.py +65 -0
- kyber/session/__init__.py +5 -0
- kyber/session/manager.py +202 -0
- kyber/skills/README.md +47 -0
- kyber/skills/github/SKILL.md +48 -0
- kyber/skills/skill-creator/SKILL.md +371 -0
- kyber/skills/summarize/SKILL.md +67 -0
- kyber/skills/tmux/SKILL.md +121 -0
- kyber/skills/tmux/scripts/find-sessions.sh +112 -0
- kyber/skills/tmux/scripts/wait-for-text.sh +83 -0
- kyber/skills/weather/SKILL.md +49 -0
- kyber/utils/__init__.py +5 -0
- kyber/utils/helpers.py +91 -0
- kyber_chat-1.0.0.dist-info/METADATA +35 -0
- kyber_chat-1.0.0.dist-info/RECORD +71 -0
- kyber_chat-1.0.0.dist-info/WHEEL +4 -0
- kyber_chat-1.0.0.dist-info/entry_points.txt +2 -0
- 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,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
|