2026-02-28 10:38:38 +00:00
<!DOCTYPE html>
< html lang = "en" >
< head >
< script >
// Apply theme before paint to avoid flash
(function(){
var s = localStorage.getItem('picoclaw-theme') || 'system';
var t = s === 'system' ? (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light') : s;
document.documentElement.setAttribute('data-theme', t);
})();
< / script >
< meta charset = "UTF-8" >
< meta name = "viewport" content = "width=device-width, initial-scale=1.0" >
< title > PicoClaw Config< / title >
< meta name = "description" content = "PicoClaw configuration editor" >
< link rel = "preconnect" href = "https://fonts.googleapis.com" >
< link rel = "preconnect" href = "https://fonts.gstatic.com" crossorigin >
< link href = "https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap"
rel="stylesheet">
< style >
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
:root {
--radius: 12px;
--transition: 200ms cubic-bezier(0.4, 0, 0.2, 1);
--sidebar-w: 220px;
}
/* Dark theme (default) */
[data-theme="dark"] {
--bg-primary: #0f1117;
--bg-secondary: #161822;
--bg-elevated: #1c1f2e;
--bg-editor: #12141e;
--border: #2a2d3e;
--border-focus: #6366f1;
--text-primary: #e2e8f0;
--text-secondary: #94a3b8;
--text-muted: #64748b;
--accent: #6366f1;
--accent-hover: #818cf8;
--accent-glow: rgba(99, 102, 241, 0.15);
--success: #22c55e;
--success-bg: rgba(34, 197, 94, 0.1);
--error: #ef4444;
--error-bg: rgba(239, 68, 68, 0.1);
--warning: #f59e0b;
--warning-bg: rgba(245, 158, 11, 0.1);
--link: #818cf8;
}
/* Light theme */
[data-theme="light"] {
--bg-primary: #f8f9fb;
--bg-secondary: #ffffff;
--bg-elevated: #f1f3f5;
--bg-editor: #ffffff;
--border: #d5d9e0;
--border-focus: #6366f1;
--text-primary: #1a1d2e;
--text-secondary: #4b5563;
--text-muted: #8892a4;
--accent: #6366f1;
--accent-hover: #4f46e5;
--accent-glow: rgba(99, 102, 241, 0.12);
--success: #16a34a;
--success-bg: rgba(22, 163, 74, 0.08);
--error: #dc2626;
--error-bg: rgba(220, 38, 38, 0.08);
--warning: #d97706;
--warning-bg: rgba(217, 119, 6, 0.08);
--link: #4f46e5;
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
min-height: 100vh;
display: flex;
flex-direction: column;
}
/* ── Header ─────────────────────────────────── */
.header {
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
padding: 12px 24px;
display: flex;
align-items: center;
justify-content: space-between;
position: sticky;
top: 0;
z-index: 100;
backdrop-filter: blur(12px);
gap: 16px;
}
.header-left {
display: flex;
align-items: center;
gap: 12px;
flex-shrink: 0;
}
.logo {
width: 32px; height: 32px;
background: linear-gradient(135deg, var(--accent), #a78bfa);
border-radius: 8px;
display: flex; align-items: center; justify-content: center;
font-size: 16px; font-weight: 700; color: #fff;
box-shadow: 0 2px 12px var(--accent-glow);
}
.header h1 {
font-size: 16px; font-weight: 600; letter-spacing: -0.02em; white-space: nowrap;
}
.header h1 span { color: var(--text-muted); font-weight: 400; margin-left: 6px; font-size: 13px; }
.header-center { flex: 1; display: flex; justify-content: center; align-items: center; }
.header-right { display: flex; align-items: center; gap: 10px; flex-shrink: 0; }
/* Language switcher */
.lang-btn {
font-family: 'Inter', sans-serif; font-size: 12px; font-weight: 500;
padding: 5px 10px; border-radius: 6px;
border: 1px solid var(--border); background: var(--bg-elevated);
color: var(--text-secondary); cursor: pointer;
transition: all var(--transition);
}
.lang-btn:hover { border-color: var(--text-muted); color: var(--text-primary); }
/* Buttons */
.btn {
font-family: 'Inter', sans-serif; font-size: 13px; font-weight: 500;
padding: 7px 16px; border-radius: 8px;
border: 1px solid var(--border); background: var(--bg-elevated);
color: var(--text-primary); cursor: pointer;
transition: all var(--transition);
display: inline-flex; align-items: center; gap: 6px; white-space: nowrap;
}
.btn:hover { border-color: var(--text-muted); background: var(--bg-secondary); transform: translateY(-1px); }
.btn:active { transform: translateY(0); }
.btn-primary { background: var(--accent); border-color: var(--accent); color: #fff; box-shadow: 0 2px 8px var(--accent-glow); }
.btn-primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
.btn-sm { padding: 5px 12px; font-size: 12px; }
.btn-danger { border-color: rgba(239, 68, 68, 0.3); color: var(--error); }
.btn-danger:hover { background: var(--error-bg); border-color: var(--error); }
.btn-success { border-color: rgba(34, 197, 94, 0.3); color: var(--success); }
.btn-success:hover { background: var(--success-bg); border-color: var(--success); }
.btn-run { background: var(--success); border-color: var(--success); color: #fff; }
.btn-run:hover { background: #16a34a; }
.btn-process { padding: 8px 28px; font-size: 14px; font-weight: 600; border-radius: 10px; letter-spacing: 0.01em; }
.btn-process .btn-spinner {
width: 14px; height: 14px; border: 2px solid rgba(255,255,255,0.3);
border-top-color: #fff; border-radius: 50%;
animation: spin 0.7s linear infinite; display: inline-block;
}
.process-hint { font-size: 12px; color: var(--text-muted); margin-left: 10px; white-space: nowrap; }
.btn-stop { background: var(--error); border-color: var(--error); color: #fff; }
.btn-stop:hover { background: #dc2626; }
.btn:disabled { opacity: 0.5; cursor: not-allowed; transform: none !important; }
.btn .icon { font-size: 14px; }
/* ── Toast notifications ────────────────────── */
.toast-container {
position: fixed; top: 60px; left: 50%; transform: translateX(-50%);
z-index: 300; display: flex; flex-direction: column; align-items: center; gap: 8px;
pointer-events: none;
}
.toast {
font-size: 13px; font-weight: 500; padding: 10px 20px;
border-radius: 10px; display: inline-flex; align-items: center; gap: 6px;
pointer-events: auto; box-shadow: 0 4px 20px rgba(0,0,0,0.25);
animation: toastIn 300ms ease-out;
transition: opacity 300ms ease;
}
.toast.success { background: var(--success-bg); color: var(--success); border: 1px solid rgba(34, 197, 94, 0.3); backdrop-filter: blur(8px); }
.toast.error { background: var(--error-bg); color: var(--error); border: 1px solid rgba(239, 68, 68, 0.3); backdrop-filter: blur(8px); }
.toast.fade-out { opacity: 0; }
@keyframes toastIn { from { opacity: 0; transform: translateY(-12px); } to { opacity: 1; transform: translateY(0); } }
/* ── Layout ──────────────────────────────────── */
.main { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
/* Settings layout */
.settings-layout { display: flex; flex: 1; overflow: hidden; }
/* Sidebar */
.sidebar {
width: var(--sidebar-w); flex-shrink: 0;
background: var(--bg-secondary); border-right: 1px solid var(--border);
overflow-y: auto; padding: 12px 0;
}
.sidebar-group { margin-bottom: 4px; }
.sidebar-group-title {
font-size: 11px; font-weight: 600; text-transform: uppercase;
letter-spacing: 0.06em; color: var(--text-muted);
padding: 8px 16px 4px; cursor: pointer;
display: flex; align-items: center; justify-content: space-between;
user-select: none;
}
.sidebar-group-title:hover { color: var(--text-secondary); }
.sidebar-group-title .arrow {
font-size: 10px; transition: transform var(--transition);
}
.sidebar-group.collapsed .arrow { transform: rotate(-90deg); }
.sidebar-group.collapsed .sidebar-items { display: none; }
.sidebar-item {
font-size: 13px; padding: 6px 16px 6px 28px;
color: var(--text-secondary); cursor: pointer;
transition: all var(--transition); border-left: 2px solid transparent;
}
.sidebar-item:hover { background: var(--bg-elevated); color: var(--text-primary); }
.sidebar-item.active {
background: var(--accent-glow); color: var(--accent-hover);
border-left-color: var(--accent);
}
.sidebar-divider { height: 1px; background: var(--border); margin: 8px 16px; }
.sidebar-item.standalone {
padding-left: 16px; font-weight: 500; color: var(--text-muted);
}
/* Content area */
.content { flex: 1; overflow-y: auto; padding: 24px; }
.content-panel { display: none; }
.content-panel.active { display: block; }
.panel-title { font-size: 18px; font-weight: 600; margin-bottom: 4px; }
.panel-desc { font-size: 13px; color: var(--text-muted); margin-bottom: 20px; }
.panel-desc a.doc-link {
color: var(--link, var(--accent-hover)); font-weight: 500;
text-decoration: none; border-bottom: 1px dashed var(--link, var(--accent-hover));
padding-bottom: 1px; transition: opacity var(--transition);
}
.panel-desc a.doc-link:hover { opacity: 0.8; }
/* Theme toggle */
.theme-btn {
font-family: 'Inter', sans-serif; font-size: 14px;
width: 32px; height: 32px; border-radius: 6px;
border: 1px solid var(--border); background: var(--bg-elevated);
color: var(--text-secondary); cursor: pointer;
transition: all var(--transition);
display: flex; align-items: center; justify-content: center;
line-height: 1;
}
.theme-btn:hover { border-color: var(--text-muted); color: var(--text-primary); }
/* ── Model cards ─────────────────────────────── */
.model-grid {
display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 12px; margin-bottom: 16px;
}
.model-card {
background: var(--bg-secondary); border: 1px solid var(--border);
border-radius: var(--radius); padding: 16px;
transition: border-color var(--transition), opacity var(--transition);
position: relative;
}
.model-card:hover { border-color: var(--text-muted); }
.model-card.unavailable { opacity: 0.45; }
.model-card.unavailable:hover { opacity: 0.65; }
.model-card-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px; }
.model-name { font-size: 14px; font-weight: 600; display: flex; align-items: center; gap: 8px; }
.model-protocol {
font-size: 11px; font-family: 'JetBrains Mono', monospace;
color: var(--text-muted); background: var(--bg-elevated);
padding: 2px 6px; border-radius: 4px;
}
.badge-primary {
font-size: 10px; font-weight: 600; padding: 2px 8px; border-radius: 20px;
background: var(--accent-glow); color: var(--accent-hover);
border: 1px solid rgba(99, 102, 241, 0.3); text-transform: uppercase;
}
.badge-nokey {
font-size: 10px; font-weight: 600; padding: 2px 8px; border-radius: 20px;
background: var(--bg-elevated); color: var(--text-muted);
border: 1px solid var(--border);
}
.model-detail { font-size: 12px; color: var(--text-muted); margin-bottom: 3px; word-break: break-all; }
.model-detail strong { color: var(--text-secondary); font-weight: 500; }
.model-actions { margin-top: 12px; display: flex; gap: 6px; flex-wrap: wrap; }
/* ── Model edit modal ────────────────────────── */
.modal-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,0.6);
z-index: 200; display: flex; align-items: center; justify-content: center;
opacity: 0; pointer-events: none; transition: opacity var(--transition);
}
.modal-overlay.active { opacity: 1; pointer-events: auto; }
.modal {
background: var(--bg-secondary); border: 1px solid var(--border);
border-radius: var(--radius); padding: 24px; width: 480px; max-width: 90vw;
max-height: 80vh; overflow-y: auto;
}
.modal-title { font-size: 16px; font-weight: 600; margin-bottom: 16px; }
.modal-actions { margin-top: 20px; display: flex; gap: 8px; justify-content: flex-end; }
/* Collapsible optional fields */
.collapsible-header {
font-size: 12px; font-weight: 500; color: var(--text-muted);
cursor: pointer; user-select: none;
display: flex; align-items: center; gap: 6px;
padding: 8px 0; margin-top: 4px;
border-top: 1px solid var(--border);
}
.collapsible-header:hover { color: var(--text-secondary); }
.collapsible-header .arrow {
font-size: 10px; transition: transform var(--transition);
}
.collapsible-header.open .arrow { transform: rotate(90deg); }
.collapsible-body { display: none; }
.collapsible-body.open { display: block; }
/* ── Form fields ─────────────────────────────── */
.form-group { margin-bottom: 16px; }
.form-label {
font-size: 12px; font-weight: 500; color: var(--text-secondary);
margin-bottom: 6px; display: block;
}
.form-input {
width: 100%; font-family: 'JetBrains Mono', monospace; font-size: 13px;
padding: 8px 12px; border-radius: 8px;
border: 1px solid var(--border); background: var(--bg-editor);
color: var(--text-primary); outline: none;
transition: border-color var(--transition);
}
.form-input:focus { border-color: var(--border-focus); }
.form-input::placeholder { color: var(--text-muted); }
.form-input-number { width: 120px; }
.form-hint { font-size: 11px; color: var(--text-muted); margin-top: 4px; }
/* Toggle switch */
.toggle-row { display: flex; align-items: center; gap: 10px; margin-bottom: 16px; }
.toggle {
width: 40px; height: 22px; border-radius: 11px;
background: var(--border); cursor: pointer;
position: relative; transition: background var(--transition);
flex-shrink: 0;
}
.toggle.on { background: var(--accent); }
.toggle::after {
content: ''; position: absolute; top: 3px; left: 3px;
width: 16px; height: 16px; border-radius: 50%;
background: #fff; transition: transform var(--transition);
}
.toggle.on::after { transform: translateX(18px); }
.toggle-label { font-size: 13px; font-weight: 500; }
/* Array editor */
.array-editor { display: flex; flex-direction: column; gap: 6px; }
.array-row { display: flex; gap: 6px; align-items: center; }
.array-row .form-input { flex: 1; }
.array-add { font-size: 12px; color: var(--accent); cursor: pointer; padding: 4px 0; }
.array-add:hover { color: var(--accent-hover); }
/* Channel form */
.channel-form { max-width: 560px; }
.form-section-title {
font-size: 13px; font-weight: 600; color: var(--text-secondary);
margin: 20px 0 12px; padding-bottom: 6px;
border-bottom: 1px solid var(--border);
}
.form-section-title:first-child { margin-top: 0; }
/* ── Auth panel ──────────────────────────────── */
.provider-grid {
display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 14px;
}
.provider-card {
background: var(--bg-secondary); border: 1px solid var(--border);
border-radius: var(--radius); padding: 18px;
transition: border-color var(--transition);
}
.provider-card:hover { border-color: var(--text-muted); }
.provider-card-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; }
.provider-name { font-size: 14px; font-weight: 600; display: flex; align-items: center; gap: 8px; }
.provider-badge {
font-size: 10px; font-weight: 600; padding: 2px 8px; border-radius: 20px;
text-transform: uppercase; letter-spacing: 0.05em;
}
.badge-active { background: var(--success-bg); color: var(--success); border: 1px solid rgba(34, 197, 94, 0.2); }
.badge-expired { background: var(--error-bg); color: var(--error); border: 1px solid rgba(239, 68, 68, 0.2); }
.badge-pending { background: var(--warning-bg); color: var(--warning); border: 1px solid rgba(245, 158, 11, 0.2); }
.badge-none { background: var(--bg-elevated); color: var(--text-muted); border: 1px solid var(--border); }
.provider-detail { font-size: 12px; color: var(--text-muted); margin-bottom: 4px; }
.provider-detail strong { color: var(--text-secondary); font-weight: 500; }
.provider-actions { margin-top: 12px; display: flex; gap: 8px; }
.device-code-box {
background: var(--bg-elevated); border: 1px solid var(--border);
border-radius: 8px; padding: 14px; margin-top: 10px; text-align: center;
}
.device-code-box .code {
font-family: 'JetBrains Mono', monospace; font-size: 22px; font-weight: 700;
color: var(--accent-hover); letter-spacing: 0.1em; margin: 8px 0;
}
.device-code-box .url { font-size: 12px; color: var(--text-secondary); word-break: break-all; }
.device-code-box .url a { color: var(--accent-hover); text-decoration: none; }
.device-code-box .url a:hover { text-decoration: underline; }
.device-code-box .hint { font-size: 11px; color: var(--text-muted); margin-top: 8px; }
.token-input-group { display: flex; gap: 8px; margin-top: 10px; }
.token-input-group input {
flex: 1; font-family: 'JetBrains Mono', monospace; font-size: 12px;
padding: 8px 12px; border-radius: 8px; border: 1px solid var(--border);
background: var(--bg-editor); color: var(--text-primary); outline: none;
transition: border-color var(--transition);
}
.token-input-group input:focus { border-color: var(--border-focus); }
/* ── Raw JSON editor ─────────────────────────── */
.editor-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; }
.editor-label { font-size: 12px; font-weight: 500; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.06em; }
.file-path {
font-family: 'JetBrains Mono', monospace; font-size: 12px; color: var(--text-muted);
background: var(--bg-elevated); padding: 4px 10px; border-radius: 6px; border: 1px solid var(--border);
}
.editor-toolbar { display: flex; gap: 8px; margin-bottom: 12px; }
.editor-wrapper {
flex: 1; position: relative; border: 1px solid var(--border);
border-radius: var(--radius); overflow: hidden;
transition: border-color var(--transition), box-shadow var(--transition);
min-height: 400px;
}
.editor-wrapper:focus-within { border-color: var(--border-focus); box-shadow: 0 0 0 3px var(--accent-glow); }
.editor-wrapper.error { border-color: var(--error); box-shadow: 0 0 0 3px var(--error-bg); }
#editor {
width: 100%; height: 100%; min-height: 400px; resize: vertical;
background: var(--bg-editor); color: var(--text-primary);
font-family: 'JetBrains Mono', monospace; font-size: 13px; line-height: 1.65;
padding: 16px 20px; border: none; outline: none;
tab-size: 2; white-space: pre; overflow: auto;
}
.loading-overlay {
position: absolute; inset: 0; background: rgba(15, 17, 23, 0.85);
display: flex; align-items: center; justify-content: center;
z-index: 10; backdrop-filter: blur(4px);
opacity: 0; pointer-events: none; transition: opacity var(--transition);
}
.loading-overlay.active { opacity: 1; pointer-events: auto; }
.spinner {
width: 28px; height: 28px; border: 3px solid var(--border);
border-top-color: var(--accent); border-radius: 50%;
animation: spin 0.7s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* ── Log panel ──────────────────────────────── */
.log-toolbar {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 12px; gap: 8px;
}
.log-toolbar-right { display: flex; align-items: center; gap: 12px; }
.log-autoscroll { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--text-secondary); cursor: pointer; user-select: none; }
.log-autoscroll input { accent-color: var(--accent); cursor: pointer; }
.log-container {
background: var(--bg-editor); border: 1px solid var(--border);
border-radius: var(--radius); overflow: hidden; position: relative;
min-height: 300px; max-height: calc(100vh - 260px);
display: flex; flex-direction: column;
}
.log-output {
flex: 1; overflow-y: auto; padding: 16px 20px;
font-family: 'JetBrains Mono', monospace; font-size: 12px; line-height: 1.7;
color: var(--text-primary); white-space: pre-wrap; word-break: break-all;
margin: 0;
}
.log-placeholder {
color: var(--text-muted); font-style: italic;
font-family: 'Inter', sans-serif; font-size: 13px;
}
/* ── Footer ──────────────────────────────────── */
.footer {
padding: 10px 24px; border-top: 1px solid var(--border);
font-size: 12px; color: var(--text-muted);
display: flex; align-items: center; justify-content: space-between;
flex-shrink: 0;
}
/* ── Responsive ──────────────────────────────── */
@media (max-width: 768px) {
:root { --sidebar-w: 180px; }
.header { padding: 10px 16px; flex-wrap: wrap; gap: 8px; }
.header h1 span { display: none; }
.content { padding: 16px; }
}
@media (max-width: 640px) {
.settings-layout { flex-direction: column; }
.sidebar {
width: 100%; border-right: none; border-bottom: 1px solid var(--border);
max-height: 200px; padding: 8px 0;
}
.model-grid { grid-template-columns: 1fr; }
.provider-grid { grid-template-columns: 1fr; }
}
< / style >
< / head >
< body >
< header class = "header" >
< div class = "header-left" >
< div class = "logo" > P< / div >
< h1 > PicoClaw < span > Config< / span > < / h1 >
< / div >
< div class = "header-center" >
< button class = "btn btn-run btn-process" id = "btnRunStop" disabled >
< span class = "icon" id = "btnRunStopIcon" > ▶ < / span > < span id = "btnRunStopText" data-i18n = "start" > Start< / span >
< / button >
< span class = "process-hint" id = "processHint" > < / span >
< / div >
< div class = "header-right" >
< button class = "theme-btn" id = "btnTheme" onclick = "cycleTheme()" title = "Toggle theme" > < / button >
< button class = "lang-btn" id = "btnLang" onclick = "toggleLang()" > EN< / button >
< / div >
< / header >
< div class = "toast-container" id = "toastContainer" > < / div >
< main class = "main" >
< div class = "settings-layout" >
<!-- Sidebar -->
< nav class = "sidebar" id = "sidebar" >
< div class = "sidebar-group" data-group = "providers" >
< div class = "sidebar-group-title" onclick = "toggleGroup(this)" >
< span data-i18n = "sidebar.providers" > Providers< / span > < span class = "arrow" > ▾ < / span >
< / div >
< div class = "sidebar-items" >
< div class = "sidebar-item active" data-panel = "panelModels" data-i18n = "sidebar.models" > Models< / div >
< div class = "sidebar-item" data-panel = "panelAuth" data-i18n = "sidebar.auth" > Auth< / div >
< / div >
< / div >
< div class = "sidebar-group" data-group = "channels" >
< div class = "sidebar-group-title" onclick = "toggleGroup(this)" >
< span data-i18n = "sidebar.channels" > Channels< / span > < span class = "arrow" > ▾ < / span >
< / div >
< div class = "sidebar-items" >
< div class = "sidebar-item" data-panel = "panelCh_telegram" > Telegram< / div >
< div class = "sidebar-item" data-panel = "panelCh_discord" > Discord< / div >
< div class = "sidebar-item" data-panel = "panelCh_slack" > Slack< / div >
< div class = "sidebar-item" data-panel = "panelCh_wecom" > WeCom< / div >
< div class = "sidebar-item" data-panel = "panelCh_wecom_app" > WeCom App< / div >
< div class = "sidebar-item" data-panel = "panelCh_dingtalk" > DingTalk< / div >
< div class = "sidebar-item" data-panel = "panelCh_feishu" data-i18n = "sidebar.feishu" > Feishu< / div >
< div class = "sidebar-item" data-panel = "panelCh_line" > LINE< / div >
< div class = "sidebar-item" data-panel = "panelCh_whatsapp" > WhatsApp< / div >
< div class = "sidebar-item" data-panel = "panelCh_qq" > QQ< / div >
< div class = "sidebar-item" data-panel = "panelCh_onebot" > OneBot< / div >
< div class = "sidebar-item" data-panel = "panelCh_maixcam" > MaixCAM< / div >
< / div >
< / div >
< div class = "sidebar-divider" > < / div >
< div class = "sidebar-item standalone" data-panel = "panelLogs" data-i18n = "sidebar.logs" > Logs< / div >
< div class = "sidebar-item standalone" data-panel = "panelRawJson" data-i18n = "sidebar.rawjson" > Raw JSON< / div >
< / nav >
<!-- Content -->
< div class = "content" id = "contentArea" >
< div class = "content-panel active" id = "panelModels" >
< div class = "panel-title" data-i18n = "models.title" > Models< / div >
< div class = "panel-desc" data-i18n = "models.desc" > Manage LLM model configurations. Models without an API key are grayed out. Only available models can be set as primary.< / div >
< div style = "margin-bottom: 14px;" >
< button class = "btn btn-sm btn-primary" onclick = "showAddModelModal()" data-i18n = "models.add" > + Add Model< / button >
< / div >
< div class = "model-grid" id = "modelGrid" > < / div >
< / div >
< div class = "content-panel" id = "panelAuth" >
< div class = "panel-title" data-i18n = "auth.title" > Provider Authentication< / div >
< div class = "panel-desc" id = "authDesc" > < / div >
< div class = "provider-grid" id = "providerGrid" >
< div class = "provider-card" id = "card-openai" >
< div class = "provider-card-header" >
< span class = "provider-name" > OpenAI< / span >
< span class = "provider-badge badge-none" id = "badge-openai" data-i18n = "auth.notLoggedIn" > Not logged in< / span >
< / div >
< div id = "details-openai" > < / div >
< div class = "provider-actions" id = "actions-openai" >
< button class = "btn btn-sm btn-primary" onclick = "loginProvider('openai')" data-i18n = "auth.loginDevice" > Login (Device Code)< / button >
< / div >
< / div >
< div class = "provider-card" id = "card-anthropic" >
< div class = "provider-card-header" >
< span class = "provider-name" > Anthropic< / span >
< span class = "provider-badge badge-none" id = "badge-anthropic" data-i18n = "auth.notLoggedIn" > Not logged in< / span >
< / div >
< div id = "details-anthropic" > < / div >
< div class = "provider-actions" id = "actions-anthropic" >
< button class = "btn btn-sm btn-primary" onclick = "showTokenInput('anthropic')" data-i18n = "auth.loginToken" > Login (API Token)< / button >
< / div >
< / div >
< div class = "provider-card" id = "card-google-antigravity" >
< div class = "provider-card-header" >
< span class = "provider-name" > Google Antigravity< / span >
< span class = "provider-badge badge-none" id = "badge-google-antigravity" data-i18n = "auth.notLoggedIn" > Not logged in< / span >
< / div >
< div id = "details-google-antigravity" > < / div >
< div class = "provider-actions" id = "actions-google-antigravity" >
< button class = "btn btn-sm btn-primary" onclick = "loginProvider('google-antigravity')" data-i18n = "auth.loginOAuth" > Login (Browser OAuth)< / button >
< / div >
< / div >
< / div >
< / div >
<!-- Channel Panels -->
< div class = "content-panel" id = "panelCh_telegram" > < / div >
< div class = "content-panel" id = "panelCh_discord" > < / div >
< div class = "content-panel" id = "panelCh_slack" > < / div >
< div class = "content-panel" id = "panelCh_wecom" > < / div >
< div class = "content-panel" id = "panelCh_wecom_app" > < / div >
< div class = "content-panel" id = "panelCh_dingtalk" > < / div >
< div class = "content-panel" id = "panelCh_feishu" > < / div >
< div class = "content-panel" id = "panelCh_line" > < / div >
< div class = "content-panel" id = "panelCh_whatsapp" > < / div >
< div class = "content-panel" id = "panelCh_qq" > < / div >
< div class = "content-panel" id = "panelCh_onebot" > < / div >
< div class = "content-panel" id = "panelCh_maixcam" > < / div >
<!-- Logs Panel -->
< div class = "content-panel" id = "panelLogs" >
< div class = "panel-title" data-i18n = "logs.title" > Gateway Logs< / div >
< div class = "panel-desc" data-i18n = "logs.desc" > Real-time output from the gateway process.< / div >
< div class = "log-toolbar" >
< div >
< button class = "btn btn-sm" onclick = "clearLogDisplay()" data-i18n = "logs.clear" > Clear Display< / button >
< / div >
< div class = "log-toolbar-right" >
< label class = "log-autoscroll" >
< input type = "checkbox" id = "logAutoScrollCb" checked onchange = "logAutoScrollEnabled=this.checked" >
< span data-i18n = "logs.autoScroll" > Auto-scroll< / span >
< / label >
< / div >
< / div >
< div class = "log-container" >
< pre class = "log-output" id = "logOutput" > < span class = "log-placeholder" id = "logPlaceholder" data-i18n = "logs.noLogs" > No logs available. Start the gateway to see output here.< / span > < / pre >
< / div >
< / div >
<!-- Raw JSON Panel -->
< div class = "content-panel" id = "panelRawJson" >
< div class = "panel-title" data-i18n = "raw.title" > Raw JSON< / div >
< div class = "panel-desc" data-i18n = "raw.desc" > Directly edit the configuration file.< / div >
< div class = "editor-header" >
< span class = "editor-label" > config.json< / span >
< span class = "file-path" id = "filePath" > -< / span >
< / div >
< div class = "editor-toolbar" >
< button class = "btn btn-sm" id = "btnReload" onclick = "loadConfig()" >
< span class = "icon" > ↻ < / span > < span data-i18n = "raw.reload" > Reload< / span >
< / button >
< button class = "btn btn-sm" id = "btnFormat" onclick = "formatJson()" >
< span class = "icon" > { }< / span > < span data-i18n = "raw.format" > Format< / span >
< / button >
< button class = "btn btn-sm btn-primary" id = "btnSave" onclick = "saveRawConfig()" data-i18n = "save" > Save< / button >
< / div >
< div class = "editor-wrapper" id = "editorWrapper" >
< textarea id = "editor" spellcheck = "false" > < / textarea >
< div class = "loading-overlay" id = "loading" >
< div class = "spinner" > < / div >
< / div >
< / div >
< / div >
< / div >
< / div >
< / main >
<!-- Model Edit Modal -->
< div class = "modal-overlay" id = "modelModal" >
< div class = "modal" >
< div class = "modal-title" id = "modalTitle" > < / div >
< div id = "modalBody" > < / div >
< div class = "modal-actions" >
< button class = "btn btn-sm" onclick = "closeModelModal()" data-i18n = "cancel" > Cancel< / button >
< button class = "btn btn-sm btn-primary" id = "modalSaveBtn" onclick = "saveModelFromModal()" data-i18n = "save" > Save< / button >
< / div >
< / div >
< / div >
< footer class = "footer" >
< span > PicoClaw Config< / span >
< span id = "jsonStatus" > -< / span >
< / footer >
< script >
// ── Theme ───────────────────────────────────────────
const themeIcons = { light: '\u2600\uFE0F', dark: '\uD83C\uDF19', system: '\uD83D\uDCBB' };
const themeOrder = ['system', 'light', 'dark'];
let currentThemeSetting = localStorage.getItem('picoclaw-theme') || 'system';
function getSystemTheme() {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
function applyTheme() {
const resolved = currentThemeSetting === 'system' ? getSystemTheme() : currentThemeSetting;
document.documentElement.setAttribute('data-theme', resolved);
const btn = document.getElementById('btnTheme');
if (btn) btn.textContent = themeIcons[currentThemeSetting];
}
function cycleTheme() {
const idx = themeOrder.indexOf(currentThemeSetting);
currentThemeSetting = themeOrder[(idx + 1) % themeOrder.length];
localStorage.setItem('picoclaw-theme', currentThemeSetting);
applyTheme();
}
// Listen for system theme changes
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
if (currentThemeSetting === 'system') applyTheme();
});
// Apply immediately to avoid flash
applyTheme();
// ── i18n ────────────────────────────────────────────
const i18nData = {
en: {
// Header & global
'start': 'Start',
'stop': 'Stop',
'save': 'Save',
'cancel': 'Cancel',
'edit': 'Edit',
'delete': 'Delete',
'enabled': 'Enabled',
'comingSoon': 'Coming soon',
// Sidebar
'sidebar.providers': 'Providers',
'sidebar.models': 'Models',
'sidebar.auth': 'Auth',
'sidebar.channels': 'Channels',
'sidebar.feishu': 'Feishu',
'sidebar.logs': 'Logs',
'sidebar.rawjson': 'Raw JSON',
// Models
'models.title': 'Models',
'models.desc': 'Manage LLM model configurations. Models without an API key are grayed out. Only available models can be set as primary.',
'models.add': '+ Add Custom Model',
'models.noModels': 'No models configured.',
'models.primary': 'Primary',
'models.noKey': 'No Key',
'models.setPrimary': 'Set Primary',
'models.editModel': 'Edit Model',
'models.addModel': 'Add Model',
'models.advancedOptions': 'Advanced Options',
'models.deleteConfirm': 'Delete model "{name}"?',
'models.requiredFields': 'Model Name and Model ID are required',
// Model fields
'field.modelName': 'Model Name',
'field.modelId': 'Model ID',
'field.modelIdHint': 'Format: protocol/model-id',
'field.apiKey': 'API Key',
'field.apiBase': 'API Base',
'field.proxy': 'Proxy',
'field.authMethod': 'Auth Method',
'field.connectMode': 'Connect Mode',
'field.workspace': 'Workspace',
'field.rpm': 'RPM Limit',
'field.requestTimeout': 'Request Timeout (s)',
// Auth
'auth.title': 'Provider Authentication',
'auth.desc': 'Login to providers using OAuth or API tokens. Credentials are stored locally in < code style = "font-family:\'JetBrains Mono\',monospace;font-size:12px;background:var(--bg-elevated);padding:2px 6px;border-radius:4px;" > ~/.picoclaw/auth.json< / code > .',
'auth.notLoggedIn': 'Not logged in',
'auth.active': 'Active',
'auth.expired': 'Expired',
'auth.needsRefresh': 'Needs Refresh',
'auth.authenticating': 'Authenticating...',
'auth.loginDevice': 'Login (Device Code)',
'auth.loginToken': 'Login (API Token)',
'auth.loginOAuth': 'Login (Browser OAuth)',
'auth.logout': 'Logout',
'auth.retry': 'Retry',
'auth.waiting': 'Waiting...',
'auth.pasteKey': 'Paste your API key here...',
'auth.step1': 'Step 1: Click the link below',
'auth.step2': 'Step 2: Enter this code',
'auth.step3': 'Step 3: Complete auth in browser, this page updates automatically',
'auth.method': 'Method',
'auth.email': 'Email',
'auth.account': 'Account',
'auth.project': 'Project',
'auth.expires': 'Expires',
// Channel
'ch.configure': 'Configure {name} channel settings.',
'ch.docLink': 'Configuration Guide',
'ch.accessControl': 'Access Control',
'ch.allowFrom': 'Allow From (User IDs)',
'ch.addItem': '+ Add',
'ch.mentionOnly': 'Mention Only',
'ch.mentionOnlyHint': 'Only respond when mentioned',
'ch.groupTrigger': 'Group Trigger Prefixes',
// Logs
'logs.title': 'Gateway Logs',
'logs.desc': 'Real-time output from the gateway process.',
'logs.clear': 'Clear Display',
'logs.autoScroll': 'Auto-scroll',
'logs.noLogs': 'No logs available. Start the gateway to see output here.',
'logs.noCapture': 'Logs are not available. The gateway was not started from this launcher.',
// Raw JSON
'raw.title': 'Raw JSON',
'raw.desc': 'Directly edit the configuration file.',
'raw.reload': 'Reload',
'raw.format': 'Format',
// Status messages
'status.configLoaded': 'Config loaded',
'status.configSaved': 'Config saved',
'status.loadFailed': 'Load failed',
'status.saveFailed': 'Save failed',
'status.formatted': 'Formatted',
'status.invalidJson': 'Invalid JSON, please fix before saving',
'status.jsonValid': 'JSON valid',
'status.jsonInvalid': 'JSON invalid',
'status.saved': '{name} saved',
'status.tokenEmpty': 'Token cannot be empty',
'status.tokenSaved': 'Token saved for {name}',
'status.loggedOut': 'Logged out from {name}',
'status.loginFailed': 'Login failed',
'status.logoutFailed': 'Logout failed',
'status.openingBrowser': 'Opening browser for authentication...',
'status.loginStarted': 'Login started...',
'status.loginSuccess': 'Login successful!',
// Process
'process.running': 'Service running',
'process.notRunning': 'Service not running',
'process.starting': 'Starting gateway...',
'process.started': 'Gateway started',
'process.startFailed': 'Failed to start gateway',
'process.stopping': 'Stopping gateway...',
'process.stopped': 'Gateway stopped',
'process.stopFailed': 'Failed to stop gateway',
'process.needModel': 'At least one model with API key is required',
'process.needChannel': 'At least one channel must be enabled',
'process.checkLogs': 'Check Logs for details',
'process.needBoth': 'Need model with API key and enabled channel to start',
},
zh: {
'start': '\u542F\u52A8',
'stop': '\u505C\u6B62',
'save': '\u4FDD\u5B58',
'cancel': '\u53D6\u6D88',
'edit': '\u7F16\u8F91',
'delete': '\u5220\u9664',
'enabled': '\u542F\u7528',
'comingSoon': '\u5373\u5C06\u63A8\u51FA',
'sidebar.providers': '\u63D0\u4F9B\u5546',
'sidebar.models': '\u6A21\u578B',
'sidebar.auth': '\u8BA4\u8BC1',
'sidebar.channels': '\u901A\u9053',
'sidebar.feishu': '\u98DE\u4E66',
'sidebar.logs': '\u65E5\u5FD7',
'sidebar.rawjson': '\u539F\u59CB JSON',
'models.title': '\u6A21\u578B',
'models.desc': '\u7BA1\u7406 LLM \u6A21\u578B\u914D\u7F6E\u3002\u6CA1\u6709 API Key \u7684\u6A21\u578B\u663E\u793A\u4E3A\u7070\u8272\u3002\u53EA\u6709\u53EF\u7528\u6A21\u578B\u624D\u80FD\u8BBE\u4E3A\u4E3B\u6A21\u578B\u3002',
'models.add': '+ \u6DFB\u52A0\u81EA\u5B9A\u4E49\u6A21\u578B',
'models.noModels': '\u6682\u65E0\u6A21\u578B\u914D\u7F6E\u3002',
'models.primary': '\u4E3B\u6A21\u578B',
'models.noKey': '\u65E0 Key',
'models.setPrimary': '\u8BBE\u4E3A\u4E3B\u6A21\u578B',
'models.editModel': '\u7F16\u8F91\u6A21\u578B',
'models.addModel': '\u6DFB\u52A0\u6A21\u578B',
'models.advancedOptions': '\u9AD8\u7EA7\u9009\u9879',
'models.deleteConfirm': '\u786E\u5B9A\u5220\u9664\u6A21\u578B\u201C{name}\u201D\uFF1F',
'models.requiredFields': '\u6A21\u578B\u540D\u79F0\u548C\u6A21\u578B ID \u4E3A\u5FC5\u586B\u9879',
'field.modelName': '\u6A21\u578B\u540D\u79F0',
'field.modelId': '\u6A21\u578B ID',
'field.modelIdHint': '\u683C\u5F0F\uFF1A\u534F\u8BAE/\u6A21\u578B\u6807\u8BC6',
'field.apiKey': 'API Key',
'field.apiBase': 'API \u5730\u5740',
'field.proxy': '\u4EE3\u7406',
'field.authMethod': '\u8BA4\u8BC1\u65B9\u5F0F',
'field.connectMode': '\u8FDE\u63A5\u6A21\u5F0F',
'field.workspace': '\u5DE5\u4F5C\u533A',
'field.rpm': 'RPM \u9650\u5236',
'field.requestTimeout': '\u8BF7\u6C42\u8D85\u65F6 (\u79D2)',
'auth.title': '\u63D0\u4F9B\u5546\u8BA4\u8BC1',
'auth.desc': '\u901A\u8FC7 OAuth \u6216 API Token \u767B\u5F55\u63D0\u4F9B\u5546\u3002\u51ED\u8BC1\u5B58\u50A8\u5728\u672C\u5730 < code style = "font-family:\'JetBrains Mono\',monospace;font-size:12px;background:var(--bg-elevated);padding:2px 6px;border-radius:4px;" > ~/.picoclaw/auth.json< / code > \u3002',
'auth.notLoggedIn': '\u672A\u767B\u5F55',
'auth.active': '\u5DF2\u6FC0\u6D3B',
'auth.expired': '\u5DF2\u8FC7\u671F',
'auth.needsRefresh': '\u9700\u8981\u5237\u65B0',
'auth.authenticating': '\u8BA4\u8BC1\u4E2D...',
'auth.loginDevice': '\u767B\u5F55 (\u8BBE\u5907\u7801)',
'auth.loginToken': '\u767B\u5F55 (API Token)',
'auth.loginOAuth': '\u767B\u5F55 (\u6D4F\u89C8\u5668 OAuth)',
'auth.logout': '\u767B\u51FA',
'auth.retry': '\u91CD\u8BD5',
'auth.waiting': '\u7B49\u5F85\u4E2D...',
'auth.pasteKey': '\u8BF7\u7C98\u8D34 API Key...',
'auth.step1': '\u7B2C 1 \u6B65\uFF1A\u70B9\u51FB\u4E0B\u65B9\u94FE\u63A5',
'auth.step2': '\u7B2C 2 \u6B65\uFF1A\u8F93\u5165\u4EE5\u4E0B\u4EE3\u7801',
'auth.step3': '\u7B2C 3 \u6B65\uFF1A\u5728\u6D4F\u89C8\u5668\u4E2D\u5B8C\u6210\u8BA4\u8BC1\uFF0C\u672C\u9875\u9762\u4F1A\u81EA\u52A8\u66F4\u65B0',
'auth.method': '\u65B9\u5F0F',
'auth.email': '\u90AE\u7BB1',
'auth.account': '\u8D26\u53F7',
'auth.project': '\u9879\u76EE',
'auth.expires': '\u8FC7\u671F\u65F6\u95F4',
'ch.configure': '\u914D\u7F6E {name} \u901A\u9053\u8BBE\u7F6E\u3002',
'ch.docLink': '\u914D\u7F6E\u6307\u5357',
'ch.accessControl': '\u8BBF\u95EE\u63A7\u5236',
'ch.allowFrom': '\u5141\u8BB8\u7684\u7528\u6237 ID',
'ch.addItem': '+ \u6DFB\u52A0',
'ch.mentionOnly': '\u4EC5\u63D0\u53CA\u65F6\u54CD\u5E94',
'ch.mentionOnlyHint': '\u53EA\u5728\u88AB @\u63D0\u53CA\u65F6\u56DE\u590D',
'ch.groupTrigger': '\u7FA4\u804A\u89E6\u53D1\u524D\u7F00',
'logs.title': 'Gateway \u65E5\u5FD7',
'logs.desc': 'Gateway \u8FDB\u7A0B\u7684\u5B9E\u65F6\u8F93\u51FA\u3002',
'logs.clear': '\u6E05\u9664\u663E\u793A',
'logs.autoScroll': '\u81EA\u52A8\u6EDA\u52A8',
'logs.noLogs': '\u6682\u65E0\u65E5\u5FD7\u3002\u542F\u52A8 Gateway \u540E\u53EF\u5728\u6B64\u67E5\u770B\u8F93\u51FA\u3002',
'logs.noCapture': '\u65E5\u5FD7\u4E0D\u53EF\u7528\u3002Gateway \u4E0D\u662F\u901A\u8FC7\u6B64\u542F\u52A8\u5668\u542F\u52A8\u7684\u3002',
'raw.title': '\u539F\u59CB JSON',
'raw.desc': '\u76F4\u63A5\u7F16\u8F91\u914D\u7F6E\u6587\u4EF6\u3002',
'raw.reload': '\u91CD\u65B0\u52A0\u8F7D',
'raw.format': '\u683C\u5F0F\u5316',
'status.configLoaded': '\u914D\u7F6E\u5DF2\u52A0\u8F7D',
'status.configSaved': '\u914D\u7F6E\u5DF2\u4FDD\u5B58',
'status.loadFailed': '\u52A0\u8F7D\u5931\u8D25',
'status.saveFailed': '\u4FDD\u5B58\u5931\u8D25',
'status.formatted': '\u5DF2\u683C\u5F0F\u5316',
'status.invalidJson': 'JSON \u683C\u5F0F\u65E0\u6548\uFF0C\u8BF7\u4FEE\u6B63\u540E\u518D\u4FDD\u5B58',
'status.jsonValid': 'JSON \u6709\u6548',
'status.jsonInvalid': 'JSON \u65E0\u6548',
'status.saved': '{name} \u5DF2\u4FDD\u5B58',
'status.tokenEmpty': 'Token \u4E0D\u80FD\u4E3A\u7A7A',
'status.tokenSaved': '\u5DF2\u4FDD\u5B58 {name} \u7684 Token',
'status.loggedOut': '\u5DF2\u4ECE {name} \u767B\u51FA',
'status.loginFailed': '\u767B\u5F55\u5931\u8D25',
'status.logoutFailed': '\u767B\u51FA\u5931\u8D25',
'status.openingBrowser': '\u6B63\u5728\u6253\u5F00\u6D4F\u89C8\u5668\u8FDB\u884C\u8BA4\u8BC1...',
'status.loginStarted': '\u767B\u5F55\u5DF2\u542F\u52A8...',
'status.loginSuccess': '\u767B\u5F55\u6210\u529F\uFF01',
'process.running': '\u670D\u52A1\u5DF2\u542F\u52A8',
'process.notRunning': '\u670D\u52A1\u672A\u542F\u52A8',
'process.starting': '\u6B63\u5728\u542F\u52A8 Gateway...',
'process.started': 'Gateway \u5DF2\u542F\u52A8',
'process.startFailed': 'Gateway \u542F\u52A8\u5931\u8D25',
'process.stopping': '\u6B63\u5728\u505C\u6B62 Gateway...',
'process.stopped': 'Gateway \u5DF2\u505C\u6B62',
'process.stopFailed': 'Gateway \u505C\u6B62\u5931\u8D25',
'process.needModel': '\u81F3\u5C11\u9700\u8981\u4E00\u4E2A\u914D\u7F6E\u4E86 API Key \u7684\u6A21\u578B',
'process.needChannel': '\u81F3\u5C11\u9700\u8981\u542F\u7528\u4E00\u4E2A\u901A\u9053',
'process.checkLogs': '\u8BF7\u67E5\u770B\u65E5\u5FD7\u4E86\u89E3\u8BE6\u60C5',
'process.needBoth': '\u9700\u8981\u914D\u7F6E\u6A21\u578B\u548C\u542F\u7528\u901A\u9053\u624D\u80FD\u542F\u52A8',
}
};
let currentLang = localStorage.getItem('picoclaw-lang') || (navigator.language.startsWith('zh') ? 'zh' : 'en');
function t(key, params) {
let s = (i18nData[currentLang] & & i18nData[currentLang][key]) || i18nData.en[key] || key;
if (params) {
Object.keys(params).forEach(k => { s = s.replace('{' + k + '}', params[k]); });
}
return s;
}
function applyI18n() {
document.querySelectorAll('[data-i18n]').forEach(el => {
const key = el.dataset.i18n;
const val = t(key);
if (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA') {
el.placeholder = val;
} else {
el.textContent = val;
}
});
document.getElementById('authDesc').innerHTML = t('auth.desc');
document.getElementById('btnLang').textContent = currentLang === 'en' ? 'EN' : '\u4E2D';
document.documentElement.lang = currentLang === 'zh' ? 'zh-CN' : 'en';
}
function toggleLang() {
currentLang = currentLang === 'en' ? 'zh' : 'en';
localStorage.setItem('picoclaw-lang', currentLang);
applyI18n();
// Re-render process button text
updateRunStopButton(gatewayRunning);
// Re-render dynamic panels
renderModels();
const activePanel = document.querySelector('.content-panel.active');
if (activePanel) {
const id = activePanel.id;
if (id === 'panelAuth') loadAuthStatus();
if (id.startsWith('panelCh_')) renderChannelForm(id.replace('panelCh_', ''));
}
}
// ── State ───────────────────────────────────────────
let configData = null;
let configPath = '';
let authPollTimer = null;
let editingModelIndex = -1;
// ── Channel schemas ─────────────────────────────────
const channelSchemas = {
telegram: {
title: 'Telegram', configKey: 'telegram', docSlug: 'telegram',
fields: [
{ key: 'token', label: 'Bot Token', type: 'password', placeholder: 'Telegram bot token from @BotFather' },
{ key: 'proxy', label: 'Proxy', type: 'text', placeholder: 'http://proxy:port' },
]
},
discord: {
title: 'Discord', configKey: 'discord', docSlug: 'discord',
fields: [
{ key: 'token', label: 'Bot Token', type: 'password', placeholder: 'Discord bot token' },
{ key: 'mention_only', label: 'ch.mentionOnly', type: 'toggle', hint: 'ch.mentionOnlyHint', i18nLabel: true },
]
},
slack: {
title: 'Slack', configKey: 'slack', docSlug: 'slack',
fields: [
{ key: 'bot_token', label: 'Bot Token', type: 'password', placeholder: 'xoxb-...' },
{ key: 'app_token', label: 'App Token', type: 'password', placeholder: 'xapp-...' },
]
},
wecom: {
title: 'WeCom (Bot)', configKey: 'wecom', docSlug: 'wecom-bot',
fields: [
{ key: 'token', label: 'Token', type: 'password', placeholder: 'Verification token' },
{ key: 'encoding_aes_key', label: 'Encoding AES Key', type: 'password', placeholder: '43-char AES key' },
{ key: 'webhook_url', label: 'Webhook URL', type: 'text', placeholder: 'https://qyapi.weixin.qq.com/...' },
{ key: 'webhook_host', label: 'Webhook Host', type: 'text', placeholder: '0.0.0.0' },
{ key: 'webhook_port', label: 'Webhook Port', type: 'number', placeholder: '18793' },
{ key: 'webhook_path', label: 'Webhook Path', type: 'text', placeholder: '/webhook/wecom' },
{ key: 'reply_timeout', label: 'Reply Timeout (s)', type: 'number', placeholder: '5' },
]
},
wecom_app: {
title: 'WeCom (App)', configKey: 'wecom_app', docSlug: 'wecom-app',
fields: [
{ key: 'corp_id', label: 'Corp ID', type: 'text', placeholder: 'Corporation ID' },
{ key: 'corp_secret', label: 'Corp Secret', type: 'password', placeholder: 'Corporation secret' },
{ key: 'agent_id', label: 'Agent ID', type: 'number', placeholder: 'Agent ID (number)' },
{ key: 'token', label: 'Token', type: 'password', placeholder: 'Verification token' },
{ key: 'encoding_aes_key', label: 'Encoding AES Key', type: 'password', placeholder: '43-char AES key' },
{ key: 'webhook_host', label: 'Webhook Host', type: 'text', placeholder: '0.0.0.0' },
{ key: 'webhook_port', label: 'Webhook Port', type: 'number', placeholder: '18792' },
{ key: 'webhook_path', label: 'Webhook Path', type: 'text', placeholder: '/webhook/wecom-app' },
{ key: 'reply_timeout', label: 'Reply Timeout (s)', type: 'number', placeholder: '5' },
]
},
dingtalk: {
title: 'DingTalk', configKey: 'dingtalk', docSlug: 'dingtalk',
fields: [
{ key: 'client_id', label: 'Client ID', type: 'text', placeholder: 'App key / Client ID' },
{ key: 'client_secret', label: 'Client Secret', type: 'password', placeholder: 'App secret' },
]
},
feishu: {
title: 'Feishu', configKey: 'feishu', docSlug: 'feishu',
fields: [
{ key: 'app_id', label: 'App ID', type: 'text', placeholder: 'Feishu app ID' },
{ key: 'app_secret', label: 'App Secret', type: 'password', placeholder: 'Feishu app secret' },
{ key: 'encrypt_key', label: 'Encrypt Key', type: 'password', placeholder: 'Event encrypt key' },
{ key: 'verification_token', label: 'Verification Token', type: 'password', placeholder: 'Event verification token' },
]
},
line: {
title: 'LINE', configKey: 'line', docSlug: 'line',
fields: [
{ key: 'channel_secret', label: 'Channel Secret', type: 'password', placeholder: 'LINE channel secret' },
{ key: 'channel_access_token', label: 'Channel Access Token', type: 'password', placeholder: 'LINE channel access token' },
{ key: 'webhook_host', label: 'Webhook Host', type: 'text', placeholder: '0.0.0.0' },
{ key: 'webhook_port', label: 'Webhook Port', type: 'number', placeholder: '18791' },
{ key: 'webhook_path', label: 'Webhook Path', type: 'text', placeholder: '/webhook/line' },
]
},
whatsapp: {
title: 'WhatsApp', configKey: 'whatsapp', docSlug: null,
fields: [
{ key: 'bridge_url', label: 'Bridge URL', type: 'text', placeholder: 'ws://localhost:3001' },
]
},
qq: {
title: 'QQ', configKey: 'qq', docSlug: 'qq',
fields: [
{ key: 'app_id', label: 'App ID', type: 'text', placeholder: 'QQ bot App ID' },
{ key: 'app_secret', label: 'App Secret', type: 'password', placeholder: 'QQ bot App Secret' },
]
},
onebot: {
title: 'OneBot', configKey: 'onebot', docSlug: 'onebot',
fields: [
{ key: 'ws_url', label: 'WebSocket URL', type: 'text', placeholder: 'ws://127.0.0.1:3001' },
{ key: 'access_token', label: 'Access Token', type: 'password', placeholder: 'Access token' },
{ key: 'reconnect_interval', label: 'Reconnect Interval (s)', type: 'number', placeholder: '5' },
{ key: 'group_trigger_prefix', label: 'ch.groupTrigger', type: 'array', placeholder: 'Trigger word', i18nLabel: true },
]
},
maixcam: {
title: 'MaixCAM', configKey: 'maixcam', docSlug: 'maixcam',
fields: [
{ key: 'host', label: 'Host', type: 'text', placeholder: '0.0.0.0' },
{ key: 'port', label: 'Port', type: 'number', placeholder: '18790' },
]
},
};
// ── Sidebar ─────────────────────────────────────────
function toggleGroup(el) {
el.parentElement.classList.toggle('collapsed');
}
document.querySelectorAll('.sidebar-item').forEach(item => {
item.addEventListener('click', () => {
document.querySelectorAll('.sidebar-item').forEach(i => i.classList.remove('active'));
item.classList.add('active');
const panelId = item.dataset.panel;
document.querySelectorAll('.content-panel').forEach(p => p.classList.remove('active'));
const panel = document.getElementById(panelId);
if (panel) panel.classList.add('active');
if (panelId === 'panelModels') renderModels();
if (panelId === 'panelAuth') loadAuthStatus();
if (panelId === 'panelRawJson') syncEditorFromConfig();
if (panelId.startsWith('panelCh_')) {
renderChannelForm(panelId.replace('panelCh_', ''));
}
});
});
// ── Status messages ─────────────────────────────────
function showStatus(text, type) {
const container = document.getElementById('toastContainer');
const toast = document.createElement('div');
toast.className = 'toast ' + type;
toast.textContent = (type === 'success' ? '\u2713 ' : '\u2717 ') + text;
container.appendChild(toast);
setTimeout(() => {
toast.classList.add('fade-out');
setTimeout(() => toast.remove(), 300);
}, 3000);
}
// ── Config API ──────────────────────────────────────
async function loadConfig() {
try {
const res = await fetch('/api/config');
if (!res.ok) throw new Error('HTTP ' + res.status + ': ' + (await res.text()));
const data = await res.json();
configData = data.config;
configPath = data.path || '';
document.getElementById('filePath').textContent = configPath || '-';
renderModels();
if (!pendingAction) updateRunStopButton(gatewayRunning);
} catch (e) {
showStatus(t('status.loadFailed') + ': ' + e.message, 'error');
}
}
async function saveConfig() {
if (!configData) return;
try {
const res = await fetch('/api/config', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(configData),
});
if (!res.ok) throw new Error('HTTP ' + res.status + ': ' + (await res.text()));
showStatus(t('status.configSaved'), 'success');
} catch (e) {
showStatus(t('status.saveFailed') + ': ' + e.message, 'error');
}
// Refresh start button state after config changes
if (!pendingAction) updateRunStopButton(gatewayRunning);
}
// ── Raw JSON editor ─────────────────────────────────
function syncEditorFromConfig() {
const editor = document.getElementById('editor');
if (configData) editor.value = JSON.stringify(configData, null, 2);
document.getElementById('filePath').textContent = configPath || '-';
validateJson();
}
async function saveRawConfig() {
const obj = validateJson();
if (obj === null) { showStatus(t('status.invalidJson'), 'error'); return; }
configData = obj;
await saveConfig();
document.getElementById('editor').value = JSON.stringify(configData, null, 2);
}
function formatJson() {
const obj = validateJson();
if (obj !== null) {
document.getElementById('editor').value = JSON.stringify(obj, null, 2);
showStatus(t('status.formatted'), 'success');
}
}
function validateJson() {
const editor = document.getElementById('editor');
const jsonStatus = document.getElementById('jsonStatus');
const editorWrap = document.getElementById('editorWrapper');
const val = editor.value.trim();
if (!val) { jsonStatus.textContent = '-'; editorWrap.classList.remove('error'); return null; }
try {
const obj = JSON.parse(val);
jsonStatus.textContent = '\u2713 ' + t('status.jsonValid');
jsonStatus.style.color = 'var(--success)';
editorWrap.classList.remove('error');
return obj;
} catch (e) {
jsonStatus.textContent = '\u2717 ' + t('status.jsonInvalid') + ': ' + e.message;
jsonStatus.style.color = 'var(--error)';
editorWrap.classList.add('error');
return null;
}
}
document.getElementById('editor').addEventListener('input', validateJson);
document.addEventListener('keydown', (e) => {
if ((e.ctrlKey || e.metaKey) & & e.key === 's') {
e.preventDefault();
if (document.getElementById('panelRawJson').classList.contains('active')) saveRawConfig();
else saveConfig();
}
});
// ── Models Panel ────────────────────────────────────
function renderModels() {
const grid = document.getElementById('modelGrid');
if (!configData || !configData.model_list) {
grid.innerHTML = `< div style = "color:var(--text-muted);font-size:13px;" > ${t('models.noModels')}< / div > `;
return;
}
const primaryModelName = (configData.agents & & configData.agents.defaults)
? (configData.agents.defaults.model_name || configData.agents.defaults.model || '')
: '';
// Build index array sorted: primary first, then the rest in original order
const indices = configData.model_list.map((_, i) => i);
indices.sort((a, b) => {
const ap = configData.model_list[a].model_name === primaryModelName ? 0 : 1;
const bp = configData.model_list[b].model_name === primaryModelName ? 0 : 1;
return ap !== bp ? ap - bp : a - b;
});
const isModelAvailable = isModelAvailableGlobal;
let html = '';
indices.forEach(idx => {
const m = configData.model_list[idx];
const available = isModelAvailable(m);
const isPrimary = m.model_name === primaryModelName;
const protocol = m.model ? m.model.split('/')[0] : '';
html += `< div class = "model-card ${available ? '' : 'unavailable'}" > `;
html += `< div class = "model-card-head" > `;
html += `< div class = "model-name" > ${esc(m.model_name)}`;
if (protocol) html += ` < span class = "model-protocol" > ${esc(protocol)}< / span > `;
html += `< / div > `;
if (isPrimary) html += `< span class = "badge-primary" > ${t('models.primary')}< / span > `;
else if (!available) html += `< span class = "badge-nokey" > ${t('models.noKey')}< / span > `;
html += `< / div > `;
html += `< div class = "model-detail" > < strong > Model:< / strong > ${esc(m.model || '-')}< / div > `;
if (m.api_base) html += `< div class = "model-detail" > < strong > API Base:< / strong > ${esc(m.api_base)}< / div > `;
if (m.api_key) html += `< div class = "model-detail" > < strong > API Key:< / strong > ${maskKey(m.api_key)}< / div > `;
if (m.auth_method) html += `< div class = "model-detail" > < strong > Auth:< / strong > ${esc(m.auth_method)}< / div > `;
if (m.proxy) html += `< div class = "model-detail" > < strong > Proxy:< / strong > ${esc(m.proxy)}< / div > `;
html += `< div class = "model-actions" > `;
html += `< button class = "btn btn-sm" onclick = "showEditModelModal(${idx})" > ${t('edit')}< / button > `;
if (available & & !isPrimary) {
html += `< button class = "btn btn-sm btn-success" onclick = "setPrimaryModel(${idx})" > ${t('models.setPrimary')}< / button > `;
}
html += `< button class = "btn btn-sm btn-danger" onclick = "deleteModel(${idx})" > ${t('delete')}< / button > `;
html += `< / div > < / div > `;
});
grid.innerHTML = html;
}
function setPrimaryModel(idx) {
if (!configData || !configData.model_list[idx]) return;
if (!configData.agents) configData.agents = {};
if (!configData.agents.defaults) configData.agents.defaults = {};
configData.agents.defaults.model_name = configData.model_list[idx].model_name;
saveConfig().then(renderModels);
}
function deleteModel(idx) {
if (!configData || !configData.model_list) return;
const name = configData.model_list[idx].model_name;
if (!confirm(t('models.deleteConfirm', { name }))) return;
configData.model_list.splice(idx, 1);
saveConfig().then(renderModels);
}
// ── Model Modal ─────────────────────────────────────
const modelFieldsRequired = [
{ key: 'model_name', labelKey: 'field.modelName', type: 'text', placeholder: 'e.g. gpt-4o', required: true },
{ key: 'model', labelKey: 'field.modelId', type: 'text', placeholder: 'e.g. openai/gpt-4o', required: true, hintKey: 'field.modelIdHint' },
{ key: 'api_key', labelKey: 'field.apiKey', type: 'password', placeholder: 'API key' },
{ key: 'api_base', labelKey: 'field.apiBase', type: 'text', placeholder: 'https://api.openai.com/v1' },
];
const modelFieldsOptional = [
{ key: 'proxy', labelKey: 'field.proxy', type: 'text', placeholder: 'http://proxy:port' },
{ key: 'auth_method', labelKey: 'field.authMethod', type: 'text', placeholder: 'oauth / token' },
{ key: 'connect_mode', labelKey: 'field.connectMode', type: 'text', placeholder: 'stdio / grpc' },
{ key: 'workspace', labelKey: 'field.workspace', type: 'text', placeholder: 'Workspace path' },
{ key: 'rpm', labelKey: 'field.rpm', type: 'number', placeholder: 'RPM' },
{ key: 'request_timeout', labelKey: 'field.requestTimeout', type: 'number', placeholder: 'Seconds' },
];
const modelFields = [...modelFieldsRequired, ...modelFieldsOptional];
function showEditModelModal(idx) {
editingModelIndex = idx;
const m = configData.model_list[idx];
document.getElementById('modalTitle').textContent = t('models.editModel') + ': ' + m.model_name;
renderModalBody(m);
document.getElementById('modelModal').classList.add('active');
}
function showAddModelModal() {
editingModelIndex = -1;
document.getElementById('modalTitle').textContent = t('models.addModel');
renderModalBody({});
document.getElementById('modelModal').classList.add('active');
}
function closeModelModal() {
document.getElementById('modelModal').classList.remove('active');
}
function renderModalBody(data) {
const hasOptionalValues = modelFieldsOptional.some(f => {
const v = data[f.key];
return v !== undefined & & v !== null & & v !== '' & & v !== 0;
});
function renderField(f) {
const val = data[f.key] !== undefined & & data[f.key] !== null ? data[f.key] : '';
const label = t(f.labelKey);
let h = `< div class = "form-group" > `;
h += `< label class = "form-label" > ${label}${f.required ? ' *' : ''}< / label > `;
h += `< input class = "form-input ${f.type === 'number' ? 'form-input-number' : ''}" ` ;
h += `type="${f.type === 'password' ? 'password' : f.type === 'number' ? 'number' : 'text'}" `;
h += `data-field="${f.key}" value="${esc(String(val))}" placeholder="${f.placeholder || ''}">`;
if (f.hintKey) h += `< div class = "form-hint" > ${t(f.hintKey)}< / div > `;
h += `< / div > `;
return h;
}
let html = '';
modelFieldsRequired.forEach(f => { html += renderField(f); });
html += `< div class = "collapsible-header" onclick = "this.classList.toggle('open');this.nextElementSibling.classList.toggle('open')" > `;
html += `< span class = "arrow" > ▸ < / span > ${t('models.advancedOptions')}`;
html += `< / div > `;
html += `< div class = "collapsible-body" > `;
modelFieldsOptional.forEach(f => { html += renderField(f); });
html += `< / div > `;
document.getElementById('modalBody').innerHTML = html;
}
function saveModelFromModal() {
const inputs = document.querySelectorAll('#modalBody input[data-field]');
const obj = {};
inputs.forEach(input => {
const key = input.dataset.field;
let val = input.value.trim();
if (input.type === 'number' & & val) val = parseInt(val, 10) || 0;
if (val !== '' & & val !== 0) obj[key] = val;
else if (key === 'model_name' || key === 'model') obj[key] = val;
});
if (!obj.model_name || !obj.model) {
showStatus(t('models.requiredFields'), 'error');
return;
}
if (!configData.model_list) configData.model_list = [];
if (editingModelIndex >= 0) {
configData.model_list[editingModelIndex] = { ...configData.model_list[editingModelIndex], ...obj };
modelFields.forEach(f => {
if (!f.required & & (obj[f.key] === '' || obj[f.key] === 0)) {
delete configData.model_list[editingModelIndex][f.key];
}
});
} else {
configData.model_list.push(obj);
// Auto-set as primary model
if (!configData.agents) configData.agents = {};
if (!configData.agents.defaults) configData.agents.defaults = {};
configData.agents.defaults.model_name = obj.model_name;
}
closeModelModal();
saveConfig().then(renderModels);
}
2026-03-06 10:41:22 +00:00
2026-02-28 10:38:38 +00:00
// ── Channel Forms ───────────────────────────────────
function renderChannelForm(chKey) {
const schema = channelSchemas[chKey];
if (!schema) return;
const panel = document.getElementById('panelCh_' + chKey);
const chData = (configData & & configData.channels & & configData.channels[schema.configKey]) || {};
let html = `< div class = "panel-title" > ${schema.title}< / div > `;
html += `< div class = "panel-desc" > ${t('ch.configure', { name: schema.title })}`;
if (schema.docSlug) {
const docBase = currentLang === 'zh' ? 'https://docs.picoclaw.io/zh-Hans/docs/channels/' : 'https://docs.picoclaw.io/docs/channels/';
html += ` < a class = "doc-link" href = "${docBase}${schema.docSlug}" target = "_blank" rel = "noopener noreferrer" > \u{1F4D6} ${t('ch.docLink')}< / a > `;
}
html += `< / div > `;
html += `< div class = "channel-form" id = "chForm_${chKey}" > `;
// Enabled toggle
html += `< div class = "toggle-row" > `;
html += `< div class = "toggle ${chData.enabled ? 'on' : ''}" id = "chToggle_${chKey}" onclick = "toggleChannelEnabled('${chKey}', this)" > < / div > `;
html += `< span class = "toggle-label" > ${t('enabled')}< / span > `;
html += `< / div > `;
schema.fields.forEach(f => {
const label = f.i18nLabel ? t(f.label) : f.label;
if (f.type === 'toggle') {
const hint = f.i18nLabel & & f.hint ? t(f.hint) : (f.hint || '');
html += `< div class = "toggle-row" > `;
html += `< div class = "toggle ${chData[f.key] ? 'on' : ''}" data-chfield = "${f.key}" onclick = "this.classList.toggle('on')" > < / div > `;
html += `< span class = "toggle-label" > ${label}< / span > `;
if (hint) html += `< span class = "form-hint" style = "margin-left:8px;" > ${hint}< / span > `;
html += `< / div > `;
} else if (f.type === 'array') {
const arr = chData[f.key] || [];
html += `< div class = "form-group" > `;
html += `< label class = "form-label" > ${label}< / label > `;
html += `< div class = "array-editor" data-chfield = "${f.key}" data-placeholder = "${f.placeholder || ''}" > `;
arr.forEach(v => {
html += `< div class = "array-row" > `;
html += `< input class = "form-input" type = "text" value = "${esc(String(v))}" placeholder = "${f.placeholder || ''}" > `;
html += `< button class = "btn btn-sm btn-danger" onclick = "removeArrayRow(this)" > × < / button > `;
html += `< / div > `;
});
html += `< div class = "array-add" onclick = "addArrayRow(this.parentElement)" > ${t('ch.addItem')}< / div > `;
html += `< / div > < / div > `;
} else {
const val = chData[f.key] !== undefined & & chData[f.key] !== null ? chData[f.key] : '';
html += `< div class = "form-group" > `;
html += `< label class = "form-label" > ${label}< / label > `;
html += `< input class = "form-input ${f.type === 'number' ? 'form-input-number' : ''}" ` ;
html += `type="${f.type === 'password' ? 'password' : f.type === 'number' ? 'number' : 'text'}" `;
html += `data-chfield="${f.key}" value="${esc(String(val))}" placeholder="${f.placeholder || ''}">`;
html += `< / div > `;
}
});
// Allow from
const allowFrom = chData.allow_from || [];
html += `< div class = "form-section-title" > ${t('ch.accessControl')}< / div > `;
html += `< div class = "form-group" > `;
html += `< label class = "form-label" > ${t('ch.allowFrom')}< / label > `;
html += `< div class = "array-editor" data-chfield = "allow_from" data-placeholder = "User / Chat ID" > `;
allowFrom.forEach(v => {
html += `< div class = "array-row" > `;
html += `< input class = "form-input" type = "text" value = "${esc(String(v))}" placeholder = "User / Chat ID" > `;
html += `< button class = "btn btn-sm btn-danger" onclick = "removeArrayRow(this)" > × < / button > `;
html += `< / div > `;
});
html += `< div class = "array-add" onclick = "addArrayRow(this.parentElement)" > ${t('ch.addItem')}< / div > `;
html += `< / div > < / div > `;
html += `< div style = "margin-top:20px;" > `;
html += `< button class = "btn btn-primary" onclick = "saveChannelForm('${chKey}')" > ${t('save')}< / button > `;
html += `< / div > < / div > `;
panel.innerHTML = html;
}
function toggleChannelEnabled(chKey, el) { el.classList.toggle('on'); }
function addArrayRow(container) {
const placeholder = container.dataset.placeholder || '';
const addBtn = container.querySelector('.array-add');
const row = document.createElement('div');
row.className = 'array-row';
row.innerHTML = `< input class = "form-input" type = "text" value = "" placeholder = "${placeholder}" > ` +
`< button class = "btn btn-sm btn-danger" onclick = "removeArrayRow(this)" > × < / button > `;
container.insertBefore(row, addBtn);
row.querySelector('input').focus();
}
function removeArrayRow(btn) { btn.parentElement.remove(); }
function saveChannelForm(chKey) {
const schema = channelSchemas[chKey];
if (!schema || !configData) return;
if (!configData.channels) configData.channels = {};
const chObj = configData.channels[schema.configKey] || {};
const toggle = document.getElementById('chToggle_' + chKey);
chObj.enabled = toggle ? toggle.classList.contains('on') : false;
const form = document.getElementById('chForm_' + chKey);
schema.fields.forEach(f => {
if (f.type === 'toggle') {
const el = form.querySelector(`[data-chfield="${f.key}"].toggle`);
if (el) chObj[f.key] = el.classList.contains('on');
} else if (f.type === 'array') {
const container = form.querySelector(`.array-editor[data-chfield="${f.key}"]`);
if (container) {
const vals = [];
container.querySelectorAll('.array-row input').forEach(input => {
const v = input.value.trim();
if (v) vals.push(v);
});
chObj[f.key] = vals;
}
} else {
const input = form.querySelector(`input[data-chfield="${f.key}"]`);
if (input) {
let val = input.value.trim();
if (f.type === 'number' & & val) {
val = parseInt(val, 10);
if (isNaN(val)) val = 0;
}
chObj[f.key] = val === '' ? (f.type === 'number' ? 0 : '') : val;
}
}
});
const afContainer = form.querySelector('.array-editor[data-chfield="allow_from"]');
if (afContainer) {
const vals = [];
afContainer.querySelectorAll('.array-row input').forEach(input => {
const v = input.value.trim();
if (v) vals.push(v);
});
chObj.allow_from = vals;
}
configData.channels[schema.configKey] = chObj;
saveConfig().then(() => showStatus(t('status.saved', { name: schema.title }), 'success'));
}
// ── Auth API ────────────────────────────────────────
let authProviderMap = {}; // { 'openai': { status: 'active', ... }, ... }
async function loadAuthStatus() {
try {
const res = await fetch('/api/auth/status');
if (!res.ok) return;
const data = await res.json();
const providers = data.providers || [];
authProviderMap = {};
providers.forEach(p => { authProviderMap[p.provider] = p; });
renderAuthStatus(providers, data.pending_device);
} catch (e) {
console.error('Failed to load auth status:', e);
}
}
function renderAuthStatus(providersList, pendingDevice) {
const providerMap = {};
providersList.forEach(p => { providerMap[p.provider] = p; });
['openai', 'anthropic', 'google-antigravity'].forEach(name => {
const badge = document.getElementById('badge-' + name);
const details = document.getElementById('details-' + name);
const actions = document.getElementById('actions-' + name);
const p = providerMap[name];
if (p) {
const badgeClass = p.status === 'active' ? 'badge-active' :
p.status === 'expired' ? 'badge-expired' : 'badge-pending';
const badgeText = p.status === 'active' ? t('auth.active') :
p.status === 'expired' ? t('auth.expired') : t('auth.needsRefresh');
badge.className = 'provider-badge ' + badgeClass;
badge.textContent = badgeText;
let dh = '';
if (p.auth_method) dh += `< div class = "provider-detail" > < strong > ${t('auth.method')}:< / strong > ${p.auth_method}< / div > `;
if (p.email) dh += `< div class = "provider-detail" > < strong > ${t('auth.email')}:< / strong > ${p.email}< / div > `;
if (p.account_id) dh += `< div class = "provider-detail" > < strong > ${t('auth.account')}:< / strong > ${p.account_id}< / div > `;
if (p.project_id) dh += `< div class = "provider-detail" > < strong > ${t('auth.project')}:< / strong > ${p.project_id}< / div > `;
if (p.expires_at) {
const d = new Date(p.expires_at);
dh += `< div class = "provider-detail" > < strong > ${t('auth.expires')}:< / strong > ${d.toLocaleString()}< / div > `;
}
details.innerHTML = dh;
actions.innerHTML = `< button class = "btn btn-sm btn-danger" onclick = "logoutProvider('${name}')" > ${t('auth.logout')}< / button > `;
} else {
badge.className = 'provider-badge badge-none';
badge.textContent = t('auth.notLoggedIn');
details.innerHTML = '';
if (name === 'openai') {
actions.innerHTML = `< button class = "btn btn-sm btn-primary" onclick = "loginProvider('openai')" > ${t('auth.loginDevice')}< / button > `;
} else if (name === 'anthropic') {
actions.innerHTML = `< button class = "btn btn-sm btn-primary" onclick = "showTokenInput('anthropic')" > ${t('auth.loginToken')}< / button > `;
} else {
actions.innerHTML = `< button class = "btn btn-sm btn-primary" onclick = "loginProvider('google-antigravity')" > ${t('auth.loginOAuth')}< / button > `;
}
}
});
if (pendingDevice & & pendingDevice.status === 'pending') {
const name = pendingDevice.provider;
const badge = document.getElementById('badge-' + name);
const details = document.getElementById('details-' + name);
const actions = document.getElementById('actions-' + name);
if (badge) { badge.className = 'provider-badge badge-pending'; badge.textContent = t('auth.authenticating'); }
if (pendingDevice.device_url & & pendingDevice.user_code & & details) {
details.innerHTML = `
< div class = "device-code-box" >
< div class = "hint" style = "margin-bottom:8px;font-size:12px;" > ${t('auth.step1')}< / div >
< div class = "url" > < a href = "${pendingDevice.device_url}" target = "_blank" > ${pendingDevice.device_url} ↗ < / a > < / div >
< div class = "hint" style = "margin-top:10px;margin-bottom:4px;font-size:12px;" > ${t('auth.step2')}< / div >
< div class = "code" > ${pendingDevice.user_code}< / div >
< div class = "hint" > ${t('auth.step3')}< / div >
< / div > `;
}
if (pendingDevice.error & & details) {
details.innerHTML = `< div class = "provider-detail" style = "color:var(--error)" > ${pendingDevice.error}< / div > `;
if (actions) actions.innerHTML = `< button class = "btn btn-sm btn-primary" onclick = "loginProvider('${name}')" > ${t('auth.retry')}< / button > `;
} else if (actions) {
actions.innerHTML = `< button class = "btn btn-sm" disabled > < span class = "spinner" style = "width:14px;height:14px;border-width:2px;display:inline-block;vertical-align:middle;margin-right:6px;" > < / span > ${t('auth.waiting')}< / button > `;
}
startAuthPolling();
} else {
stopAuthPolling();
}
}
function startAuthPolling() {
stopAuthPolling();
authPollTimer = setInterval(() => loadAuthStatus(), 3000);
}
function stopAuthPolling() {
if (authPollTimer) { clearInterval(authPollTimer); authPollTimer = null; }
}
async function loginProvider(provider) {
const actions = document.getElementById('actions-' + provider);
const original = actions ? actions.innerHTML : '';
if (actions) actions.querySelectorAll('.btn').forEach(b => { b.disabled = true; b.style.opacity = '0.5'; });
try {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ provider }),
});
if (!res.ok) throw new Error(await res.text());
const data = await res.json();
if (data.status === 'redirect' & & data.auth_url) {
showStatus(t('status.openingBrowser'), 'success');
window.open(data.auth_url, '_blank');
if (actions) actions.innerHTML = original;
return;
}
if (data.status === 'pending') {
showStatus(data.message || t('status.loginStarted'), 'success');
if (data.device_url & & data.user_code) {
const badge = document.getElementById('badge-' + provider);
const details = document.getElementById('details-' + provider);
if (badge) { badge.className = 'provider-badge badge-pending'; badge.textContent = t('auth.authenticating'); }
if (details) {
details.innerHTML = `
< div class = "device-code-box" >
< div class = "hint" style = "margin-bottom:8px;font-size:12px;" > ${t('auth.step1')}< / div >
< div class = "url" > < a href = "${data.device_url}" target = "_blank" > ${data.device_url} ↗ < / a > < / div >
< div class = "hint" style = "margin-top:10px;margin-bottom:4px;font-size:12px;" > ${t('auth.step2')}< / div >
< div class = "code" > ${data.user_code}< / div >
< div class = "hint" > ${t('auth.step3')}< / div >
< / div > `;
}
if (actions) {
actions.innerHTML = `< button class = "btn btn-sm" disabled > < span class = "spinner" style = "width:14px;height:14px;border-width:2px;display:inline-block;vertical-align:middle;margin-right:6px;" > < / span > ${t('auth.waiting')}< / button > `;
}
}
startAuthPolling();
} else if (data.status === 'success') {
showStatus(data.message || t('status.loginSuccess'), 'success');
loadAuthStatus();
}
} catch (e) {
showStatus(t('status.loginFailed') + ': ' + e.message, 'error');
if (actions) actions.innerHTML = original;
}
}
function showTokenInput(provider) {
const actions = document.getElementById('actions-' + provider);
actions.innerHTML = `
< div class = "token-input-group" >
< input type = "password" id = "tokenInput-${provider}" placeholder = "${t('auth.pasteKey')}" / >
< button class = "btn btn-sm btn-primary" onclick = "submitToken('${provider}')" > ${t('save')}< / button >
< button class = "btn btn-sm" onclick = "loadAuthStatus()" > ${t('cancel')}< / button >
< / div > `;
document.getElementById('tokenInput-' + provider).focus();
}
async function submitToken(provider) {
const input = document.getElementById('tokenInput-' + provider);
const token = input.value.trim();
if (!token) { showStatus(t('status.tokenEmpty'), 'error'); return; }
try {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ provider, token }),
});
if (!res.ok) throw new Error(await res.text());
showStatus(t('status.tokenSaved', { name: provider }), 'success');
loadAuthStatus();
} catch (e) {
showStatus(t('status.loginFailed') + ': ' + e.message, 'error');
}
}
async function logoutProvider(provider) {
try {
const res = await fetch('/api/auth/logout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ provider }),
});
if (!res.ok) throw new Error(await res.text());
showStatus(t('status.loggedOut', { name: provider }), 'success');
loadAuthStatus();
} catch (e) {
showStatus(t('status.logoutFailed') + ': ' + e.message, 'error');
}
}
// ── Process API (Start/Stop/Status) ─────────────────
let gatewayRunning = false;
let processPolling = null;
let pendingAction = null; // { type: 'start'|'stop', retries: 0 }
const MAX_RETRIES = 10;
function isModelAvailableGlobal(m) {
if (m.api_key) return true;
if (m.auth_method === 'oauth') {
const protocol = m.model ? m.model.split('/')[0] : '';
const providerName = protocol === 'google-antigravity' ? 'google-antigravity' : protocol;
const authInfo = authProviderMap[providerName];
return !!(authInfo & & authInfo.status === 'active');
}
if (m.auth_method) return true;
return false;
}
function checkStartPrereqs() {
const primaryModelName = configData & & configData.agents & & configData.agents.defaults
? (configData.agents.defaults.model_name || '') : '';
const hasModel = !!(primaryModelName & & configData.model_list & &
configData.model_list.some(m => m.model_name === primaryModelName & & isModelAvailableGlobal(m)));
const hasChannel = configData & & configData.channels & &
Object.keys(channelSchemas).some(k => {
const cfg = configData.channels[channelSchemas[k].configKey];
return cfg & & cfg.enabled;
});
return { hasModel, hasChannel, canStart: hasModel & & hasChannel };
}
function updateRunStopButton(running) {
gatewayRunning = running;
const btn = document.getElementById('btnRunStop');
const icon = document.getElementById('btnRunStopIcon');
const text = document.getElementById('btnRunStopText');
const hint = document.getElementById('processHint');
if (running) {
btn.disabled = false;
btn.className = 'btn btn-stop btn-process';
icon.innerHTML = '■ ';
text.textContent = t('stop');
hint.textContent = '(' + t('process.running') + ')';
hint.style.color = '';
} else {
const prereqs = checkStartPrereqs();
btn.disabled = !prereqs.canStart;
btn.className = 'btn btn-run btn-process';
icon.innerHTML = '▶ ';
text.textContent = t('start');
if (!prereqs.canStart) {
const reason = !prereqs.hasModel & & !prereqs.hasChannel ? t('process.needBoth')
: !prereqs.hasModel ? t('process.needModel')
: t('process.needChannel');
hint.textContent = '(' + reason + ')';
hint.style.color = 'var(--danger, #e74c3c)';
} else {
hint.textContent = '(' + t('process.notRunning') + ')';
hint.style.color = '';
}
}
}
function setButtonLoading(actionType) {
const btn = document.getElementById('btnRunStop');
const icon = document.getElementById('btnRunStopIcon');
const text = document.getElementById('btnRunStopText');
const hint = document.getElementById('processHint');
btn.disabled = true;
btn.className = 'btn btn-process';
icon.innerHTML = '< span class = "btn-spinner" > < / span > ';
text.textContent = actionType === 'start' ? t('process.starting') : t('process.stopping');
hint.textContent = '';
}
async function checkProcessStatus() {
let running = false;
try {
const params = new URLSearchParams({ log_offset: logOffset, log_run_id: logRunID });
const res = await fetch('/api/process/status?' + params);
if (res.ok) {
const data = await res.json();
running = data.process_status === 'running';
handleLogData(data);
}
} catch (e) { /* treat as not running */ }
if (pendingAction) {
const expected = pendingAction.type === 'start';
if (running === expected) {
showStatus(expected ? t('process.started') : t('process.stopped'), 'success');
pendingAction = null;
restoreNormalPolling();
updateRunStopButton(running);
} else {
pendingAction.retries++;
if (pendingAction.retries >= MAX_RETRIES) {
let msg = pendingAction.type === 'start' ? t('process.startFailed') : t('process.stopFailed');
if (pendingAction.type === 'start') msg += ' — ' + t('process.checkLogs');
showStatus(msg, 'error');
pendingAction = null;
restoreNormalPolling();
updateRunStopButton(running);
}
}
} else {
updateRunStopButton(running);
}
}
function restoreNormalPolling() {
clearInterval(processPolling);
processPolling = setInterval(checkProcessStatus, 5000);
}
async function startGateway() {
const prereqs = checkStartPrereqs();
if (!prereqs.canStart) {
const reason = !prereqs.hasModel & & !prereqs.hasChannel ? t('process.needBoth')
: !prereqs.hasModel ? t('process.needModel')
: t('process.needChannel');
showStatus(reason, 'error');
return;
}
setButtonLoading('start');
// Set pending BEFORE fetch so polls don't overwrite loading state
pendingAction = { type: 'start', retries: 0 };
clearInterval(processPolling);
processPolling = setInterval(checkProcessStatus, 1000);
try {
const res = await fetch('/api/process/start', { method: 'POST' });
if (!res.ok) throw new Error(await res.text());
} catch (e) {
showStatus(t('process.startFailed') + ': ' + e.message + ' — ' + t('process.checkLogs'), 'error');
pendingAction = null;
restoreNormalPolling();
updateRunStopButton(false);
}
}
async function stopGateway() {
setButtonLoading('stop');
pendingAction = { type: 'stop', retries: 0 };
clearInterval(processPolling);
processPolling = setInterval(checkProcessStatus, 1000);
try {
const res = await fetch('/api/process/stop', { method: 'POST' });
if (!res.ok) throw new Error(await res.text());
} catch (e) {
showStatus(t('process.stopFailed') + ': ' + e.message, 'error');
pendingAction = null;
restoreNormalPolling();
updateRunStopButton(true);
}
}
document.getElementById('btnRunStop').addEventListener('click', () => {
if (gatewayRunning) { stopGateway(); } else { startGateway(); }
});
// Poll status every 5 seconds
checkProcessStatus();
processPolling = setInterval(checkProcessStatus, 5000);
// ── Log Panel ───────────────────────────────────────
let logOffset = 0;
let logRunID = -1;
let logAutoScrollEnabled = true;
let logHasContent = false;
function handleLogData(data) {
const output = document.getElementById('logOutput');
const placeholder = document.getElementById('logPlaceholder');
if (!output) return;
const serverRunID = data.log_run_id;
const serverSource = data.log_source;
const lines = data.logs || [];
const total = data.log_total || 0;
// External gateway (not launched by us) — show appropriate placeholder
if (serverSource === 'none') {
if (!logHasContent) {
if (placeholder) {
placeholder.textContent = gatewayRunning ? t('logs.noCapture') : t('logs.noLogs');
placeholder.style.display = '';
}
}
return;
}
// Gateway restarted — clear display
if (serverRunID !== logRunID & & logRunID !== -1) {
output.textContent = '';
logHasContent = false;
}
logRunID = serverRunID;
// Append new lines
if (lines.length > 0) {
// Hide placeholder
if (placeholder) placeholder.style.display = 'none';
// Remove placeholder node text if it's still the only content
if (!logHasContent) {
output.textContent = '';
}
output.textContent += lines.join('\n') + '\n';
logHasContent = true;
// Auto-scroll
if (logAutoScrollEnabled) {
output.scrollTop = output.scrollHeight;
}
}
logOffset = total;
// Show placeholder if we've never had content
if (!logHasContent & & placeholder) {
placeholder.textContent = t('logs.noLogs');
placeholder.style.display = '';
}
}
function clearLogDisplay() {
const output = document.getElementById('logOutput');
const placeholder = document.getElementById('logPlaceholder');
if (output) output.textContent = '';
logHasContent = false;
// Re-add placeholder
if (output & & placeholder) {
output.appendChild(placeholder);
placeholder.textContent = t('logs.noLogs');
placeholder.style.display = '';
}
// Keep logOffset/logRunID so we don't re-fetch old lines
}
// ── Utilities ───────────────────────────────────────
function esc(s) {
const div = document.createElement('div');
div.textContent = s;
return div.innerHTML;
}
function maskKey(key) {
if (!key || key.length < 8 ) return ' * * * * ' ;
return key.substring(0, 4) + '...' + key.substring(key.length - 4);
}
// ── Init ────────────────────────────────────────────
applyI18n();
loadConfig();
loadAuthStatus().then(() => renderModels());
if (window.location.hash === '#auth') {
document.querySelector('[data-panel="panelAuth"]').click();
window.location.hash = '';
}
< / script >
< / body >
< / html >