rebrand + purge orphaned frontend: Chat Switchboard → Switchboard Core
Branding: bulk rename across 50+ files (Go, JS, CSS, HTML, YAML, JSON, shell scripts). Fix Helm chart description and git repo URLs. Fix test helper taglines. Admin surface: strip 14 gutted tabs (providers, models, personas, roles, knowledge, memory, tasks, health, routing, capabilities, channels, dashboard, usage, stats). Collapse to 4 categories: People, Workflows, System, Monitoring. Settings surface: strip 11 gutted tabs (models, personas, providers, roles, knowledge, memory, usage, workflows, tasks, data, gitkeys). Keep: general, appearance, profile, teams, connections, notifications. Team-admin surface: strip 5 gutted tabs (personas, providers, knowledge, tasks, usage). Keep: members, groups, connections, workflows, settings, activity. Components: delete chat-pane/ (13 files, 1938 lines) and notes-pane/ (10 files, 2381 lines). main.go: remove stale Health.Prune maintenance goroutine. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,105 +0,0 @@
|
||||
/**
|
||||
* Admin > Routing > Capabilities
|
||||
*/
|
||||
const { html } = window;
|
||||
const { useState, useEffect, useCallback } = hooks;
|
||||
|
||||
const CAP_FIELDS = ['supports_vision', 'supports_tools', 'supports_thinking', 'context_window', 'max_output_tokens'];
|
||||
|
||||
export default function CapabilitiesSection() {
|
||||
const [overrides, setOverrides] = useState([]);
|
||||
const [models, setModels] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editing, setEditing] = useState(null); // model_id string or null
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const [o, m] = await Promise.all([
|
||||
sw.api.admin.models.overrides(),
|
||||
sw.api.admin.models.list().catch(() => []),
|
||||
]);
|
||||
setOverrides(o.data || []);
|
||||
setModels(m || []);
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
async function saveOverride(modelId, field, value) {
|
||||
try {
|
||||
await sw.api.admin.models.setCapability(modelId, { [field]: value });
|
||||
sw.toast('Override saved', 'success');
|
||||
load();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function deleteOverride(modelId, overrideId) {
|
||||
try {
|
||||
await sw.api.admin.models.delCapability(modelId, overrideId);
|
||||
sw.toast('Override removed', 'success');
|
||||
load();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
if (loading) return html`<div class="settings-placeholder">Loading\u2026</div>`;
|
||||
|
||||
return html`
|
||||
<div>
|
||||
<p class="text-muted" style="font-size:12px;margin-bottom:12px;">
|
||||
Override auto-detected model capabilities. ${overrides.length} active overrides.
|
||||
</p>
|
||||
|
||||
<div class="admin-list">
|
||||
${overrides.length === 0 && html`<div class="empty-hint">No capability overrides</div>`}
|
||||
${overrides.map(o => html`
|
||||
<div class="admin-surface-row" key=${o.id} style="flex-direction:column;align-items:stretch;">
|
||||
<div style="display:flex;align-items:center;gap:8px;">
|
||||
<strong style="flex:1;">${o.model_id || o.display_name || o.id}</strong>
|
||||
<button class="btn-small btn-danger" onClick=${() => deleteOverride(o.model_id, o.id)}>Remove</button>
|
||||
</div>
|
||||
<div class="text-muted" style="font-size:12px;margin-top:4px;">
|
||||
${Object.entries(o).filter(([k]) => CAP_FIELDS.includes(k) && o[k] != null).map(([k, v]) =>
|
||||
html`<span key=${k} class="badge" style="margin-right:4px;">${k}: ${String(v)}</span>`
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
|
||||
<div class="settings-section" style="margin-top:24px;">
|
||||
<h4>Add Override</h4>
|
||||
<div class="form-row">
|
||||
<div class="form-group"><label>Model</label>
|
||||
<select value=${editing || ''} onChange=${e => setEditing(e.target.value || null)}>
|
||||
<option value="">Select model\u2026</option>
|
||||
${models.map(m => html`<option key=${m.id} value=${m.model_id || m.id}>${m.display_name || m.model_id || m.id}</option>`)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
${editing && html`
|
||||
<div style="margin-top:8px;">
|
||||
${CAP_FIELDS.map(field => {
|
||||
const isBool = field.startsWith('supports_');
|
||||
return html`
|
||||
<div key=${field} class="form-row" style="margin-bottom:4px;align-items:center;">
|
||||
<span style="width:180px;font-size:12px;">${field}</span>
|
||||
${isBool
|
||||
? html`
|
||||
<button class="btn-small btn-primary" onClick=${() => saveOverride(editing, field, true)}>true</button>
|
||||
<button class="btn-small" onClick=${() => saveOverride(editing, field, false)}>false</button>
|
||||
`
|
||||
: html`
|
||||
<input type="number" style="width:120px;" placeholder="value"
|
||||
onKeyDown=${e => { if (e.key === 'Enter') saveOverride(editing, field, parseInt(e.target.value)); }} />
|
||||
`
|
||||
}
|
||||
</div>
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
/**
|
||||
* Admin > System > Channels (Archived + Retention)
|
||||
*/
|
||||
const { html } = window;
|
||||
const { useState, useEffect, useCallback } = hooks;
|
||||
|
||||
export default function ChannelsSection() {
|
||||
const [channels, setChannels] = useState([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [retentionTTL, setRetentionTTL] = useState(0);
|
||||
const [ttlDraft, setTTLDraft] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const data = await sw.api.admin.channels.archived();
|
||||
setChannels(data.data || []);
|
||||
setTotal(data.total || 0);
|
||||
const ttl = data.retention_ttl_days || 0;
|
||||
setRetentionTTL(ttl);
|
||||
setTTLDraft(String(ttl));
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
async function saveTTL() {
|
||||
const val = parseInt(ttlDraft, 10);
|
||||
if (isNaN(val) || val < 0) { sw.toast('TTL must be 0 or more', 'error'); return; }
|
||||
try {
|
||||
await sw.api.admin.settings.update('retention_ttl_days', { value: { value: val } });
|
||||
setRetentionTTL(val);
|
||||
sw.toast('Retention TTL saved', 'success');
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function purge(id, title) {
|
||||
const ok = await sw.confirm(`Permanently delete "${title || id}"? This cannot be undone.`, true);
|
||||
if (!ok) return;
|
||||
try {
|
||||
await sw.api.admin.channels.purge(id);
|
||||
sw.toast('Channel purged', 'success');
|
||||
load();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
if (loading) return html`<div class="settings-placeholder">Loading\u2026</div>`;
|
||||
|
||||
return html`
|
||||
<div>
|
||||
<div class="stat-cards-grid" style="margin-bottom:16px;">
|
||||
<div class="stat-card"><div class="stat-value">${total}</div><div class="stat-label">Archived</div></div>
|
||||
<div class="stat-card"><div class="stat-value">${retentionTTL || 'Off'}</div><div class="stat-label">Retention TTL (days)</div></div>
|
||||
</div>
|
||||
|
||||
<div class="admin-section" style="margin-bottom:20px;">
|
||||
<h4 style="margin:0 0 8px;">Retention Policy</h4>
|
||||
<p class="text-muted" style="font-size:12px;margin:0 0 8px;">
|
||||
Channels are archived on delete and purged after this many days.
|
||||
Only Personal (BYOK) provider channels are exempt. Set to 0 to disable retention.
|
||||
</p>
|
||||
<div style="display:flex;gap:8px;align-items:center;">
|
||||
<input type="number" min="0" value=${ttlDraft}
|
||||
onInput=${e => setTTLDraft(e.target.value)}
|
||||
style="width:80px;padding:6px 8px;background:var(--bg-2);color:var(--text);border:1px solid var(--border);border-radius:4px;"
|
||||
placeholder="0" />
|
||||
<span class="text-muted" style="font-size:12px;">days</span>
|
||||
<button class="btn-small" onClick=${saveTTL}
|
||||
disabled=${String(retentionTTL) === ttlDraft}>Save</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h4 style="margin:0 0 8px;">Archived Channels</h4>
|
||||
<div class="admin-list">
|
||||
${channels.length === 0 && html`<div class="empty-hint">No archived channels</div>`}
|
||||
${channels.map(c => html`
|
||||
<div class="admin-surface-row" key=${c.id}>
|
||||
<strong style="flex:1;">${c.title || c.id}</strong>
|
||||
<span class="text-muted" style="font-size:11px;">${c.archived_at ? new Date(c.archived_at).toLocaleDateString() : ''}</span>
|
||||
<button class="btn-small btn-danger" onClick=${() => purge(c.id, c.title)}>Purge</button>
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
/**
|
||||
* Admin > Monitoring > Dashboard
|
||||
*/
|
||||
const { html } = window;
|
||||
const { useState, useEffect, useCallback, useRef } = hooks;
|
||||
|
||||
export default function DashboardSection() {
|
||||
const [data, setData] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const timerRef = useRef(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const d = await sw.api.admin.dashboard();
|
||||
setData(d);
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
timerRef.current = setInterval(load, 10000);
|
||||
return () => clearInterval(timerRef.current);
|
||||
}, []);
|
||||
|
||||
function formatUptime(seconds) {
|
||||
if (!seconds) return '\u2014';
|
||||
const d = Math.floor(seconds / 86400);
|
||||
const h = Math.floor((seconds % 86400) / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
if (d > 0) return `${d}d ${h}h`;
|
||||
if (h > 0) return `${h}h ${m}m`;
|
||||
return `${m}m`;
|
||||
}
|
||||
|
||||
function formatBytes(b) {
|
||||
if (!b) return '0 B';
|
||||
if (b < 1048576) return (b / 1024).toFixed(0) + ' KB';
|
||||
if (b < 1073741824) return (b / 1048576).toFixed(1) + ' MB';
|
||||
return (b / 1073741824).toFixed(2) + ' GB';
|
||||
}
|
||||
|
||||
if (loading) return html`<div class="settings-placeholder">Loading\u2026</div>`;
|
||||
if (!data) return html`<div class="empty-hint">No dashboard data</div>`;
|
||||
|
||||
return html`
|
||||
<div>
|
||||
<p class="text-muted" style="font-size:12px;margin-bottom:12px;">Auto-refreshes every 10s</p>
|
||||
|
||||
<div class="stat-cards-grid" style="margin-bottom:16px;">
|
||||
<div class="stat-card"><div class="stat-value">${formatUptime(data.uptime_seconds)}</div><div class="stat-label">Uptime</div></div>
|
||||
<div class="stat-card"><div class="stat-value">${data.active_connections ?? '\u2014'}</div><div class="stat-label">Connections</div></div>
|
||||
<div class="stat-card"><div class="stat-value">${data.goroutines ?? '\u2014'}</div><div class="stat-label">Goroutines</div></div>
|
||||
<div class="stat-card"><div class="stat-value">${formatBytes(data.heap_alloc)}</div><div class="stat-label">Heap</div></div>
|
||||
</div>
|
||||
|
||||
${data.db_pool && html`
|
||||
<div class="stat-cards-grid" style="margin-bottom:16px;">
|
||||
<div class="stat-card"><div class="stat-value">${data.db_pool.open || 0}</div><div class="stat-label">DB Open</div></div>
|
||||
<div class="stat-card"><div class="stat-value">${data.db_pool.in_use || 0}</div><div class="stat-label">DB In Use</div></div>
|
||||
<div class="stat-card"><div class="stat-value">${data.db_pool.idle || 0}</div><div class="stat-label">DB Idle</div></div>
|
||||
</div>
|
||||
`}
|
||||
|
||||
${data.providers && data.providers.length > 0 && html`
|
||||
<h4 style="margin:16px 0 8px;">Provider Health</h4>
|
||||
<div class="stat-cards-grid">
|
||||
${data.providers.map(p => html`
|
||||
<div class="dashboard-provider-card" key=${p.name || p.id}>
|
||||
<div style="display:flex;align-items:center;gap:6px;margin-bottom:4px;">
|
||||
<strong style="font-size:13px;">${p.name || p.id}</strong>
|
||||
<span class="badge ${p.status === 'healthy' ? 'badge-active' : p.status === 'degraded' ? 'badge-pending' : 'badge-inactive'}">${p.status}</span>
|
||||
</div>
|
||||
<div style="font-size:11px;color:var(--text-3);">${Math.round(p.avg_latency_ms || 0)}ms avg</div>
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
`}
|
||||
|
||||
${data.recent_errors && data.recent_errors.length > 0 && html`
|
||||
<h4 style="margin:16px 0 8px;">Recent Errors</h4>
|
||||
<div class="admin-list">
|
||||
${data.recent_errors.map((e, i) => html`
|
||||
<div class="admin-surface-row" key=${i} style="font-size:12px;color:var(--danger);">${e}</div>
|
||||
`)}
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
/**
|
||||
* Admin > Routing > Health
|
||||
*/
|
||||
const { html } = window;
|
||||
const { useState, useEffect, useCallback, useRef } = hooks;
|
||||
|
||||
export default function HealthSection() {
|
||||
const [providers, setProviders] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const timerRef = useRef(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const data = await sw.api.admin.providers.health();
|
||||
setProviders(data || []);
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
timerRef.current = setInterval(load, 30000);
|
||||
return () => clearInterval(timerRef.current);
|
||||
}, []);
|
||||
|
||||
function statusClass(s) {
|
||||
if (s === 'healthy') return 'badge-active';
|
||||
if (s === 'degraded') return 'badge-pending';
|
||||
return 'badge-inactive';
|
||||
}
|
||||
|
||||
if (loading) return html`<div class="settings-placeholder">Loading\u2026</div>`;
|
||||
|
||||
return html`
|
||||
<div>
|
||||
<p class="text-muted" style="font-size:12px;margin-bottom:12px;">Auto-refreshes every 30s</p>
|
||||
<div class="stat-cards-grid">
|
||||
${providers.length === 0 && html`<div class="empty-hint">No providers</div>`}
|
||||
${providers.map(p => html`
|
||||
<div class="dashboard-provider-card" key=${p.provider_config_id}>
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;">
|
||||
<strong>${p.provider_config_name || p.provider_config_id}</strong>
|
||||
<span class="badge ${statusClass(p.status)}">${p.status}</span>
|
||||
</div>
|
||||
<div style="font-size:12px;color:var(--text-2);">
|
||||
<div>Error rate: ${((p.error_rate || 0) * 100).toFixed(1)}%</div>
|
||||
<div>Avg latency: ${Math.round(p.avg_latency_ms || 0)}ms</div>
|
||||
<div>Timeout rate: ${((p.timeout_rate || 0) * 100).toFixed(1)}%</div>
|
||||
<div>Rate limits: ${p.rate_limit_count || 0}</div>
|
||||
${p.last_error && html`<div style="color:var(--danger);margin-top:4px;font-size:11px;">Last error: ${p.last_error}</div>`}
|
||||
</div>
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -19,23 +19,17 @@ import { DialogStack } from '../../shell/dialog-stack.js';
|
||||
// ── Category → Section mapping ──────────────
|
||||
const ADMIN_SECTIONS = {
|
||||
people: ['users', 'teams', 'groups'],
|
||||
ai: ['providers', 'models', 'personas', 'roles', 'knowledgeBases', 'memory'],
|
||||
workflows: ['workflows', 'tasks'],
|
||||
routing: ['health', 'routing', 'capabilities'],
|
||||
system: ['settings', 'storage', 'packages', 'connections', 'channels', 'broadcast'],
|
||||
monitoring: ['dashboard', 'usage', 'audit', 'stats'],
|
||||
workflows: ['workflows'],
|
||||
system: ['settings', 'storage', 'packages', 'connections', 'broadcast'],
|
||||
monitoring: ['audit'],
|
||||
};
|
||||
|
||||
const ADMIN_LABELS = {
|
||||
users: 'Users', teams: 'Teams', groups: 'Groups',
|
||||
providers: 'Providers', models: 'Models', personas: 'Personas',
|
||||
roles: 'Roles', knowledgeBases: 'Knowledge', memory: 'Memory',
|
||||
health: 'Health', routing: 'Routing', capabilities: 'Capabilities',
|
||||
workflows: 'Workflows', tasks: 'Tasks',
|
||||
workflows: 'Workflows',
|
||||
settings: 'Settings', storage: 'Storage', packages: 'Packages',
|
||||
connections: 'Connections',
|
||||
channels: 'Channels', broadcast: 'Broadcast',
|
||||
dashboard: 'Dashboard', usage: 'Usage', audit: 'Audit', stats: 'Stats',
|
||||
connections: 'Connections', broadcast: 'Broadcast',
|
||||
audit: 'Audit',
|
||||
};
|
||||
|
||||
// ── v0.38.3: Extension config sections ──────
|
||||
@@ -54,11 +48,9 @@ for (const cs of _configSections) {
|
||||
|
||||
const CATEGORY_META = {
|
||||
people: { label: 'People', first: 'users', icon: 'M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2|C9 7 4' },
|
||||
ai: { label: 'AI', first: 'providers', icon: 'R4 4 16 16 2|L9 9 9.01 9|L15 9 15.01 9|M8 14s1.5 2 4 2 4-2 4-2' },
|
||||
workflows: { label: 'Workflows', first: 'workflows', icon: 'P16 3 21 3 21 8|L4 20 21 3|P21 16 21 21 16 21|L15 15 21 21|L4 4 9 9' },
|
||||
routing: { label: 'Routing', first: 'health', icon: 'G13 2 3 14 12 14 11 22 21 10 12 10 13 2' },
|
||||
system: { label: 'System', first: 'settings', icon: 'C12 12 3|M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09' },
|
||||
monitoring: { label: 'Monitoring', first: 'dashboard', icon: 'L18 20 18 10|L12 20 12 4|L6 20 6 14' },
|
||||
monitoring: { label: 'Monitoring', first: 'audit', icon: 'L18 20 18 10|L12 20 12 4|L6 20 6 14' },
|
||||
};
|
||||
|
||||
// ── Lazy section imports ────────────────────
|
||||
@@ -68,27 +60,13 @@ const sectionModules = {
|
||||
users: () => import(`./users.js${_v}`),
|
||||
teams: () => import(`./teams.js${_v}`),
|
||||
groups: () => import(`./groups.js${_v}`),
|
||||
providers: () => import(`./providers.js${_v}`),
|
||||
models: () => import(`./models.js${_v}`),
|
||||
personas: () => import(`./personas.js${_v}`),
|
||||
roles: () => import(`./roles.js${_v}`),
|
||||
knowledgeBases: () => import(`./knowledge.js${_v}`),
|
||||
memory: () => import(`./memory.js${_v}`),
|
||||
workflows: () => import(`./workflows.js${_v}`),
|
||||
tasks: () => import(`./tasks.js${_v}`),
|
||||
health: () => import(`./health.js${_v}`),
|
||||
routing: () => import(`./routing.js${_v}`),
|
||||
capabilities: () => import(`./capabilities.js${_v}`),
|
||||
settings: () => import(`./settings.js${_v}`),
|
||||
storage: () => import(`./storage.js${_v}`),
|
||||
packages: () => import(`./packages.js${_v}`),
|
||||
connections: () => import(`./connections.js${_v}`),
|
||||
channels: () => import(`./channels.js${_v}`),
|
||||
broadcast: () => import(`./broadcast.js${_v}`),
|
||||
dashboard: () => import(`./dashboard.js${_v}`),
|
||||
usage: () => import(`./usage.js${_v}`),
|
||||
audit: () => import(`./audit.js${_v}`),
|
||||
stats: () => import(`./stats.js${_v}`),
|
||||
};
|
||||
|
||||
// v0.38.3: Register dynamic section loaders for extension config sections
|
||||
|
||||
@@ -1,132 +0,0 @@
|
||||
/**
|
||||
* Admin > AI > Knowledge Bases
|
||||
*/
|
||||
const { html } = window;
|
||||
const { useState, useEffect, useCallback } = hooks;
|
||||
|
||||
export default function KnowledgeSection() {
|
||||
const [kbs, setKbs] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [detail, setDetail] = useState(null);
|
||||
const [docs, setDocs] = useState([]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const resp = await sw.api.knowledge.list();
|
||||
setKbs(resp.data || []);
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
async function openDetail(kb) {
|
||||
setDetail(kb);
|
||||
try {
|
||||
const d = await sw.api.knowledge.documents(kb.id);
|
||||
setDocs(d.data || []);
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function uploadDoc() {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = '.txt,.md,.csv,.html';
|
||||
input.onchange = async () => {
|
||||
const file = input.files[0];
|
||||
if (!file) return;
|
||||
try {
|
||||
await sw.api.knowledge.upload(detail.id, file);
|
||||
sw.toast('Document uploaded', 'success');
|
||||
const d = await sw.api.knowledge.documents(detail.id);
|
||||
setDocs(d.data || []);
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
};
|
||||
input.click();
|
||||
}
|
||||
|
||||
async function deleteDoc(docId) {
|
||||
const ok = await sw.confirm('Delete this document?', true);
|
||||
if (!ok) return;
|
||||
try {
|
||||
await sw.api.knowledge.delDoc(detail.id, docId);
|
||||
sw.toast('Document deleted', 'success');
|
||||
setDocs(prev => prev.filter(d => d.id !== docId));
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function deleteKb(id) {
|
||||
const ok = await sw.confirm('Delete this knowledge base and all documents?', true);
|
||||
if (!ok) return;
|
||||
try {
|
||||
await sw.api.knowledge.del(id);
|
||||
sw.toast('Knowledge base deleted', 'success');
|
||||
setDetail(null);
|
||||
load();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
function statusBadge(status) {
|
||||
const cls = status === 'ready' ? 'badge-active' : status === 'error' ? 'badge-inactive' : 'badge-pending';
|
||||
return html`<span class="badge ${cls}">${status}</span>`;
|
||||
}
|
||||
|
||||
function formatBytes(b) {
|
||||
if (!b) return '0 B';
|
||||
if (b < 1024) return b + ' B';
|
||||
if (b < 1048576) return (b / 1024).toFixed(1) + ' KB';
|
||||
return (b / 1048576).toFixed(1) + ' MB';
|
||||
}
|
||||
|
||||
if (loading) return html`<div class="settings-placeholder">Loading\u2026</div>`;
|
||||
|
||||
if (detail) return html`
|
||||
<div>
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;">
|
||||
<button class="btn-small" onClick=${() => setDetail(null)}>\u2190 Back</button>
|
||||
<h4 style="margin:0;">${detail.name}</h4>
|
||||
${statusBadge(detail.status || 'active')}
|
||||
<div style="flex:1"></div>
|
||||
<button class="btn-small" onClick=${uploadDoc}>Upload Document</button>
|
||||
<button class="btn-small btn-danger" onClick=${() => deleteKb(detail.id)}>Delete KB</button>
|
||||
</div>
|
||||
|
||||
<p class="text-muted" style="font-size:12px;margin-bottom:12px;">
|
||||
${detail.document_count || 0} docs \u2022 ${detail.chunk_count || 0} chunks \u2022 ${formatBytes(detail.total_bytes)}
|
||||
\u2022 scope: ${detail.scope || 'personal'}
|
||||
</p>
|
||||
|
||||
<div class="admin-list">
|
||||
${docs.length === 0 && html`<div class="empty-hint">No documents</div>`}
|
||||
${docs.map(d => html`
|
||||
<div class="admin-surface-row" key=${d.id}>
|
||||
<div style="flex:1;min-width:0;">
|
||||
<strong>${d.filename}</strong>
|
||||
<span class="text-muted" style="margin-left:8px;font-size:11px;">${formatBytes(d.size_bytes)}</span>
|
||||
${' '}${statusBadge(d.status || 'pending')}
|
||||
</div>
|
||||
<button class="btn-small btn-danger" onClick=${() => deleteDoc(d.id)}>Delete</button>
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
return html`
|
||||
<div>
|
||||
<div class="admin-list">
|
||||
${kbs.length === 0 && html`<div class="empty-hint">No knowledge bases</div>`}
|
||||
${kbs.map(k => html`
|
||||
<div class="admin-surface-row" key=${k.id} style="cursor:pointer;" onClick=${() => openDetail(k)}>
|
||||
<div style="flex:1;min-width:0;">
|
||||
<strong>${k.name}</strong>
|
||||
<span class="text-muted" style="margin-left:8px;font-size:12px;">${k.document_count || 0} docs</span>
|
||||
</div>
|
||||
<span class="badge">${k.scope || 'personal'}</span>
|
||||
${statusBadge(k.status || 'active')}
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
/**
|
||||
* Admin > AI > Memory
|
||||
*/
|
||||
const { html } = window;
|
||||
const { useState, useEffect, useCallback } = hooks;
|
||||
|
||||
export default function MemorySection() {
|
||||
const [pending, setPending] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const data = await sw.api.admin.memories.pending();
|
||||
setPending(data || []);
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
async function bulkApprove() {
|
||||
const ids = pending.map(m => m.id);
|
||||
if (!ids.length) return;
|
||||
try {
|
||||
const result = await sw.api.admin.memories.bulkApprove(ids);
|
||||
sw.toast(`Approved ${result.count || ids.length} memories`, 'success');
|
||||
load();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function approveSingle(id) {
|
||||
try {
|
||||
await sw.api.memory.approve(id);
|
||||
sw.toast('Memory approved', 'success');
|
||||
setPending(prev => prev.filter(m => m.id !== id));
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function rejectSingle(id) {
|
||||
try {
|
||||
await sw.api.memory.reject(id);
|
||||
sw.toast('Memory rejected', 'success');
|
||||
setPending(prev => prev.filter(m => m.id !== id));
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
if (loading) return html`<div class="settings-placeholder">Loading\u2026</div>`;
|
||||
|
||||
return html`
|
||||
<div>
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;">
|
||||
<span class="text-muted" style="font-size:12px;">${pending.length} pending</span>
|
||||
<div style="flex:1"></div>
|
||||
${pending.length > 0 && html`<button class="btn-small btn-primary" onClick=${bulkApprove}>Approve All</button>`}
|
||||
</div>
|
||||
|
||||
<div class="admin-list">
|
||||
${pending.length === 0 && html`<div class="empty-hint">No pending memories</div>`}
|
||||
${pending.map(m => html`
|
||||
<div class="admin-surface-row" key=${m.id} style="flex-direction:column;align-items:stretch;">
|
||||
<div style="display:flex;align-items:center;gap:8px;">
|
||||
<strong style="flex:1;">${m.key}</strong>
|
||||
<span class="badge">${m.scope || 'user'}</span>
|
||||
<span class="text-muted" style="font-size:11px;">conf: ${(m.confidence || 0).toFixed(2)}</span>
|
||||
</div>
|
||||
<div class="text-muted" style="font-size:12px;margin:4px 0;">${m.value}</div>
|
||||
<div style="display:flex;gap:6px;">
|
||||
<button class="btn-small btn-primary" onClick=${() => approveSingle(m.id)}>Approve</button>
|
||||
<button class="btn-small btn-danger" onClick=${() => rejectSingle(m.id)}>Reject</button>
|
||||
</div>
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
/**
|
||||
* Admin > AI > Models
|
||||
*/
|
||||
const { html } = window;
|
||||
const { useState, useEffect, useCallback } = hooks;
|
||||
|
||||
export default function ModelsSection() {
|
||||
const [models, setModels] = useState([]);
|
||||
const [search, setSearch] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [fetching, setFetching] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const data = await sw.api.admin.models.list();
|
||||
setModels(data || []);
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
async function fetchModels() {
|
||||
setFetching(true);
|
||||
try {
|
||||
const result = await sw.api.admin.models.fetch();
|
||||
sw.toast(`Synced: ${result.added || 0} added, ${result.updated || 0} updated, ${result.total || 0} total`, 'success');
|
||||
load();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
finally { setFetching(false); }
|
||||
}
|
||||
|
||||
async function setVisibility(id, visibility) {
|
||||
try {
|
||||
await sw.api.admin.models.update(id, { visibility });
|
||||
setModels(prev => prev.map(m => m.id === id ? { ...m, visibility } : m));
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function bulkSetVisibility(visibility) {
|
||||
try {
|
||||
await sw.api.admin.models.bulkUpdate(visibility);
|
||||
sw.toast(`All models set to ${visibility}`, 'success');
|
||||
load();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
const filtered = models.filter(m => {
|
||||
if (!search) return true;
|
||||
const q = search.toLowerCase();
|
||||
return (m.model_id || m.id || '').toLowerCase().includes(q) ||
|
||||
(m.display_name || '').toLowerCase().includes(q) ||
|
||||
(m.provider_name || '').toLowerCase().includes(q);
|
||||
});
|
||||
|
||||
if (loading) return html`<div class="settings-placeholder">Loading\u2026</div>`;
|
||||
|
||||
return html`
|
||||
<div>
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;">
|
||||
<button class="btn-small btn-primary" onClick=${fetchModels} disabled=${fetching}>
|
||||
${fetching ? 'Fetching\u2026' : 'Fetch Models'}
|
||||
</button>
|
||||
<span class="text-muted" style="font-size:12px;">${models.length} models</span>
|
||||
<div style="flex:1"></div>
|
||||
<button class="btn-small" onClick=${() => bulkSetVisibility('enabled')}>Enable All</button>
|
||||
<button class="btn-small" onClick=${() => bulkSetVisibility('disabled')}>Disable All</button>
|
||||
</div>
|
||||
|
||||
<div class="admin-search" style="margin-bottom:12px;">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="color:var(--text-3);flex-shrink:0;"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
||||
<input placeholder="Search models\u2026" value=${search} onInput=${e => setSearch(e.target.value)} autocomplete="off" />
|
||||
</div>
|
||||
|
||||
<div class="admin-list">
|
||||
${filtered.length === 0 && html`<div class="empty-hint">No models found</div>`}
|
||||
${filtered.map(m => html`
|
||||
<div class="admin-surface-row" key=${m.id}>
|
||||
<div style="flex:1;min-width:0;">
|
||||
<strong>${m.display_name || m.model_id || m.id}</strong>
|
||||
${m.display_name && m.model_id && html`<span class="text-muted" style="margin-left:8px;font-size:11px;">${m.model_id}</span>`}
|
||||
${m.provider_name && html`<span class="badge" style="margin-left:6px;">${m.provider_name}</span>`}
|
||||
</div>
|
||||
<select value=${m.visibility || 'disabled'} onChange=${e => setVisibility(m.id, e.target.value)}
|
||||
style="font-size:11px;padding:2px 4px;">
|
||||
<option value="enabled">enabled</option>
|
||||
<option value="disabled">disabled</option>
|
||||
<option value="team">team only</option>
|
||||
</select>
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -1,225 +0,0 @@
|
||||
/**
|
||||
* Admin > AI > Personas
|
||||
*/
|
||||
const { html } = window;
|
||||
const { useState, useEffect, useCallback } = hooks;
|
||||
|
||||
export default function PersonasSection() {
|
||||
const [personas, setPersonas] = useState([]);
|
||||
const [models, setModels] = useState([]);
|
||||
const [kbs, setKbs] = useState([]);
|
||||
const [search, setSearch] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editing, setEditing] = useState(null); // null | 'new' | persona object
|
||||
const [editKbs, setEditKbs] = useState([]);
|
||||
const [editToolGrants, setEditToolGrants] = useState([]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const [p, m, k] = await Promise.all([
|
||||
sw.api.admin.personas.list(),
|
||||
sw.api.admin.models.list().catch(() => []),
|
||||
sw.api.knowledge.list().catch(() => []),
|
||||
]);
|
||||
setPersonas(p || []);
|
||||
setModels(m || []);
|
||||
setKbs(k.data || []);
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
async function startEdit(persona) {
|
||||
setEditing(persona);
|
||||
if (persona !== 'new') {
|
||||
try {
|
||||
const [kbData, tgData] = await Promise.all([
|
||||
sw.api.admin.personas.kbs(persona.id).catch(() => []),
|
||||
sw.api.admin.personas.toolGrants(persona.id).catch(() => ({ tool_names: [] })),
|
||||
]);
|
||||
setEditKbs(Array.isArray(kbData) ? kbData.map(k => k.kb_id || k.id) : kbData.kb_ids || []);
|
||||
setEditToolGrants(tgData.tool_names || []);
|
||||
} catch { setEditKbs([]); setEditToolGrants([]); }
|
||||
} else {
|
||||
setEditKbs([]);
|
||||
setEditToolGrants([]);
|
||||
}
|
||||
}
|
||||
|
||||
async function savePersona(e) {
|
||||
e.preventDefault();
|
||||
const form = e.target;
|
||||
const data = {
|
||||
name: form.pname.value.trim(),
|
||||
handle: form.handle.value.trim(),
|
||||
description: form.description.value.trim(),
|
||||
icon: form.icon.value.trim(),
|
||||
system_prompt: form.system_prompt.value,
|
||||
base_model_id: form.model.value || null,
|
||||
temperature: form.temperature.value ? parseFloat(form.temperature.value) : null,
|
||||
max_tokens: form.max_tokens.value ? parseInt(form.max_tokens.value) : null,
|
||||
memory_enabled: form.memory_enabled.checked,
|
||||
};
|
||||
try {
|
||||
let id;
|
||||
if (editing === 'new') {
|
||||
const res = await sw.api.admin.personas.create(data);
|
||||
id = res.id;
|
||||
sw.toast('Persona created', 'success');
|
||||
} else {
|
||||
id = editing.id;
|
||||
await sw.api.admin.personas.update(id, data);
|
||||
sw.toast('Persona updated', 'success');
|
||||
}
|
||||
// Save KB bindings
|
||||
if (id) {
|
||||
await sw.api.admin.personas.setKbs(id, { kb_ids: editKbs }).catch(() => {});
|
||||
await sw.api.admin.personas.setToolGrants(id, { tool_names: editToolGrants }).catch(() => {});
|
||||
}
|
||||
setEditing(null);
|
||||
load();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function deletePersona(id) {
|
||||
const ok = await sw.confirm('Delete this persona?', true);
|
||||
if (!ok) return;
|
||||
try {
|
||||
await sw.api.admin.personas.del(id);
|
||||
sw.toast('Persona deleted', 'success');
|
||||
load();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function uploadAvatar(id) {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = 'image/*';
|
||||
input.onchange = async () => {
|
||||
const file = input.files[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = async () => {
|
||||
try {
|
||||
await sw.api.admin.personas.avatar(id, reader.result);
|
||||
sw.toast('Avatar uploaded', 'success');
|
||||
load();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
};
|
||||
input.click();
|
||||
}
|
||||
|
||||
function toggleKb(kbId) {
|
||||
setEditKbs(prev => prev.includes(kbId) ? prev.filter(k => k !== kbId) : [...prev, kbId]);
|
||||
}
|
||||
|
||||
const filtered = personas.filter(p => {
|
||||
if (!search) return true;
|
||||
const q = search.toLowerCase();
|
||||
return (p.name || '').toLowerCase().includes(q) || (p.handle || '').toLowerCase().includes(q);
|
||||
});
|
||||
|
||||
if (loading) return html`<div class="settings-placeholder">Loading\u2026</div>`;
|
||||
|
||||
if (editing) {
|
||||
const isNew = editing === 'new';
|
||||
const p = isNew ? {} : editing;
|
||||
return html`
|
||||
<form onSubmit=${savePersona}>
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;">
|
||||
<button type="button" class="btn-small" onClick=${() => setEditing(null)}>\u2190 Back</button>
|
||||
<h4 style="margin:0;">${isNew ? 'Create Persona' : `Edit: ${p.name}`}</h4>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group"><label>Name</label><input name="pname" value=${p.name || ''} placeholder="Code Reviewer" /></div>
|
||||
<div class="form-group"><label>Handle</label><input name="handle" value=${p.handle || ''} placeholder="code-reviewer" /></div>
|
||||
<div class="form-group" style="width:60px;"><label>Icon</label><input name="icon" value=${p.icon || ''} placeholder="\ud83d\udd0d" /></div>
|
||||
</div>
|
||||
<div class="form-group"><label>Description</label><input name="description" value=${p.description || ''} placeholder="Optional description" /></div>
|
||||
<div class="form-group"><label>System Prompt</label>
|
||||
<textarea name="system_prompt" rows="4" placeholder="System prompt\u2026">${p.system_prompt || ''}</textarea>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group"><label>Model</label>
|
||||
<select name="model">
|
||||
<option value="">Default</option>
|
||||
${models.map(m => html`<option key=${m.id} value=${m.model_id || m.id} selected=${p.base_model_id === (m.model_id || m.id)}>${m.display_name || m.model_id || m.id}</option>`)}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group" style="width:100px;"><label>Temperature</label>
|
||||
<input name="temperature" type="number" step="0.1" min="0" max="2" value=${p.temperature ?? ''} placeholder="0.7" />
|
||||
</div>
|
||||
<div class="form-group" style="width:100px;"><label>Max Tokens</label>
|
||||
<input name="max_tokens" type="number" min="1" value=${p.max_tokens ?? ''} placeholder="4096" />
|
||||
</div>
|
||||
</div>
|
||||
<label class="toggle-label" style="margin:8px 0;">
|
||||
<input type="checkbox" name="memory_enabled" checked=${!!p.memory_enabled} />
|
||||
<span class="toggle-track"></span>
|
||||
<span>Memory enabled</span>
|
||||
</label>
|
||||
|
||||
${!isNew && html`
|
||||
<div style="margin:12px 0;">
|
||||
<button type="button" class="btn-small" onClick=${() => uploadAvatar(p.id)}>Upload Avatar</button>
|
||||
${p.avatar && html`<button type="button" class="btn-small btn-danger" style="margin-left:6px;"
|
||||
onClick=${async () => { await sw.api.admin.personas.delAvatar(p.id); sw.toast('Avatar removed', 'success'); load(); }}>Remove Avatar</button>`}
|
||||
</div>
|
||||
`}
|
||||
|
||||
<div class="settings-section" style="margin:12px 0;">
|
||||
<h5 style="margin:0 0 8px;">Knowledge Base Bindings</h5>
|
||||
${kbs.length === 0
|
||||
? html`<span class="text-muted" style="font-size:12px;">No knowledge bases available</span>`
|
||||
: kbs.map(k => html`
|
||||
<label class="toggle-label" key=${k.id}>
|
||||
<input type="checkbox" checked=${editKbs.includes(k.id)} onChange=${() => toggleKb(k.id)} />
|
||||
<span class="toggle-track"></span>
|
||||
<span>${k.name}</span>
|
||||
</label>
|
||||
`)
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="form-row" style="margin-top:16px;gap:8px;">
|
||||
<button type="submit" class="btn-small btn-primary">${isNew ? 'Create' : 'Save'}</button>
|
||||
<button type="button" class="btn-small" onClick=${() => setEditing(null)}>Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div>
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;">
|
||||
<div class="admin-search">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="color:var(--text-3);flex-shrink:0;"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
||||
<input placeholder="Search personas\u2026" value=${search} onInput=${e => setSearch(e.target.value)} autocomplete="off" />
|
||||
</div>
|
||||
<div style="flex:1"></div>
|
||||
<button class="btn-md btn-primary" onClick=${() => startEdit('new')}>+ Add</button>
|
||||
</div>
|
||||
|
||||
<div class="admin-list">
|
||||
${filtered.length === 0 && html`<div class="empty-hint">No personas</div>`}
|
||||
${filtered.map(p => html`
|
||||
<div class="admin-surface-row" key=${p.id}>
|
||||
<span style="font-size:18px;margin-right:6px;">${p.icon || '\ud83e\udd16'}</span>
|
||||
<div style="flex:1;min-width:0;">
|
||||
<strong>${p.name}</strong>
|
||||
<span class="text-muted" style="margin-left:6px;font-size:11px;">@${p.handle || '?'}</span>
|
||||
${p.scope && html`<span class="badge" style="margin-left:6px;">${p.scope}</span>`}
|
||||
</div>
|
||||
<div class="admin-actions-cell">
|
||||
<button class="btn-small" onClick=${() => startEdit(p)}>Edit</button>
|
||||
<button class="btn-small btn-danger" onClick=${() => deletePersona(p.id)}>Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
/**
|
||||
* Admin > AI > Providers
|
||||
*/
|
||||
const { html } = window;
|
||||
const { useState, useEffect, useCallback } = hooks;
|
||||
|
||||
export default function ProvidersSection() {
|
||||
const [configs, setConfigs] = useState([]);
|
||||
const [providerTypes, setProviderTypes] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editing, setEditing] = useState(null); // null | 'new' | config object
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const [c, t] = await Promise.all([
|
||||
sw.api.admin.configs.list(),
|
||||
sw.api.admin.providers.types(),
|
||||
]);
|
||||
setConfigs(c || []);
|
||||
setProviderTypes(t || []);
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
async function saveProvider(e) {
|
||||
e.preventDefault();
|
||||
const form = e.target;
|
||||
const data = {
|
||||
name: form.pname.value.trim(),
|
||||
provider: form.provider.value,
|
||||
endpoint: form.endpoint.value.trim(),
|
||||
api_key: form.api_key.value || undefined,
|
||||
is_private: form.is_private.checked,
|
||||
};
|
||||
try {
|
||||
if (editing === 'new') {
|
||||
await sw.api.admin.configs.create(data);
|
||||
sw.toast('Provider created', 'success');
|
||||
} else {
|
||||
await sw.api.admin.configs.update(editing.id, data);
|
||||
sw.toast('Provider updated', 'success');
|
||||
}
|
||||
setEditing(null);
|
||||
load();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function syncModels(id) {
|
||||
try {
|
||||
const result = await sw.api.admin.models.fetch();
|
||||
sw.toast(`Synced: ${result.added || 0} added, ${result.updated || 0} updated`, 'success');
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function testProvider(cfg) {
|
||||
sw.toast(`Testing ${cfg.name}\u2026`, 'info');
|
||||
try {
|
||||
const result = await sw.api.admin.models.fetch();
|
||||
sw.toast(`${cfg.name}: connection OK`, 'success');
|
||||
} catch (e) {
|
||||
sw.toast(`${cfg.name}: connection failed \u2014 ${e.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteProvider(id) {
|
||||
const ok = await sw.confirm('Delete this provider config?', true);
|
||||
if (!ok) return;
|
||||
try {
|
||||
await sw.api.admin.configs.del(id);
|
||||
sw.toast('Provider deleted', 'success');
|
||||
load();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
if (loading) return html`<div class="settings-placeholder">Loading\u2026</div>`;
|
||||
|
||||
return html`
|
||||
<div>
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;">
|
||||
<div style="flex:1"></div>
|
||||
<button class="btn-md btn-primary" onClick=${() => setEditing('new')}>+ Add</button>
|
||||
</div>
|
||||
|
||||
${editing && html`
|
||||
<form class="admin-inline-form" style="margin-bottom:16px;padding:12px;border:1px solid var(--border);border-radius:var(--radius);" onSubmit=${saveProvider}>
|
||||
<h4 style="margin:0 0 12px;">${editing === 'new' ? 'Add Provider' : 'Edit Provider'}</h4>
|
||||
<div class="form-row">
|
||||
<div class="form-group"><label>Name</label>
|
||||
<input name="pname" placeholder="My OpenAI" value=${editing !== 'new' ? editing.name || '' : ''} />
|
||||
</div>
|
||||
<div class="form-group"><label>Type</label>
|
||||
<select name="provider">
|
||||
${providerTypes.map(t => html`<option key=${t.id} value=${t.id} selected=${editing !== 'new' && editing.provider === t.id}>${t.display_name || t.name}</option>`)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group" style="flex:2;"><label>Endpoint</label>
|
||||
<input name="endpoint" placeholder="https://api.openai.com/v1" value=${editing !== 'new' ? editing.endpoint || '' : ''} />
|
||||
</div>
|
||||
<div class="form-group" style="flex:1;"><label>API Key</label>
|
||||
<input name="api_key" type="password" placeholder="${editing !== 'new' ? '(unchanged)' : 'sk-...'}" autocomplete="off" />
|
||||
</div>
|
||||
</div>
|
||||
<label class="toggle-label" style="margin:8px 0;">
|
||||
<input type="checkbox" name="is_private" checked=${editing !== 'new' && editing.is_private} />
|
||||
<span class="toggle-track"></span>
|
||||
<span>Private (admin personas only)</span>
|
||||
</label>
|
||||
<div class="form-row" style="margin-top:12px;gap:8px;">
|
||||
<button type="submit" class="btn-small btn-primary">${editing === 'new' ? 'Create' : 'Save'}</button>
|
||||
<button type="button" class="btn-small" onClick=${() => setEditing(null)}>Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
`}
|
||||
|
||||
<div class="provider-cards">
|
||||
${configs.length === 0 && html`<div class="empty-hint">No provider configs</div>`}
|
||||
${configs.map(c => html`
|
||||
<div class="provider-card" key=${c.id}>
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;">
|
||||
<strong>${c.name}</strong>
|
||||
<span class="badge">${c.provider}</span>
|
||||
${c.is_private && html`<span class="badge badge-inactive">private</span>`}
|
||||
${c.has_key ? html`<span class="badge badge-active">key set</span>` : html`<span class="badge badge-pending">no key</span>`}
|
||||
</div>
|
||||
<div class="text-muted" style="font-size:12px;margin-bottom:8px;word-break:break-all;">${c.endpoint || '(default)'}</div>
|
||||
<div style="display:flex;gap:6px;">
|
||||
<button class="btn-small" onClick=${() => testProvider(c)}>Test</button>
|
||||
<button class="btn-small" onClick=${() => setEditing(c)}>Edit</button>
|
||||
<button class="btn-small" onClick=${() => syncModels(c.id)}>Sync Models</button>
|
||||
<button class="btn-small btn-danger" onClick=${() => deleteProvider(c.id)}>Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
/**
|
||||
* Admin > AI > Roles
|
||||
*/
|
||||
const { html } = window;
|
||||
const { useState, useEffect, useCallback } = hooks;
|
||||
|
||||
export default function RolesSection() {
|
||||
const [roles, setRoles] = useState({});
|
||||
const [models, setModels] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [dirty, setDirty] = useState({});
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const [r, m] = await Promise.all([
|
||||
sw.api.admin.roles.list(),
|
||||
sw.api.admin.models.list().catch(() => []),
|
||||
]);
|
||||
setRoles(r || {});
|
||||
setModels(m || []);
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
function updateRole(roleName, field, value) {
|
||||
setDirty(prev => ({
|
||||
...prev,
|
||||
[roleName]: { ...(prev[roleName] || {}), [field]: value },
|
||||
}));
|
||||
}
|
||||
|
||||
async function saveRole(roleName) {
|
||||
const changes = dirty[roleName];
|
||||
if (!changes) return;
|
||||
try {
|
||||
// Get current role config and merge changes
|
||||
const current = await sw.api.admin.roles.get(roleName);
|
||||
const updated = { ...current, ...changes };
|
||||
await sw.api.admin.roles.update(roleName, updated);
|
||||
sw.toast(`Role "${roleName}" saved`, 'success');
|
||||
setDirty(prev => { const n = { ...prev }; delete n[roleName]; return n; });
|
||||
load();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
if (loading) return html`<div class="settings-placeholder">Loading\u2026</div>`;
|
||||
|
||||
return html`
|
||||
<div>
|
||||
${Object.keys(roles).length === 0 && html`<div class="empty-hint">No roles configured</div>`}
|
||||
${Object.entries(roles).map(([name, config]) => {
|
||||
const changes = dirty[name] || {};
|
||||
const primary = changes.primary_model ?? config.primary_model ?? '';
|
||||
const fallback = changes.fallback_model ?? config.fallback_model ?? '';
|
||||
|
||||
return html`
|
||||
<div class="settings-section" key=${name} style="margin-bottom:16px;padding:12px;border:1px solid var(--border);border-radius:var(--radius);">
|
||||
<h4 style="margin:0 0 8px;">${name}</h4>
|
||||
<div class="form-row">
|
||||
<div class="form-group"><label>Primary Model</label>
|
||||
<select value=${primary} onChange=${e => updateRole(name, 'primary_model', e.target.value)}>
|
||||
<option value="">-- None --</option>
|
||||
${models.map(m => html`<option key=${m.id} value=${m.model_id || m.id}>${m.display_name || m.model_id || m.id}</option>`)}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group"><label>Fallback Model</label>
|
||||
<select value=${fallback} onChange=${e => updateRole(name, 'fallback_model', e.target.value)}>
|
||||
<option value="">-- None --</option>
|
||||
${models.map(m => html`<option key=${m.id} value=${m.model_id || m.id}>${m.display_name || m.model_id || m.id}</option>`)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
${dirty[name] && html`<button class="btn-small btn-primary" style="margin-top:8px;" onClick=${() => saveRole(name)}>Save</button>`}
|
||||
</div>
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
/**
|
||||
* Admin > Routing > Routing Policies
|
||||
*/
|
||||
const { html } = window;
|
||||
const { useState, useEffect, useCallback } = hooks;
|
||||
|
||||
const POLICY_TYPES = ['provider_prefer', 'team_route', 'cost_limit', 'model_alias', 'capability_match'];
|
||||
|
||||
export default function RoutingSection() {
|
||||
const [policies, setPolicies] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editing, setEditing] = useState(null); // null | 'new' | policy
|
||||
const [testModel, setTestModel] = useState('');
|
||||
const [testUser, setTestUser] = useState('');
|
||||
const [testResult, setTestResult] = useState(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const data = await sw.api.admin.routing.policies();
|
||||
setPolicies(data || []);
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
async function savePolicy(e) {
|
||||
e.preventDefault();
|
||||
const form = e.target;
|
||||
const data = {
|
||||
name: form.pname.value.trim(),
|
||||
scope: form.scope.value,
|
||||
priority: parseInt(form.priority.value) || 0,
|
||||
policy_type: form.policy_type.value,
|
||||
config: JSON.parse(form.config.value || '{}'),
|
||||
is_active: form.is_active.checked,
|
||||
};
|
||||
try {
|
||||
if (editing === 'new') {
|
||||
await sw.api.admin.routing.createPolicy(data);
|
||||
sw.toast('Policy created', 'success');
|
||||
} else {
|
||||
await sw.api.admin.routing.updatePolicy(editing.id, data);
|
||||
sw.toast('Policy updated', 'success');
|
||||
}
|
||||
setEditing(null);
|
||||
load();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function deletePolicy(id) {
|
||||
const ok = await sw.confirm('Delete this routing policy?', true);
|
||||
if (!ok) return;
|
||||
try {
|
||||
await sw.api.admin.routing.deletePolicy(id);
|
||||
sw.toast('Policy deleted', 'success');
|
||||
load();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function runTest() {
|
||||
if (!testModel) return sw.toast('Model is required', 'error');
|
||||
try {
|
||||
const result = await sw.api.admin.routing.test({
|
||||
model: testModel,
|
||||
user_id: testUser || undefined,
|
||||
});
|
||||
setTestResult(result);
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
if (loading) return html`<div class="settings-placeholder">Loading\u2026</div>`;
|
||||
|
||||
if (editing) {
|
||||
const isNew = editing === 'new';
|
||||
const p = isNew ? {} : editing;
|
||||
return html`
|
||||
<form onSubmit=${savePolicy}>
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;">
|
||||
<button type="button" class="btn-small" onClick=${() => setEditing(null)}>\u2190 Back</button>
|
||||
<h4 style="margin:0;">${isNew ? 'New Policy' : `Edit: ${p.name}`}</h4>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group"><label>Name</label><input name="pname" value=${p.name || ''} placeholder="Prefer Anthropic" /></div>
|
||||
<div class="form-group" style="width:100px;"><label>Priority</label>
|
||||
<input name="priority" type="number" value=${p.priority ?? 10} />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group"><label>Scope</label>
|
||||
<select name="scope">
|
||||
<option value="global" selected=${p.scope !== 'team'}>global</option>
|
||||
<option value="team" selected=${p.scope === 'team'}>team</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group"><label>Type</label>
|
||||
<select name="policy_type">
|
||||
${POLICY_TYPES.map(t => html`<option key=${t} value=${t} selected=${p.policy_type === t}>${t}</option>`)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group"><label>Config (JSON)</label>
|
||||
<textarea name="config" rows="4" style="font-family:monospace;font-size:12px;">${JSON.stringify(p.config || {}, null, 2)}</textarea>
|
||||
</div>
|
||||
<label class="toggle-label" style="margin:8px 0;">
|
||||
<input type="checkbox" name="is_active" checked=${p.is_active !== false} />
|
||||
<span class="toggle-track"></span><span>Active</span>
|
||||
</label>
|
||||
<div class="form-row" style="margin-top:12px;gap:8px;">
|
||||
<button type="submit" class="btn-small btn-primary">${isNew ? 'Create' : 'Save'}</button>
|
||||
<button type="button" class="btn-small" onClick=${() => setEditing(null)}>Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div>
|
||||
<div style="margin-bottom:12px;">
|
||||
<button class="btn-md btn-primary" onClick=${() => setEditing('new')}>+ New Policy</button>
|
||||
</div>
|
||||
|
||||
<div class="admin-list">
|
||||
${policies.length === 0 && html`<div class="empty-hint">No routing policies</div>`}
|
||||
${policies.map(p => html`
|
||||
<div class="admin-surface-row" key=${p.id}>
|
||||
<div style="flex:1;min-width:0;">
|
||||
<strong>${p.name}</strong>
|
||||
<span class="badge" style="margin-left:6px;">${p.policy_type}</span>
|
||||
<span class="text-muted" style="margin-left:6px;font-size:11px;">priority: ${p.priority}</span>
|
||||
</div>
|
||||
<span class="badge">${p.scope}</span>
|
||||
<span class="badge ${p.is_active ? 'badge-active' : 'badge-inactive'}">${p.is_active ? 'active' : 'off'}</span>
|
||||
<div class="admin-actions-cell">
|
||||
<button class="btn-small" onClick=${() => setEditing(p)}>Edit</button>
|
||||
<button class="btn-small btn-danger" onClick=${() => deletePolicy(p.id)}>Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
|
||||
<div class="settings-section" style="margin-top:24px;">
|
||||
<h4>Test Routing</h4>
|
||||
<div class="form-row">
|
||||
<div class="form-group"><label>Model</label>
|
||||
<input value=${testModel} onInput=${e => setTestModel(e.target.value)} placeholder="gpt-4o" />
|
||||
</div>
|
||||
<div class="form-group"><label>User ID</label>
|
||||
<input value=${testUser} onInput=${e => setTestUser(e.target.value)} placeholder="optional" />
|
||||
</div>
|
||||
<button class="btn-small btn-primary" style="align-self:flex-end;" onClick=${runTest}>Test</button>
|
||||
</div>
|
||||
${testResult && html`
|
||||
<pre class="code-block" style="margin-top:8px;font-size:12px;max-height:200px;overflow:auto;">${JSON.stringify(testResult, null, 2)}</pre>
|
||||
`}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -195,7 +195,7 @@ export default function SettingsSection() {
|
||||
<label class="toggle-label"><input type="checkbox" checked=${cfg.footer_enabled} onChange=${e => set('footer_enabled', e.target.checked)} /><span class="toggle-track"></span><span>Show footer bar</span></label>
|
||||
${cfg.footer_enabled && html`
|
||||
<div style="margin-top:8px;">
|
||||
<div class="form-group"><label>Text</label><input value=${cfg.footer_text} onInput=${e => set('footer_text', e.target.value)} placeholder="Powered by Chat Switchboard" /></div>
|
||||
<div class="form-group"><label>Text</label><input value=${cfg.footer_text} onInput=${e => set('footer_text', e.target.value)} placeholder="Powered by Switchboard Core" /></div>
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
/**
|
||||
* Admin > Monitoring > Stats
|
||||
*/
|
||||
const { html } = window;
|
||||
const { useState, useEffect } = hooks;
|
||||
|
||||
export default function StatsSection() {
|
||||
const [stats, setStats] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
sw.api.admin.stats()
|
||||
.then(d => setStats(d))
|
||||
.catch(e => sw.toast(e.message, 'error'))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
if (loading) return html`<div class="settings-placeholder">Loading\u2026</div>`;
|
||||
if (!stats) return html`<div class="empty-hint">No stats available</div>`;
|
||||
|
||||
const cards = [
|
||||
{ label: 'Users', value: stats.users },
|
||||
{ label: 'Channels', value: stats.channels },
|
||||
{ label: 'Messages', value: stats.messages },
|
||||
{ label: 'Models', value: stats.models },
|
||||
{ label: 'Providers', value: stats.providers },
|
||||
{ label: 'Personas', value: stats.personas },
|
||||
];
|
||||
|
||||
return html`
|
||||
<div class="stat-cards-grid">
|
||||
${cards.map(c => html`
|
||||
<div class="stat-card" key=${c.label}>
|
||||
<div class="stat-value">${c.value != null ? c.value.toLocaleString() : '\u2014'}</div>
|
||||
<div class="stat-label">${c.label}</div>
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
/**
|
||||
* Admin > Workflows > Tasks
|
||||
*/
|
||||
const { html } = window;
|
||||
const { useState, useEffect, useCallback } = hooks;
|
||||
|
||||
export default function TasksSection() {
|
||||
const [tasks, setTasks] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [expandedRuns, setExpandedRuns] = useState({}); // taskId → runs[]
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const data = await sw.api.admin.tasks.list();
|
||||
setTasks(data || []);
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
async function runTask(id) {
|
||||
try {
|
||||
await sw.api.admin.tasks.run(id);
|
||||
sw.toast('Task triggered', 'success');
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function killTask(id) {
|
||||
try {
|
||||
await sw.api.admin.tasks.kill(id);
|
||||
sw.toast('Task cancelled', 'success');
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function deleteTask(id) {
|
||||
const ok = await sw.confirm('Delete this task?', true);
|
||||
if (!ok) return;
|
||||
try {
|
||||
await sw.api.admin.tasks.del(id);
|
||||
sw.toast('Task deleted', 'success');
|
||||
load();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function toggleRuns(taskId) {
|
||||
if (expandedRuns[taskId]) {
|
||||
setExpandedRuns(prev => { const n = { ...prev }; delete n[taskId]; return n; });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const data = await sw.api.tasks.runs(taskId);
|
||||
const runs = data || [];
|
||||
setExpandedRuns(prev => ({ ...prev, [taskId]: runs }));
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
function fmtDate(d) {
|
||||
if (!d) return '\u2014';
|
||||
return new Date(d).toLocaleString(undefined, { dateStyle: 'short', timeStyle: 'short' });
|
||||
}
|
||||
|
||||
if (loading) return html`<div class="settings-placeholder">Loading\u2026</div>`;
|
||||
|
||||
return html`
|
||||
<div>
|
||||
<div class="admin-list">
|
||||
${tasks.length === 0 && html`<div class="empty-hint">No tasks</div>`}
|
||||
${tasks.map(t => html`
|
||||
<div key=${t.id}>
|
||||
<div class="admin-surface-row">
|
||||
<div style="flex:1;min-width:0;">
|
||||
<strong>${t.name}</strong>
|
||||
<span class="text-muted" style="margin-left:8px;font-size:11px;">${t.scope || 'personal'}</span>
|
||||
${t.schedule && html`<span class="badge" style="margin-left:6px;">${t.schedule}</span>`}
|
||||
</div>
|
||||
<span class="text-muted" style="font-size:11px;">
|
||||
last: ${fmtDate(t.last_run_at)} \u2022 next: ${fmtDate(t.next_run_at)}
|
||||
</span>
|
||||
<span class="badge ${t.is_active ? 'badge-active' : 'badge-inactive'}">${t.is_active ? 'active' : 'paused'}</span>
|
||||
<div class="admin-actions-cell">
|
||||
<button class="btn-small" onClick=${() => toggleRuns(t.id)}>${expandedRuns[t.id] ? 'Hide Runs' : 'Runs'}</button>
|
||||
<button class="btn-small btn-primary" onClick=${() => runTask(t.id)}>Run</button>
|
||||
<button class="btn-small" onClick=${() => killTask(t.id)}>Kill</button>
|
||||
<button class="btn-small btn-danger" onClick=${() => deleteTask(t.id)}>Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
${expandedRuns[t.id] && html`
|
||||
<div style="padding:4px 0 8px 24px;">
|
||||
${expandedRuns[t.id].length === 0 && html`<span class="text-muted" style="font-size:12px;">No runs</span>`}
|
||||
${expandedRuns[t.id].map(r => html`
|
||||
<div key=${r.id} style="font-size:12px;display:flex;gap:8px;padding:2px 0;">
|
||||
<span class="badge ${r.status === 'completed' ? 'badge-active' : r.status === 'failed' ? 'badge-inactive' : 'badge-pending'}">${r.status}</span>
|
||||
<span>${fmtDate(r.started_at)}</span>
|
||||
<span class="text-muted">${r.tokens_used || 0} tokens</span>
|
||||
${r.error && html`<span style="color:var(--danger);">${r.error}</span>`}
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
/**
|
||||
* Admin > Monitoring > Usage
|
||||
*/
|
||||
const { html } = window;
|
||||
const { useState, useEffect, useCallback } = hooks;
|
||||
|
||||
export default function UsageSection() {
|
||||
const [usage, setUsage] = useState(null);
|
||||
const [pricing, setPricing] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const [u, p] = await Promise.all([
|
||||
sw.api.admin.usage.get(),
|
||||
sw.api.admin.pricing.list().catch(() => []),
|
||||
]);
|
||||
setUsage(u);
|
||||
setPricing(p || []);
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
async function deletePricing(provId, modelId) {
|
||||
try {
|
||||
await sw.api.admin.pricing.del(provId, modelId);
|
||||
sw.toast('Pricing entry removed', 'success');
|
||||
load();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
function fmtNum(n) {
|
||||
if (n == null) return '\u2014';
|
||||
return n.toLocaleString();
|
||||
}
|
||||
|
||||
if (loading) return html`<div class="settings-placeholder">Loading\u2026</div>`;
|
||||
|
||||
return html`
|
||||
<div>
|
||||
${usage?.totals && html`
|
||||
<div class="stat-cards-grid" style="margin-bottom:16px;">
|
||||
<div class="stat-card"><div class="stat-value">${fmtNum(usage.totals.total_tokens)}</div><div class="stat-label">Total Tokens</div></div>
|
||||
<div class="stat-card"><div class="stat-value">${fmtNum(usage.totals.total_requests)}</div><div class="stat-label">Total Requests</div></div>
|
||||
<div class="stat-card"><div class="stat-value">$${(usage.totals.total_cost || 0).toFixed(2)}</div><div class="stat-label">Total Cost</div></div>
|
||||
</div>
|
||||
`}
|
||||
|
||||
${usage?.results && usage.results.length > 0 && html`
|
||||
<h4 style="margin:16px 0 8px;">Usage by Day</h4>
|
||||
<div class="admin-list">
|
||||
${usage.results.map((r, i) => html`
|
||||
<div class="admin-surface-row" key=${i}>
|
||||
<span style="width:100px;">${r.date || r.day || '\u2014'}</span>
|
||||
<span style="flex:1;">${fmtNum(r.tokens)} tokens</span>
|
||||
<span class="text-muted" style="font-size:12px;">${r.requests || 0} requests</span>
|
||||
<span class="text-muted" style="font-size:12px;">$${(r.cost || 0).toFixed(2)}</span>
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
`}
|
||||
|
||||
<h4 style="margin:24px 0 8px;">Pricing Table</h4>
|
||||
${pricing.length === 0
|
||||
? html`<div class="empty-hint">No custom pricing entries</div>`
|
||||
: html`
|
||||
<div class="admin-list">
|
||||
<div class="admin-surface-row" style="font-weight:600;font-size:12px;">
|
||||
<span style="flex:1;">Model</span>
|
||||
<span style="width:100px;">Input/M</span>
|
||||
<span style="width:100px;">Output/M</span>
|
||||
<span style="width:80px;">Currency</span>
|
||||
<span style="width:60px;"></span>
|
||||
</div>
|
||||
${pricing.map(p => html`
|
||||
<div class="admin-surface-row" key=${p.model_id + p.provider_config_id}>
|
||||
<span style="flex:1;">${p.model_id}</span>
|
||||
<span style="width:100px;">$${(p.input_per_m || 0).toFixed(2)}</span>
|
||||
<span style="width:100px;">$${(p.output_per_m || 0).toFixed(2)}</span>
|
||||
<span style="width:80px;">${p.currency || 'USD'}</span>
|
||||
<button class="btn-small btn-danger" style="width:60px;" onClick=${() => deletePricing(p.provider_config_id, p.model_id)}>Del</button>
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
`
|
||||
}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -26,7 +26,7 @@ export function Hero({ base = '', version = '', env = null }) {
|
||||
<div class="login-hero-brand login-anim login-anim-1">
|
||||
<img class="login-hero-brand-icon"
|
||||
src="${base}/favicon.svg?v=${version}"
|
||||
alt="Chat Switchboard" />
|
||||
alt="Switchboard Core" />
|
||||
<span class="login-hero-brand-text">
|
||||
Chat <span class="hl">Switchboard</span>
|
||||
</span>
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
/**
|
||||
* Settings > Data & Privacy — export and account deletion
|
||||
*/
|
||||
const { html } = window;
|
||||
const { useState, useCallback } = hooks;
|
||||
|
||||
export default function DataSection() {
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
const exportData = useCallback(async () => {
|
||||
setExporting(true);
|
||||
try {
|
||||
await sw.api.dataPortability.exportMe();
|
||||
sw.toast('Export started \u2014 you will receive a download link shortly', 'success');
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
finally { setExporting(false); }
|
||||
}, []);
|
||||
|
||||
const deleteAccount = useCallback(async () => {
|
||||
const ok = await sw.confirm(
|
||||
'Are you sure you want to delete your account? This action is permanent and cannot be undone. All your data, conversations, and settings will be permanently deleted.',
|
||||
true,
|
||||
);
|
||||
if (!ok) return;
|
||||
setDeleting(true);
|
||||
try {
|
||||
await sw.api.dataPortability.deleteAccount({ confirm: true });
|
||||
sw.toast('Account deletion initiated', 'success');
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
finally { setDeleting(false); }
|
||||
}, []);
|
||||
|
||||
return html`
|
||||
<div>
|
||||
<div class="settings-section">
|
||||
<h3>Export Your Data</h3>
|
||||
<p class="text-muted" style="font-size:13px;margin:0 0 12px;">
|
||||
Download a copy of all your data including conversations, settings, and memories.
|
||||
The export will be prepared and a download link will be provided.
|
||||
</p>
|
||||
<button class="btn-md btn-primary" disabled=${exporting} onClick=${exportData}>
|
||||
${exporting ? 'Preparing\u2026' : 'Export My Data'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="settings-section" style="margin-top:24px;border-top:1px solid var(--border);padding-top:20px;">
|
||||
<h3 style="color:var(--danger);">Danger Zone</h3>
|
||||
<p class="text-muted" style="font-size:13px;margin:0 0 12px;">
|
||||
Permanently delete your account and all associated data. This action cannot be undone.
|
||||
We recommend exporting your data before proceeding.
|
||||
</p>
|
||||
<button class="btn-md btn-danger" disabled=${deleting} onClick=${deleteAccount}>
|
||||
${deleting ? 'Deleting\u2026' : 'Delete My Account'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
/**
|
||||
* Settings > Git Keys — SSH credential management
|
||||
*/
|
||||
const { html } = window;
|
||||
const { useState, useEffect, useCallback } = hooks;
|
||||
|
||||
function fmtDate(d) {
|
||||
if (!d) return '\u2014';
|
||||
return new Date(d).toLocaleString(undefined, { dateStyle: 'short', timeStyle: 'short' });
|
||||
}
|
||||
|
||||
export default function GitKeysSection() {
|
||||
const [keys, setKeys] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const data = await sw.api.git.credentials.list();
|
||||
setKeys(data || []);
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
async function addKey(e) {
|
||||
e.preventDefault();
|
||||
const form = e.target;
|
||||
const name = form.name.value.trim();
|
||||
const key = form.key.value.trim();
|
||||
if (!name) { sw.toast('Name is required', 'error'); return; }
|
||||
try {
|
||||
await sw.api.git.credentials.create({ name, public_key: key || undefined });
|
||||
sw.toast('Credential added', 'success');
|
||||
form.reset();
|
||||
setShowAdd(false);
|
||||
load();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function deleteKey(id) {
|
||||
const ok = await sw.confirm('Delete this SSH credential?', true);
|
||||
if (!ok) return;
|
||||
try {
|
||||
await sw.api.git.credentials.del(id);
|
||||
sw.toast('Credential deleted', 'success');
|
||||
load();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
if (loading) return html`<div class="settings-placeholder">Loading\u2026</div>`;
|
||||
|
||||
return html`
|
||||
<div>
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;">
|
||||
<span class="text-muted" style="font-size:12px;">${keys.length} credential${keys.length !== 1 ? 's' : ''}</span>
|
||||
<div style="flex:1"></div>
|
||||
<button class="btn-md btn-primary" onClick=${() => setShowAdd(!showAdd)}>
|
||||
${showAdd ? 'Cancel' : '+ Add Key'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
${showAdd && html`
|
||||
<form class="settings-section" style="margin-bottom:16px;" onSubmit=${addKey}>
|
||||
<div class="form-group">
|
||||
<label>Name</label>
|
||||
<input name="name" placeholder="e.g. deploy-key-prod" required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Public Key <span class="text-muted" style="font-weight:400;">(paste, or leave blank to generate)</span></label>
|
||||
<textarea name="key" rows="3" placeholder="ssh-ed25519 AAAA..." style="font-family:var(--mono);font-size:12px;" />
|
||||
</div>
|
||||
<button type="submit" class="btn-small btn-primary">Add Credential</button>
|
||||
</form>
|
||||
`}
|
||||
|
||||
<div class="admin-list">
|
||||
${keys.length === 0 && html`<div class="empty-hint">No SSH credentials configured.</div>`}
|
||||
${keys.map(k => html`
|
||||
<div class="admin-surface-row" key=${k.id}>
|
||||
<div style="flex:1;min-width:0;">
|
||||
<strong>${k.name}</strong>
|
||||
${k.fingerprint && html`
|
||||
<span class="text-muted" style="margin-left:8px;font-size:11px;font-family:var(--mono);">
|
||||
${k.fingerprint.substring(0, 24)}\u2026
|
||||
</span>
|
||||
`}
|
||||
</div>
|
||||
<span class="text-muted" style="font-size:11px;">${fmtDate(k.created_at)}</span>
|
||||
<button class="btn-small btn-danger" onClick=${() => deleteKey(k.id)}>Delete</button>
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -26,54 +26,27 @@ const sectionModules = {
|
||||
appearance: () => import('./appearance.js'),
|
||||
profile: () => import('./profile.js'),
|
||||
teams: () => import('./teams.js'),
|
||||
models: () => import('./models.js'),
|
||||
providers: () => import('./providers.js'),
|
||||
connections: () => import('./connections.js'),
|
||||
personas: () => import('./personas.js'),
|
||||
roles: () => import('./roles.js'),
|
||||
usage: () => import('./usage.js'),
|
||||
workflows: () => import('./workflows.js'),
|
||||
tasks: () => import('./tasks.js'),
|
||||
gitkeys: () => import('./gitkeys.js'),
|
||||
data: () => import('./data.js'),
|
||||
knowledge: () => import('./knowledge.js'),
|
||||
memory: () => import('./memory.js'),
|
||||
notifications: () => import('./notifications.js'),
|
||||
};
|
||||
|
||||
// ── Nav structure ───────────────────────────
|
||||
const NAV_ITEMS = [
|
||||
{ key: 'general', label: 'General' },
|
||||
{ key: 'appearance', label: 'Appearance' },
|
||||
{ key: 'models', label: 'Models' },
|
||||
{ key: 'personas', label: 'Personas', gate: 'personas' },
|
||||
{ key: 'profile', label: 'Profile' },
|
||||
{ key: 'teams', label: 'Teams' },
|
||||
{ key: 'workflows', label: 'Assignments' },
|
||||
{ key: 'tasks', label: 'Tasks' },
|
||||
{ key: 'general', label: 'General' },
|
||||
{ key: 'appearance', label: 'Appearance' },
|
||||
{ key: 'profile', label: 'Profile' },
|
||||
{ key: 'teams', label: 'Teams' },
|
||||
{ key: 'connections', label: 'Connections' },
|
||||
{ key: 'gitkeys', label: 'Git Keys' },
|
||||
{ key: 'knowledge', label: 'Knowledge Bases' },
|
||||
{ key: 'memory', label: 'Memory' },
|
||||
{ key: 'notifications', label: 'Notifications' },
|
||||
{ key: 'data', label: 'Data & Privacy' },
|
||||
];
|
||||
|
||||
const BYOK_ITEMS = [
|
||||
{ key: 'providers', label: 'My Providers' },
|
||||
{ key: 'roles', label: 'Model Roles' },
|
||||
{ key: 'usage', label: 'My Usage' },
|
||||
];
|
||||
const BYOK_ITEMS = [];
|
||||
|
||||
// ── Section title map ───────────────────────
|
||||
const SECTION_TITLES = {
|
||||
general: 'General', appearance: 'Appearance', models: 'Models',
|
||||
personas: 'Personas', profile: 'Profile', teams: 'Teams',
|
||||
workflows: 'Assignments', tasks: 'Tasks', gitkeys: 'Git Keys',
|
||||
connections: 'Connections',
|
||||
data: 'Data & Privacy', providers: 'My Providers', roles: 'Model Roles',
|
||||
usage: 'My Usage', knowledge: 'Knowledge Bases', memory: 'Memory',
|
||||
notifications: 'Notifications',
|
||||
general: 'General', appearance: 'Appearance',
|
||||
profile: 'Profile', teams: 'Teams',
|
||||
connections: 'Connections', notifications: 'Notifications',
|
||||
};
|
||||
|
||||
// ── v0.38.3: Extension config sections ──────
|
||||
|
||||
@@ -1,186 +0,0 @@
|
||||
/**
|
||||
* Settings > Knowledge — personal knowledge base management
|
||||
*/
|
||||
const { html } = window;
|
||||
const { useState, useEffect, useCallback } = hooks;
|
||||
|
||||
function fmtDate(d) {
|
||||
if (!d) return '\u2014';
|
||||
return new Date(d).toLocaleString(undefined, { dateStyle: 'short', timeStyle: 'short' });
|
||||
}
|
||||
|
||||
function formatBytes(b) {
|
||||
if (!b) return '0 B';
|
||||
if (b < 1024) return b + ' B';
|
||||
if (b < 1048576) return (b / 1024).toFixed(1) + ' KB';
|
||||
return (b / 1048576).toFixed(1) + ' MB';
|
||||
}
|
||||
|
||||
export default function KnowledgeSection() {
|
||||
const [kbs, setKbs] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [detail, setDetail] = useState(null);
|
||||
const [docs, setDocs] = useState([]);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const resp = await sw.api.knowledge.list();
|
||||
setKbs(resp.data || []);
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
async function openDetail(kb) {
|
||||
setDetail(kb);
|
||||
try {
|
||||
const d = await sw.api.knowledge.documents(kb.id);
|
||||
setDocs(d.data || []);
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function createKb(e) {
|
||||
e.preventDefault();
|
||||
const form = e.target;
|
||||
const name = form.name.value.trim();
|
||||
if (!name) { sw.toast('Name is required', 'error'); return; }
|
||||
try {
|
||||
await sw.api.knowledge.create({
|
||||
name,
|
||||
description: form.description.value.trim(),
|
||||
});
|
||||
sw.toast('Knowledge base created', 'success');
|
||||
form.reset();
|
||||
setShowCreate(false);
|
||||
load();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function deleteKb(id) {
|
||||
const ok = await sw.confirm('Delete this knowledge base and all its documents?', true);
|
||||
if (!ok) return;
|
||||
try {
|
||||
await sw.api.knowledge.del(id);
|
||||
sw.toast('Knowledge base deleted', 'success');
|
||||
setDetail(null);
|
||||
load();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function uploadDoc() {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = '.txt,.md,.csv,.html,.pdf';
|
||||
input.onchange = async () => {
|
||||
const file = input.files[0];
|
||||
if (!file) return;
|
||||
try {
|
||||
await sw.api.knowledge.upload(detail.id, file);
|
||||
sw.toast('Document uploaded', 'success');
|
||||
const d = await sw.api.knowledge.documents(detail.id);
|
||||
setDocs(d.data || []);
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
};
|
||||
input.click();
|
||||
}
|
||||
|
||||
async function deleteDoc(docId) {
|
||||
const ok = await sw.confirm('Delete this document?', true);
|
||||
if (!ok) return;
|
||||
try {
|
||||
await sw.api.knowledge.delDoc(detail.id, docId);
|
||||
sw.toast('Document deleted', 'success');
|
||||
setDocs(prev => prev.filter(d => d.id !== docId));
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
function statusBadge(status) {
|
||||
const cls = status === 'ready' ? 'badge-active' : status === 'error' ? 'badge-inactive' : 'badge';
|
||||
return html`<span class="badge ${cls}">${status}</span>`;
|
||||
}
|
||||
|
||||
const canCreate = sw.can('kb.create');
|
||||
const canWrite = sw.can('kb.write');
|
||||
|
||||
if (loading) return html`<div class="settings-placeholder">Loading\u2026</div>`;
|
||||
|
||||
// Detail view
|
||||
if (detail) return html`
|
||||
<div>
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;">
|
||||
<button class="btn-small" onClick=${() => { setDetail(null); setDocs([]); }}>\u2190 Back</button>
|
||||
<h4 style="margin:0;">${detail.name}</h4>
|
||||
${statusBadge(detail.status || 'active')}
|
||||
<div style="flex:1"></div>
|
||||
${canWrite && html`<button class="btn-small" onClick=${uploadDoc}>Upload Document</button>`}
|
||||
${canWrite && html`<button class="btn-small btn-danger" onClick=${() => deleteKb(detail.id)}>Delete KB</button>`}
|
||||
</div>
|
||||
|
||||
${detail.description && html`
|
||||
<p class="text-muted" style="font-size:13px;margin:0 0 8px;">${detail.description}</p>
|
||||
`}
|
||||
<p class="text-muted" style="font-size:12px;margin-bottom:12px;">
|
||||
${detail.document_count || 0} docs \u2022 Created ${fmtDate(detail.created_at)}
|
||||
</p>
|
||||
|
||||
<div class="admin-list">
|
||||
${docs.length === 0 && html`<div class="empty-hint">No documents yet. Upload one to get started.</div>`}
|
||||
${docs.map(d => html`
|
||||
<div class="admin-surface-row" key=${d.id}>
|
||||
<div style="flex:1;min-width:0;">
|
||||
<strong>${d.filename}</strong>
|
||||
<span class="text-muted" style="margin-left:8px;font-size:11px;">${formatBytes(d.size_bytes)}</span>
|
||||
${' '}${statusBadge(d.status || 'pending')}
|
||||
</div>
|
||||
<span class="text-muted" style="font-size:11px;">${fmtDate(d.created_at)}</span>
|
||||
${canWrite && html`<button class="btn-small btn-danger" onClick=${() => deleteDoc(d.id)}>Delete</button>`}
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// List view
|
||||
return html`
|
||||
<div>
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;">
|
||||
<span class="text-muted" style="font-size:12px;">${kbs.length} knowledge base${kbs.length !== 1 ? 's' : ''}</span>
|
||||
<div style="flex:1"></div>
|
||||
${canCreate && html`
|
||||
<button class="btn-md btn-primary" onClick=${() => setShowCreate(!showCreate)}>
|
||||
${showCreate ? 'Cancel' : '+ Create'}
|
||||
</button>`}
|
||||
</div>
|
||||
|
||||
${showCreate && html`
|
||||
<form class="settings-section" style="margin-bottom:16px;" onSubmit=${createKb}>
|
||||
<div class="form-group">
|
||||
<label>Name</label>
|
||||
<input name="name" placeholder="e.g. Product Documentation" required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Description</label>
|
||||
<input name="description" placeholder="Optional description" />
|
||||
</div>
|
||||
<button type="submit" class="btn-small btn-primary">Create Knowledge Base</button>
|
||||
</form>
|
||||
`}
|
||||
|
||||
<div class="admin-list">
|
||||
${kbs.length === 0 && html`<div class="empty-hint">No knowledge bases. Create one to get started.</div>`}
|
||||
${kbs.map(k => html`
|
||||
<div class="admin-surface-row" key=${k.id} style="cursor:pointer;" onClick=${() => openDetail(k)}>
|
||||
<div style="flex:1;min-width:0;">
|
||||
<strong>${k.name}</strong>
|
||||
<span class="text-muted" style="margin-left:8px;font-size:12px;">${k.document_count || 0} docs</span>
|
||||
</div>
|
||||
<span class="text-muted" style="font-size:11px;">${fmtDate(k.created_at)}</span>
|
||||
${statusBadge(k.status || 'active')}
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
/**
|
||||
* Settings > Memory — personal memory management
|
||||
*/
|
||||
const { html } = window;
|
||||
const { useState, useEffect, useCallback } = hooks;
|
||||
|
||||
function fmtDate(d) {
|
||||
if (!d) return '\u2014';
|
||||
return new Date(d).toLocaleString(undefined, { dateStyle: 'short', timeStyle: 'short' });
|
||||
}
|
||||
|
||||
function truncate(s, len) {
|
||||
if (!s) return '';
|
||||
return s.length > len ? s.substring(0, len) + '\u2026' : s;
|
||||
}
|
||||
|
||||
export default function MemorySection() {
|
||||
const [memories, setMemories] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [count, setCount] = useState(0);
|
||||
const [editingId, setEditingId] = useState(null);
|
||||
const [editValue, setEditValue] = useState('');
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const [data, c] = await Promise.all([
|
||||
sw.api.memory.list(),
|
||||
sw.api.memory.count().catch(() => null),
|
||||
]);
|
||||
setMemories(data || []);
|
||||
if (c != null) setCount(typeof c === 'number' ? c : c.count || 0);
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
function startEdit(m) {
|
||||
setEditingId(m.id);
|
||||
setEditValue(m.value || m.content || '');
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
setEditingId(null);
|
||||
setEditValue('');
|
||||
}
|
||||
|
||||
async function saveEdit(id) {
|
||||
try {
|
||||
await sw.api.memory.update(id, { value: editValue, content: editValue });
|
||||
sw.toast('Memory updated', 'success');
|
||||
setEditingId(null);
|
||||
load();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function deleteMemory(id) {
|
||||
const ok = await sw.confirm('Delete this memory?', true);
|
||||
if (!ok) return;
|
||||
try {
|
||||
await sw.api.memory.del(id);
|
||||
sw.toast('Memory deleted', 'success');
|
||||
load();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
function statusBadge(status) {
|
||||
if (!status) return null;
|
||||
const cls = status === 'approved' ? 'badge-active'
|
||||
: status === 'rejected' ? 'badge-inactive'
|
||||
: 'badge';
|
||||
return html`<span class="badge ${cls}">${status}</span>`;
|
||||
}
|
||||
|
||||
if (loading) return html`<div class="settings-placeholder">Loading\u2026</div>`;
|
||||
|
||||
return html`
|
||||
<div>
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;">
|
||||
<span class="text-muted" style="font-size:12px;">${count || memories.length} memor${count === 1 ? 'y' : 'ies'}</span>
|
||||
<div style="flex:1"></div>
|
||||
</div>
|
||||
|
||||
<div class="admin-list">
|
||||
${memories.length === 0 && html`<div class="empty-hint">No memories stored.</div>`}
|
||||
${memories.map(m => html`
|
||||
<div class="admin-surface-row" key=${m.id} style="flex-direction:column;align-items:stretch;">
|
||||
${editingId === m.id ? html`
|
||||
<div>
|
||||
<textarea rows="3" style="width:100%;font-size:13px;margin-bottom:8px;"
|
||||
value=${editValue}
|
||||
onInput=${e => setEditValue(e.target.value)} />
|
||||
<div style="display:flex;gap:6px;">
|
||||
<button class="btn-small btn-primary" onClick=${() => saveEdit(m.id)}>Save</button>
|
||||
<button class="btn-small" onClick=${cancelEdit}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
` : html`
|
||||
<div style="display:flex;align-items:center;gap:8px;">
|
||||
<div style="flex:1;min-width:0;">
|
||||
${m.key && html`<strong style="margin-right:6px;">${m.key}</strong>`}
|
||||
<span style="font-size:13px;color:var(--text-2);">${truncate(m.value || m.content || '', 120)}</span>
|
||||
</div>
|
||||
${statusBadge(m.status)}
|
||||
<span class="text-muted" style="font-size:11px;white-space:nowrap;">${fmtDate(m.created_at)}</span>
|
||||
</div>
|
||||
<div style="display:flex;gap:6px;margin-top:6px;">
|
||||
<button class="btn-small" onClick=${() => startEdit(m)}>Edit</button>
|
||||
<button class="btn-small btn-danger" onClick=${() => deleteMemory(m.id)}>Delete</button>
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
/**
|
||||
* ModelsSection — user model list with visibility toggle
|
||||
*/
|
||||
const { html } = window;
|
||||
const { useState, useEffect, useCallback } = hooks;
|
||||
|
||||
function esc(s) { return s == null ? '' : String(s); }
|
||||
|
||||
export function ModelsSection() {
|
||||
const [models, setModels] = useState(null);
|
||||
const [prefs, setPrefs] = useState({});
|
||||
const [search, setSearch] = useState('');
|
||||
const [typeFilter, setTypeFilter] = useState('');
|
||||
const [provFilter, setProvFilter] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const [m, p] = await Promise.all([
|
||||
sw.api.models.enabled(),
|
||||
sw.api.models.preferences(),
|
||||
]);
|
||||
setModels(m.data || []);
|
||||
// Build prefs map: model_id → { hidden }
|
||||
const map = {};
|
||||
(p || []).forEach(x => { map[x.model_id] = x; });
|
||||
setPrefs(map);
|
||||
} catch (e) {
|
||||
console.warn('[settings/models]', e.message);
|
||||
setModels([]);
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
const toggleHidden = useCallback(async (model) => {
|
||||
const current = prefs[model.id]?.hidden || false;
|
||||
try {
|
||||
await sw.api.models.setPref(model.id, model.provider_config_id, !current);
|
||||
setPrefs(p => ({ ...p, [model.id]: { ...p[model.id], hidden: !current } }));
|
||||
} catch (e) {
|
||||
sw.emit('toast', { message: e.message, variant: 'error' });
|
||||
}
|
||||
}, [prefs]);
|
||||
|
||||
if (models === null) {
|
||||
return html`<div class="settings-placeholder">Loading models\u2026</div>`;
|
||||
}
|
||||
|
||||
// Derive filter options
|
||||
const types = [...new Set(models.map(m => m.model_type).filter(Boolean))];
|
||||
const providers = [...new Set(models.map(m => m.provider_name || m.provider).filter(Boolean))];
|
||||
|
||||
const filtered = models.filter(m => {
|
||||
const name = (m.display_name || m.model_id || '').toLowerCase();
|
||||
if (search && !name.includes(search.toLowerCase())) return false;
|
||||
if (typeFilter && m.model_type !== typeFilter) return false;
|
||||
if (provFilter && (m.provider_name || m.provider) !== provFilter) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
return html`
|
||||
<div style="display:flex;gap:8px;margin-bottom:16px;flex-wrap:wrap;">
|
||||
<input type="text" placeholder="Search models\u2026"
|
||||
style="flex:1;min-width:140px;"
|
||||
value=${search} onInput=${e => setSearch(e.target.value)} />
|
||||
${types.length > 1 && html`
|
||||
<select value=${typeFilter} onChange=${e => setTypeFilter(e.target.value)}>
|
||||
<option value="">All types</option>
|
||||
${types.map(t => html`<option value=${t}>${t}</option>`)}
|
||||
</select>
|
||||
`}
|
||||
${providers.length > 1 && html`
|
||||
<select value=${provFilter} onChange=${e => setProvFilter(e.target.value)}>
|
||||
<option value="">All providers</option>
|
||||
${providers.map(p => html`<option value=${p}>${p}</option>`)}
|
||||
</select>
|
||||
`}
|
||||
</div>
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr><th>Model</th><th>Type</th><th>Provider</th><th style="width:60px;">Visible</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${filtered.map(m => {
|
||||
const hidden = prefs[m.id]?.hidden || false;
|
||||
return html`
|
||||
<tr style=${hidden ? 'opacity:0.5' : ''}>
|
||||
<td style="font-weight:500;font-size:13px;">
|
||||
${esc(m.display_name || m.model_id)}
|
||||
</td>
|
||||
<td style="font-size:12px;color:var(--text-2);">
|
||||
${esc(m.model_type)}
|
||||
</td>
|
||||
<td style="font-size:12px;color:var(--text-2);">
|
||||
${esc(m.provider_name || m.provider)}
|
||||
</td>
|
||||
<td>
|
||||
<input type="checkbox" checked=${!hidden}
|
||||
onChange=${() => toggleHidden(m)} />
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
<div style="font-size:12px;color:var(--text-3);margin-top:8px;">
|
||||
${filtered.length} model${filtered.length !== 1 ? 's' : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
/**
|
||||
* PersonasSection — user persona list + create/edit
|
||||
*/
|
||||
const { html } = window;
|
||||
const { useState, useEffect, useCallback } = hooks;
|
||||
|
||||
function esc(s) { return s == null ? '' : String(s); }
|
||||
|
||||
export function PersonasSection() {
|
||||
const [personas, setPersonas] = useState(null);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [editId, setEditId] = useState(null);
|
||||
const [form, setForm] = useState({ name: '', description: '', system_prompt: '' });
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const resp = await sw.api.personas.list();
|
||||
setPersonas(resp || []);
|
||||
} catch (e) {
|
||||
setPersonas([]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const openAdd = useCallback(() => {
|
||||
setEditId(null);
|
||||
setForm({ name: '', description: '', system_prompt: '' });
|
||||
setShowForm(true);
|
||||
}, []);
|
||||
|
||||
const openEdit = useCallback((p) => {
|
||||
setEditId(p.id);
|
||||
setForm({ name: p.name || '', description: p.description || '', system_prompt: p.system_prompt || '' });
|
||||
setShowForm(true);
|
||||
}, []);
|
||||
|
||||
const cancel = useCallback(() => {
|
||||
setShowForm(false);
|
||||
setEditId(null);
|
||||
}, []);
|
||||
|
||||
const save = useCallback(async () => {
|
||||
if (!form.name.trim()) {
|
||||
sw.emit('toast', { message: 'Name is required', variant: 'error' });
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
if (editId) {
|
||||
await sw.api.personas.update(editId, form);
|
||||
sw.emit('toast', { message: 'Persona updated', variant: 'success' });
|
||||
} else {
|
||||
await sw.api.personas.create(form);
|
||||
sw.emit('toast', { message: 'Persona created', variant: 'success' });
|
||||
}
|
||||
setShowForm(false);
|
||||
setEditId(null);
|
||||
load();
|
||||
} catch (e) {
|
||||
sw.emit('toast', { message: e.message, variant: 'error' });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [form, editId, load]);
|
||||
|
||||
const del = useCallback(async (p) => {
|
||||
const ok = await sw.confirm(`Delete persona "${p.name}"?`);
|
||||
if (!ok) return;
|
||||
try {
|
||||
await sw.api.personas.del(p.id);
|
||||
sw.emit('toast', { message: 'Persona deleted', variant: 'success' });
|
||||
load();
|
||||
} catch (e) {
|
||||
sw.emit('toast', { message: e.message, variant: 'error' });
|
||||
}
|
||||
}, [load]);
|
||||
|
||||
const setField = useCallback((key, val) => {
|
||||
setForm(f => ({ ...f, [key]: val }));
|
||||
}, []);
|
||||
|
||||
if (personas === null) {
|
||||
return html`<div class="settings-placeholder">Loading personas\u2026</div>`;
|
||||
}
|
||||
|
||||
const canCreate = sw.can('persona.create');
|
||||
const canManage = sw.can('persona.manage');
|
||||
|
||||
return html`
|
||||
${canCreate && html`
|
||||
<div style="margin-bottom:12px;">
|
||||
<button class="btn-md btn-primary" onClick=${openAdd}>+ New Persona</button>
|
||||
</div>`}
|
||||
|
||||
${showForm && html`
|
||||
<div class="settings-section" style="margin-bottom:16px;">
|
||||
<h3>${editId ? 'Edit Persona' : 'New Persona'}</h3>
|
||||
<div class="form-group">
|
||||
<label>Name</label>
|
||||
<input type="text" value=${form.name}
|
||||
onInput=${e => setField('name', e.target.value)} />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Description</label>
|
||||
<input type="text" value=${form.description}
|
||||
onInput=${e => setField('description', e.target.value)} />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>System Prompt</label>
|
||||
<textarea rows="4" value=${form.system_prompt}
|
||||
onInput=${e => setField('system_prompt', e.target.value)}
|
||||
style="max-width:100%;" />
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;margin-top:12px;">
|
||||
<button class="btn-md btn-primary" disabled=${saving}
|
||||
onClick=${save}>
|
||||
${saving ? 'Saving\u2026' : editId ? 'Update' : 'Create'}
|
||||
</button>
|
||||
<button class="btn-md btn-secondary" onClick=${cancel}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
`}
|
||||
|
||||
${!personas.length && !showForm && html`
|
||||
<div class="empty-hint">No personas configured.</div>
|
||||
`}
|
||||
|
||||
${personas.length > 0 && html`
|
||||
<div>
|
||||
${personas.map(p => html`
|
||||
<div class="settings-section" style="margin-bottom:12px;">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;">
|
||||
<div>
|
||||
<strong style="font-size:14px;">${esc(p.name)}</strong>
|
||||
${p.description && html`
|
||||
<div style="font-size:12px;color:var(--text-2);margin-top:2px;">
|
||||
${esc(p.description)}
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
${canManage && html`
|
||||
<div style="display:flex;gap:4px;">
|
||||
<button class="icon-btn" title="Edit" onClick=${() => openEdit(p)}>
|
||||
\u{270F}\u{FE0F}
|
||||
</button>
|
||||
<button class="icon-btn icon-btn-danger" title="Delete"
|
||||
onClick=${() => del(p)}>
|
||||
\u{1F5D1}
|
||||
</button>
|
||||
</div>`}
|
||||
</div>
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
`}
|
||||
`;
|
||||
}
|
||||
@@ -1,213 +0,0 @@
|
||||
/**
|
||||
* ProvidersSection — BYOK provider CRUD (user-scoped)
|
||||
*/
|
||||
const { html } = window;
|
||||
const { useState, useEffect, useCallback } = hooks;
|
||||
|
||||
function esc(s) { return s == null ? '' : String(s); }
|
||||
|
||||
export function ProvidersSection() {
|
||||
const [providers, setProviders] = useState(null);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [editId, setEditId] = useState(null);
|
||||
const [form, setForm] = useState({ name: '', provider: 'openai', endpoint: '', api_key: '', model_default: '' });
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const resp = await sw.api.providers.list();
|
||||
setProviders(resp || []);
|
||||
} catch (e) {
|
||||
setProviders([]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const openAdd = useCallback(() => {
|
||||
setEditId(null);
|
||||
setForm({ name: '', provider: 'openai', endpoint: '', api_key: '', model_default: '' });
|
||||
setShowForm(true);
|
||||
}, []);
|
||||
|
||||
const openEdit = useCallback(async (prov) => {
|
||||
try {
|
||||
const cfg = await sw.api.providers.get(prov.id);
|
||||
setEditId(cfg.id);
|
||||
setForm({
|
||||
name: cfg.name || '',
|
||||
provider: cfg.provider || 'openai',
|
||||
endpoint: cfg.endpoint || '',
|
||||
api_key: '',
|
||||
model_default: cfg.model_default || '',
|
||||
});
|
||||
setShowForm(true);
|
||||
} catch (e) {
|
||||
sw.emit('toast', { message: e.message, variant: 'error' });
|
||||
}
|
||||
}, []);
|
||||
|
||||
const cancel = useCallback(() => {
|
||||
setShowForm(false);
|
||||
setEditId(null);
|
||||
}, []);
|
||||
|
||||
const save = useCallback(async () => {
|
||||
if (!form.name || !form.endpoint) {
|
||||
sw.emit('toast', { message: 'Name and endpoint are required', variant: 'error' });
|
||||
return;
|
||||
}
|
||||
if (!editId && !form.api_key) {
|
||||
sw.emit('toast', { message: 'API key is required', variant: 'error' });
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
if (editId) {
|
||||
const patch = { name: form.name, provider: form.provider, endpoint: form.endpoint, model_default: form.model_default };
|
||||
if (form.api_key) patch.api_key = form.api_key;
|
||||
await sw.api.providers.update(editId, patch);
|
||||
sw.emit('toast', { message: 'Provider updated', variant: 'success' });
|
||||
} else {
|
||||
const body = { name: form.name, provider: form.provider, endpoint: form.endpoint, api_key: form.api_key, model_default: form.model_default };
|
||||
const result = await sw.api.providers.create(body);
|
||||
const count = result?.models_fetched || 0;
|
||||
sw.emit('toast', {
|
||||
message: result?.warning || `Provider added \u2014 ${count} model${count !== 1 ? 's' : ''} synced`,
|
||||
variant: result?.warning ? 'warning' : 'success',
|
||||
});
|
||||
}
|
||||
setShowForm(false);
|
||||
setEditId(null);
|
||||
load();
|
||||
} catch (e) {
|
||||
sw.emit('toast', { message: e.message, variant: 'error' });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [form, editId, load]);
|
||||
|
||||
const del = useCallback(async (prov) => {
|
||||
const ok = await sw.confirm(`Remove provider "${prov.name}"?`);
|
||||
if (!ok) return;
|
||||
try {
|
||||
await sw.api.providers.del(prov.id);
|
||||
sw.emit('toast', { message: 'Provider removed', variant: 'success' });
|
||||
load();
|
||||
} catch (e) {
|
||||
sw.emit('toast', { message: e.message, variant: 'error' });
|
||||
}
|
||||
}, [load]);
|
||||
|
||||
const sync = useCallback(async (prov) => {
|
||||
sw.emit('toast', { message: `Fetching models for ${prov.name}\u2026`, variant: 'info' });
|
||||
try {
|
||||
const result = await sw.api.providers.fetchModels(prov.id);
|
||||
const total = result?.total || 0;
|
||||
sw.emit('toast', { message: `${prov.name}: ${total} model${total !== 1 ? 's' : ''} synced`, variant: 'success' });
|
||||
} catch (e) {
|
||||
sw.emit('toast', { message: e.message, variant: 'error' });
|
||||
}
|
||||
}, []);
|
||||
|
||||
const testConnection = useCallback(async (prov) => {
|
||||
sw.emit('toast', { message: `Testing ${prov.name}\u2026`, variant: 'info' });
|
||||
try {
|
||||
const result = await sw.api.providers.fetchModels(prov.id);
|
||||
const total = result?.total || 0;
|
||||
sw.emit('toast', { message: `${prov.name}: connection OK (${total} model${total !== 1 ? 's' : ''})`, variant: 'success' });
|
||||
} catch (e) {
|
||||
sw.emit('toast', { message: `${prov.name}: connection failed \u2014 ${e.message}`, variant: 'error' });
|
||||
}
|
||||
}, []);
|
||||
|
||||
const setField = useCallback((key, val) => {
|
||||
setForm(f => ({ ...f, [key]: val }));
|
||||
}, []);
|
||||
|
||||
if (providers === null) {
|
||||
return html`<div class="settings-placeholder">Loading providers\u2026</div>`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div style="margin-bottom:12px;">
|
||||
<button class="btn-md btn-primary" onClick=${openAdd}>+ Add Provider</button>
|
||||
</div>
|
||||
|
||||
${showForm && html`
|
||||
<div class="settings-section" style="margin-bottom:16px;">
|
||||
<h3>${editId ? 'Edit Provider' : 'Add Provider'}</h3>
|
||||
<div class="form-group">
|
||||
<label>Name</label>
|
||||
<input type="text" value=${form.name}
|
||||
onInput=${e => setField('name', e.target.value)} />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Provider Type</label>
|
||||
<select value=${form.provider}
|
||||
onChange=${e => setField('provider', e.target.value)}>
|
||||
<option value="openai">OpenAI-compatible</option>
|
||||
<option value="anthropic">Anthropic</option>
|
||||
<option value="openrouter">OpenRouter</option>
|
||||
<option value="venice">Venice AI</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Endpoint URL</label>
|
||||
<input type="text" placeholder="https://api.openai.com/v1"
|
||||
value=${form.endpoint}
|
||||
onInput=${e => setField('endpoint', e.target.value)} />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>API Key${editId ? ' (leave blank to keep current)' : ''}</label>
|
||||
<input type="password" placeholder="sk-..."
|
||||
value=${form.api_key}
|
||||
onInput=${e => setField('api_key', e.target.value)} />
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;margin-top:12px;">
|
||||
<button class="btn-md btn-primary" disabled=${saving}
|
||||
onClick=${save}>
|
||||
${saving ? 'Saving\u2026' : editId ? 'Update' : 'Add'}
|
||||
</button>
|
||||
<button class="btn-md btn-secondary" onClick=${cancel}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
`}
|
||||
|
||||
${!providers.length && !showForm && html`
|
||||
<div class="empty-hint">No providers configured.</div>
|
||||
`}
|
||||
|
||||
${providers.length > 0 && html`
|
||||
<table class="admin-table">
|
||||
<thead><tr><th>Provider</th><th>Type</th><th>Endpoint</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
${providers.map(p => html`
|
||||
<tr>
|
||||
<td style="font-weight:500;font-size:13px;">${esc(p.name)}</td>
|
||||
<td style="font-size:12px;color:var(--text-2);">${esc(p.provider)}</td>
|
||||
<td style="font-size:12px;color:var(--text-3);max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">
|
||||
${esc(p.endpoint)}
|
||||
</td>
|
||||
<td class="admin-actions-cell" style="white-space:nowrap;">
|
||||
<button class="icon-btn" title="Test connection" onClick=${() => testConnection(p)}>
|
||||
\u{26A1}
|
||||
</button>
|
||||
<button class="icon-btn" title="Sync models" onClick=${() => sync(p)}>
|
||||
\u{1F504}
|
||||
</button>
|
||||
<button class="icon-btn" title="Edit" onClick=${() => openEdit(p)}>
|
||||
\u{270F}\u{FE0F}
|
||||
</button>
|
||||
<button class="icon-btn icon-btn-danger" title="Delete"
|
||||
onClick=${() => del(p)}>
|
||||
\u{1F5D1}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
`)}
|
||||
</tbody>
|
||||
</table>
|
||||
`}
|
||||
`;
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
/**
|
||||
* RolesSection — user model role overrides (requires BYOK providers)
|
||||
*
|
||||
* Each "role" (primary, fallback) can be overridden to use a personal
|
||||
* provider + model instead of the org default.
|
||||
*/
|
||||
const { html } = window;
|
||||
const { useState, useEffect, useCallback } = hooks;
|
||||
|
||||
function esc(s) { return s == null ? '' : String(s); }
|
||||
|
||||
const ROLE_NAMES = ['primary', 'fallback'];
|
||||
|
||||
export function RolesSection() {
|
||||
const [providers, setProviders] = useState([]);
|
||||
const [models, setModels] = useState([]);
|
||||
const [roles, setRoles] = useState({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const [provResp, modResp, settings] = await Promise.all([
|
||||
sw.api.providers.list(),
|
||||
sw.api.models.enabled(),
|
||||
sw.api.profile.settings(),
|
||||
]);
|
||||
const provs = (provResp || [])
|
||||
.filter(c => c.scope === 'personal');
|
||||
setProviders(provs);
|
||||
setModels(modResp.data || []);
|
||||
|
||||
// Extract role overrides from settings
|
||||
const overrides = settings?.model_roles || {};
|
||||
setRoles(overrides);
|
||||
} catch (e) {
|
||||
console.warn('[settings/roles]', e.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
const setRoleProvider = useCallback((role, provId) => {
|
||||
setRoles(r => ({
|
||||
...r,
|
||||
[role]: { ...(r[role] || {}), provider_config_id: provId, model_id: '' },
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const setRoleModel = useCallback((role, modelId) => {
|
||||
setRoles(r => ({
|
||||
...r,
|
||||
[role]: { ...(r[role] || {}), model_id: modelId },
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const save = useCallback(async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await sw.api.profile.updateSettings({ model_roles: roles });
|
||||
sw.emit('toast', { message: 'Roles saved', variant: 'success' });
|
||||
} catch (e) {
|
||||
sw.emit('toast', { message: e.message, variant: 'error' });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [roles]);
|
||||
|
||||
if (loading) {
|
||||
return html`<div class="settings-placeholder">Loading\u2026</div>`;
|
||||
}
|
||||
|
||||
if (!providers.length) {
|
||||
return html`
|
||||
<div class="settings-section">
|
||||
<p style="color:var(--text-2);font-size:13px;">
|
||||
Add a personal provider first to configure model role overrides.
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return html`
|
||||
${ROLE_NAMES.map(role => {
|
||||
const r = roles[role] || {};
|
||||
const provId = r.provider_config_id || '';
|
||||
const modelId = r.model_id || '';
|
||||
const filteredModels = provId
|
||||
? models.filter(m => m.provider_config_id === provId)
|
||||
: [];
|
||||
return html`
|
||||
<div class="settings-section" style="margin-bottom:16px;">
|
||||
<h3 style="text-transform:capitalize;">${role}</h3>
|
||||
<div style="display:flex;gap:12px;">
|
||||
<div class="form-group" style="flex:1;">
|
||||
<label>Provider</label>
|
||||
<select value=${provId}
|
||||
onChange=${e => setRoleProvider(role, e.target.value)}>
|
||||
<option value="">\u2014 use org default \u2014</option>
|
||||
${providers.map(p => html`
|
||||
<option value=${p.id}>${esc(p.name)}</option>
|
||||
`)}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group" style="flex:1;">
|
||||
<label>Model</label>
|
||||
<select value=${modelId} disabled=${!provId}
|
||||
onChange=${e => setRoleModel(role, e.target.value)}>
|
||||
<option value="">\u2014 select model \u2014</option>
|
||||
${filteredModels.map(m => html`
|
||||
<option value=${m.model_id || m.id}>
|
||||
${esc(m.display_name || m.model_id || m.id)}
|
||||
</option>
|
||||
`)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
})}
|
||||
<button class="btn-md btn-primary" disabled=${saving} onClick=${save}>
|
||||
${saving ? 'Saving\u2026' : 'Save Roles'}
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
/**
|
||||
* Settings > Tasks — user's tasks with run/stop controls
|
||||
*/
|
||||
const { html } = window;
|
||||
const { useState, useEffect, useCallback } = hooks;
|
||||
|
||||
function fmtDate(d) {
|
||||
if (!d) return '\u2014';
|
||||
return new Date(d).toLocaleString(undefined, { dateStyle: 'short', timeStyle: 'short' });
|
||||
}
|
||||
|
||||
export default function TasksSection() {
|
||||
const [tasks, setTasks] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const data = await sw.api.tasks.list();
|
||||
setTasks(data || []);
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
async function runTask(id) {
|
||||
try {
|
||||
await sw.api.tasks.start(id);
|
||||
sw.toast('Task started', 'success');
|
||||
load();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function stopTask(id) {
|
||||
try {
|
||||
await sw.api.tasks.stop(id);
|
||||
sw.toast('Task stopped', 'success');
|
||||
load();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
if (loading) return html`<div class="settings-placeholder">Loading\u2026</div>`;
|
||||
|
||||
return html`
|
||||
<div>
|
||||
<div class="admin-list">
|
||||
${tasks.length === 0 && html`<div class="empty-hint">No tasks.</div>`}
|
||||
${tasks.map(t => html`
|
||||
<div class="admin-surface-row" key=${t.id}>
|
||||
<div style="flex:1;min-width:0;">
|
||||
<strong>${t.name}</strong>
|
||||
${t.schedule && html`
|
||||
<span class="badge" style="margin-left:6px;">${t.schedule}</span>
|
||||
`}
|
||||
</div>
|
||||
<span class="text-muted" style="font-size:11px;">
|
||||
last: ${fmtDate(t.last_run_at)} \u2022 next: ${fmtDate(t.next_run_at)}
|
||||
</span>
|
||||
<span class="badge ${t.is_active ? 'badge-active' : 'badge-inactive'}">
|
||||
${t.is_active ? 'running' : 'stopped'}
|
||||
</span>
|
||||
<div style="display:flex;gap:6px;">
|
||||
<button class="btn-small btn-primary" onClick=${() => runTask(t.id)}>Run</button>
|
||||
<button class="btn-small btn-ghost" onClick=${() => stopTask(t.id)}>Stop</button>
|
||||
</div>
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
/**
|
||||
* UsageSection — personal usage statistics
|
||||
*/
|
||||
const { html } = window;
|
||||
const { useState, useEffect } = hooks;
|
||||
|
||||
function esc(s) { return s == null ? '' : String(s); }
|
||||
function fmt(n) { return n == null ? '0' : n.toLocaleString(); }
|
||||
|
||||
export function UsageSection() {
|
||||
const [usage, setUsage] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Use the generic API escape hatch since there's no dedicated namespace
|
||||
sw.api.get('/api/v1/profile/usage').then(data => {
|
||||
setUsage(data);
|
||||
}).catch(() => setUsage({}));
|
||||
}, []);
|
||||
|
||||
if (usage === null) {
|
||||
return html`<div class="settings-placeholder">Loading usage\u2026</div>`;
|
||||
}
|
||||
|
||||
const totals = usage.totals || usage;
|
||||
const byModel = usage.by_model || [];
|
||||
|
||||
return html`
|
||||
<div class="settings-section">
|
||||
<h3>Totals</h3>
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:12px;">
|
||||
${[
|
||||
['Requests', totals.request_count],
|
||||
['Input Tokens', totals.input_tokens],
|
||||
['Output Tokens', totals.output_tokens],
|
||||
['Total Tokens', totals.total_tokens],
|
||||
].map(([label, val]) => html`
|
||||
<div style="background:var(--bg-raised);border-radius:8px;padding:14px;">
|
||||
<div style="font-size:11px;color:var(--text-3);margin-bottom:4px;">${label}</div>
|
||||
<div style="font-size:18px;font-weight:600;">${fmt(val)}</div>
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${byModel.length > 0 && html`
|
||||
<div class="settings-section" style="margin-top:16px;">
|
||||
<h3>By Model</h3>
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr><th>Model</th><th>Requests</th><th>Tokens</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${byModel.map(m => html`
|
||||
<tr>
|
||||
<td style="font-size:13px;">${esc(m.model_id || m.model)}</td>
|
||||
<td style="font-size:12px;">${fmt(m.request_count)}</td>
|
||||
<td style="font-size:12px;">${fmt(m.total_tokens)}</td>
|
||||
</tr>
|
||||
`)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
`}
|
||||
`;
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
/**
|
||||
* Settings > Workflows — v0.37.15 rewrite
|
||||
*
|
||||
* Replaces read-only workflow list with MyAssignments (E4).
|
||||
* Shows the user's personal assignment queue across all teams.
|
||||
*/
|
||||
const { html } = window;
|
||||
const { useState, useEffect, useCallback } = hooks;
|
||||
|
||||
function _timeAgo(ts) {
|
||||
if (!ts) return '';
|
||||
const diff = Date.now() - new Date(ts).getTime();
|
||||
const mins = Math.floor(diff / 60000);
|
||||
if (mins < 1) return 'just now';
|
||||
if (mins < 60) return `${mins}m ago`;
|
||||
const hrs = Math.floor(mins / 60);
|
||||
if (hrs < 24) return `${hrs}h ago`;
|
||||
return `${Math.floor(hrs / 24)}d ago`;
|
||||
}
|
||||
|
||||
export default function WorkflowsSection() {
|
||||
const [assignments, setAssignments] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const data = await sw.api.workflowAssignments.mine();
|
||||
setAssignments(data || []);
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
const userId = sw.auth?.user?.id;
|
||||
const mine = assignments.filter(a => a.status === 'claimed' && a.assigned_to === userId);
|
||||
const available = assignments.filter(a => a.status === 'unassigned');
|
||||
|
||||
async function claim(id) {
|
||||
try {
|
||||
await sw.api.workflowAssignments.claim(id);
|
||||
load();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function unclaim(id) {
|
||||
try {
|
||||
await sw.api.workflowAssignments.unclaim(id);
|
||||
load();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function complete(id) {
|
||||
try {
|
||||
await sw.api.workflowAssignments.complete(id);
|
||||
sw.toast('Assignment completed', 'success');
|
||||
load();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
if (loading) return html`<div class="settings-placeholder">Loading\u2026</div>`;
|
||||
|
||||
return html`
|
||||
<div>
|
||||
${mine.length > 0 && html`
|
||||
<h4 style="margin:0 0 8px;">Claimed (${mine.length})</h4>
|
||||
<div class="admin-list">
|
||||
${mine.map(a => html`
|
||||
<${AssignmentRow} key=${a.id} assignment=${a} onAction=${load} showTeam />
|
||||
`)}
|
||||
</div>
|
||||
`}
|
||||
|
||||
${available.length > 0 && html`
|
||||
<h4 style="margin:${mine.length > 0 ? '16px' : '0'} 0 8px;">Available (${available.length})</h4>
|
||||
<div class="admin-list">
|
||||
${available.map(a => html`
|
||||
<${AssignmentRow} key=${a.id} assignment=${a} onAction=${load} showTeam />
|
||||
`)}
|
||||
</div>
|
||||
`}
|
||||
|
||||
${mine.length === 0 && available.length === 0 && html`
|
||||
<div class="empty-hint">No assignments</div>
|
||||
`}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function AssignmentRow({ assignment: a, onAction, showTeam }) {
|
||||
const userId = sw.auth?.user?.id;
|
||||
const isMine = a.assigned_to === userId;
|
||||
|
||||
async function claim() {
|
||||
try {
|
||||
await sw.api.workflowAssignments.claim(a.id);
|
||||
onAction();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function unclaim() {
|
||||
try {
|
||||
await sw.api.workflowAssignments.unclaim(a.id);
|
||||
onAction();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function complete() {
|
||||
try {
|
||||
await sw.api.workflowAssignments.complete(a.id);
|
||||
sw.toast('Assignment completed', 'success');
|
||||
onAction();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="admin-surface-row" style="${isMine ? 'border-left:3px solid var(--accent-1);' : ''}">
|
||||
<div style="flex:1;min-width:0;">
|
||||
<strong>${a.workflow_name || 'Workflow'}</strong>
|
||||
${showTeam && a.team_name && html`<span class="badge">${a.team_name}</span>`}
|
||||
<span class="badge">${a.stage_name || `Stage ${a.stage}`}</span>
|
||||
${a.sla_breached && html`<span class="badge badge-danger">SLA</span>`}
|
||||
<span class="text-muted" style="font-size:11px;">${_timeAgo(a.created_at)}</span>
|
||||
</div>
|
||||
<div style="display:flex;gap:4px;">
|
||||
${a.status === 'unassigned' && html`
|
||||
<button class="btn-small btn-primary" onClick=${claim}>Claim</button>
|
||||
`}
|
||||
${isMine && html`
|
||||
<button class="btn-small" onClick=${unclaim}>Release</button>
|
||||
<button class="btn-small btn-primary"
|
||||
onClick=${() => { location.href = sw.base + '/w/' + a.channel_id; }}>Open</button>
|
||||
<button class="btn-small btn-success" onClick=${complete}>Complete</button>
|
||||
`}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -18,32 +18,22 @@ import { DialogStack } from '../../shell/dialog-stack.js';
|
||||
|
||||
// ── Section config ──────────────────────────
|
||||
const SECTIONS = [
|
||||
{ key: 'members', label: 'Members' },
|
||||
{ key: 'providers', label: 'Providers' },
|
||||
{ key: 'members', label: 'Members' },
|
||||
{ key: 'groups', label: 'Groups' },
|
||||
{ key: 'connections', label: 'Connections' },
|
||||
{ key: 'personas', label: 'Personas' },
|
||||
{ key: 'knowledge', label: 'Knowledge' },
|
||||
{ key: 'groups', label: 'Groups' },
|
||||
{ key: 'workflows', label: 'Workflows' },
|
||||
{ key: 'tasks', label: 'Tasks' },
|
||||
{ key: 'settings', label: 'Settings' },
|
||||
{ key: 'usage', label: 'Usage' },
|
||||
{ key: 'activity', label: 'Activity' },
|
||||
{ key: 'workflows', label: 'Workflows' },
|
||||
{ key: 'settings', label: 'Settings' },
|
||||
{ key: 'activity', label: 'Activity' },
|
||||
];
|
||||
|
||||
// ── Lazy section imports ────────────────────
|
||||
const sectionModules = {
|
||||
members: () => import('./members.js'),
|
||||
providers: () => import('./providers.js'),
|
||||
members: () => import('./members.js'),
|
||||
groups: () => import('./groups.js'),
|
||||
connections: () => import('./connections.js'),
|
||||
personas: () => import('./personas.js'),
|
||||
knowledge: () => import('./knowledge.js'),
|
||||
groups: () => import('./groups.js'),
|
||||
workflows: () => import('./workflows.js'),
|
||||
tasks: () => import('./tasks.js'),
|
||||
settings: () => import('./settings.js'),
|
||||
usage: () => import('./usage.js'),
|
||||
activity: () => import('./activity.js'),
|
||||
workflows: () => import('./workflows.js'),
|
||||
settings: () => import('./settings.js'),
|
||||
activity: () => import('./activity.js'),
|
||||
};
|
||||
|
||||
// ── v0.38.3: Extension config sections ──────
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
/**
|
||||
* Team Admin > Knowledge
|
||||
*/
|
||||
const { html } = window;
|
||||
const { useState, useEffect, useCallback } = hooks;
|
||||
|
||||
export default function KnowledgeSection({ teamId }) {
|
||||
const [kbs, setKbs] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const data = await sw.api.teams.knowledgeBases(teamId);
|
||||
setKbs(data || []);
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
finally { setLoading(false); }
|
||||
}, [teamId]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
function fmtDate(d) {
|
||||
if (!d) return '\u2014';
|
||||
return new Date(d).toLocaleString(undefined, { dateStyle: 'short', timeStyle: 'short' });
|
||||
}
|
||||
|
||||
if (loading) return html`<div class="settings-placeholder">Loading\u2026</div>`;
|
||||
|
||||
return html`
|
||||
<div>
|
||||
<div class="admin-list">
|
||||
${kbs.length === 0 && html`<div class="empty-hint">No knowledge bases</div>`}
|
||||
${kbs.map(k => html`
|
||||
<div class="admin-surface-row" key=${k.id}>
|
||||
<div style="flex:1;min-width:0;">
|
||||
<strong>${k.name}</strong>
|
||||
<span class="text-muted" style="margin-left:8px;font-size:12px;">${k.document_count || 0} docs</span>
|
||||
</div>
|
||||
<span class="text-muted" style="font-size:11px;">${fmtDate(k.created_at)}</span>
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
/**
|
||||
* Team Admin > Personas
|
||||
*/
|
||||
const { html } = window;
|
||||
const { useState, useEffect, useCallback } = hooks;
|
||||
|
||||
export default function PersonasSection({ teamId }) {
|
||||
const [personas, setPersonas] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editing, setEditing] = useState(null); // null | 'new' | persona object
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const data = await sw.api.teams.personas(teamId);
|
||||
setPersonas(data || []);
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
finally { setLoading(false); }
|
||||
}, [teamId]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
async function savePersona(e) {
|
||||
e.preventDefault();
|
||||
const form = e.target;
|
||||
const data = {
|
||||
name: form.pname.value.trim(),
|
||||
description: form.description.value.trim(),
|
||||
system_prompt: form.system_prompt.value,
|
||||
};
|
||||
try {
|
||||
if (editing === 'new') {
|
||||
await sw.api.teams.createPersona(teamId, data);
|
||||
sw.toast('Persona created', 'success');
|
||||
} else {
|
||||
await sw.api.teams.updatePersona(teamId, editing.id, data);
|
||||
sw.toast('Persona updated', 'success');
|
||||
}
|
||||
setEditing(null);
|
||||
load();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function deletePersona(pid) {
|
||||
const ok = await sw.confirm('Delete this persona?', true);
|
||||
if (!ok) return;
|
||||
try {
|
||||
await sw.api.teams.deletePersona(teamId, pid);
|
||||
sw.toast('Persona deleted', 'success');
|
||||
load();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
if (loading) return html`<div class="settings-placeholder">Loading\u2026</div>`;
|
||||
|
||||
if (editing) {
|
||||
const isNew = editing === 'new';
|
||||
const p = isNew ? {} : editing;
|
||||
return html`
|
||||
<form onSubmit=${savePersona}>
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;">
|
||||
<button type="button" class="btn-small" onClick=${() => setEditing(null)}>\u2190 Back</button>
|
||||
<h4 style="margin:0;">${isNew ? 'Create Persona' : `Edit: ${p.name}`}</h4>
|
||||
</div>
|
||||
<div class="form-group"><label>Name</label>
|
||||
<input name="pname" value=${p.name || ''} placeholder="Code Reviewer" />
|
||||
</div>
|
||||
<div class="form-group"><label>Description</label>
|
||||
<input name="description" value=${p.description || ''} placeholder="Optional description" />
|
||||
</div>
|
||||
<div class="form-group"><label>System Prompt</label>
|
||||
<textarea name="system_prompt" rows="6" placeholder="System prompt\u2026">${p.system_prompt || ''}</textarea>
|
||||
</div>
|
||||
<div class="form-row" style="margin-top:12px;gap:8px;">
|
||||
<button type="submit" class="btn-small btn-primary">${isNew ? 'Create' : 'Save'}</button>
|
||||
<button type="button" class="btn-small" onClick=${() => setEditing(null)}>Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div>
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;">
|
||||
<div style="flex:1"></div>
|
||||
<button class="btn-md btn-primary" onClick=${() => setEditing('new')}>+ Add</button>
|
||||
</div>
|
||||
|
||||
<div class="admin-list">
|
||||
${personas.length === 0 && html`<div class="empty-hint">No personas</div>`}
|
||||
${personas.map(p => html`
|
||||
<div class="admin-surface-row" key=${p.id}>
|
||||
<div style="flex:1;min-width:0;">
|
||||
<strong>${p.name}</strong>
|
||||
${p.description && html`<span class="text-muted" style="margin-left:8px;font-size:11px;">${p.description.substring(0, 60)}${p.description.length > 60 ? '\u2026' : ''}</span>`}
|
||||
</div>
|
||||
<span class="text-muted" style="font-size:11px;">${p.kb_count || 0} KBs</span>
|
||||
<div class="admin-actions-cell">
|
||||
<button class="btn-small" onClick=${() => setEditing(p)}>Edit</button>
|
||||
<button class="btn-small btn-danger" onClick=${() => deletePersona(p.id)}>Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
/**
|
||||
* Team Admin > Providers
|
||||
*/
|
||||
const { html } = window;
|
||||
const { useState, useEffect, useCallback } = hooks;
|
||||
|
||||
export default function ProvidersSection({ teamId }) {
|
||||
const [configs, setConfigs] = useState([]);
|
||||
const [providerTypes, setProviderTypes] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editing, setEditing] = useState(null); // null | 'new' | config object
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const [c, t] = await Promise.all([
|
||||
sw.api.teams.providers(teamId),
|
||||
sw.api.admin.providers.types().catch(() => ({ types: [] })),
|
||||
]);
|
||||
setConfigs(c.data || []);
|
||||
setProviderTypes(t || []);
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
finally { setLoading(false); }
|
||||
}, [teamId]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
async function saveProvider(e) {
|
||||
e.preventDefault();
|
||||
const form = e.target;
|
||||
const data = {
|
||||
name: form.pname.value.trim(),
|
||||
provider: form.provider.value,
|
||||
endpoint: form.endpoint.value.trim(),
|
||||
api_key: form.api_key.value || undefined,
|
||||
};
|
||||
try {
|
||||
if (editing === 'new') {
|
||||
await sw.api.teams.createProvider(teamId, data);
|
||||
sw.toast('Provider created', 'success');
|
||||
} else {
|
||||
await sw.api.teams.updateProvider(teamId, editing.id, data);
|
||||
sw.toast('Provider updated', 'success');
|
||||
}
|
||||
setEditing(null);
|
||||
load();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function syncModels(pid) {
|
||||
try {
|
||||
const result = await sw.api.teams.providerModels(teamId, pid);
|
||||
sw.toast(`Models synced: ${result.added || result.count || 0}`, 'success');
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function deleteProvider(pid) {
|
||||
const ok = await sw.confirm('Delete this provider config?', true);
|
||||
if (!ok) return;
|
||||
try {
|
||||
await sw.api.teams.deleteProvider(teamId, pid);
|
||||
sw.toast('Provider deleted', 'success');
|
||||
load();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
if (loading) return html`<div class="settings-placeholder">Loading\u2026</div>`;
|
||||
|
||||
return html`
|
||||
<div>
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;">
|
||||
<div style="flex:1"></div>
|
||||
<button class="btn-md btn-primary" onClick=${() => setEditing('new')}>+ Add</button>
|
||||
</div>
|
||||
|
||||
${editing && html`
|
||||
<form class="admin-inline-form" style="margin-bottom:16px;padding:12px;border:1px solid var(--border);border-radius:var(--radius);" onSubmit=${saveProvider}>
|
||||
<h4 style="margin:0 0 12px;">${editing === 'new' ? 'Add Provider' : 'Edit Provider'}</h4>
|
||||
<div class="form-row">
|
||||
<div class="form-group"><label>Name</label>
|
||||
<input name="pname" placeholder="My OpenAI" value=${editing !== 'new' ? editing.name || '' : ''} />
|
||||
</div>
|
||||
<div class="form-group"><label>Type</label>
|
||||
<select name="provider">
|
||||
${providerTypes.map(t => html`<option key=${t.name} value=${t.name} selected=${editing !== 'new' && editing.provider === t.name}>${t.display_name || t.name}</option>`)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group" style="flex:2;"><label>Endpoint</label>
|
||||
<input name="endpoint" placeholder="https://api.openai.com/v1" value=${editing !== 'new' ? editing.endpoint || '' : ''} />
|
||||
</div>
|
||||
<div class="form-group" style="flex:1;"><label>API Key</label>
|
||||
<input name="api_key" type="password" placeholder="${editing !== 'new' ? '(unchanged)' : 'sk-...'}" autocomplete="off" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row" style="margin-top:12px;gap:8px;">
|
||||
<button type="submit" class="btn-small btn-primary">${editing === 'new' ? 'Create' : 'Save'}</button>
|
||||
<button type="button" class="btn-small" onClick=${() => setEditing(null)}>Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
`}
|
||||
|
||||
<div class="provider-cards">
|
||||
${configs.length === 0 && html`<div class="empty-hint">No provider configs</div>`}
|
||||
${configs.map(c => html`
|
||||
<div class="provider-card" key=${c.id}>
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;">
|
||||
<strong>${c.name}</strong>
|
||||
<span class="badge">${c.provider}</span>
|
||||
${c.has_key ? html`<span class="badge badge-active">key set</span>` : html`<span class="badge badge-inactive">no key</span>`}
|
||||
</div>
|
||||
<div class="text-muted" style="font-size:12px;margin-bottom:8px;word-break:break-all;">${c.endpoint || '(default)'}</div>
|
||||
<div style="display:flex;gap:6px;">
|
||||
<button class="btn-small" onClick=${() => setEditing(c)}>Edit</button>
|
||||
<button class="btn-small" onClick=${() => syncModels(c.id)}>Sync Models</button>
|
||||
<button class="btn-small btn-danger" onClick=${() => deleteProvider(c.id)}>Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
/**
|
||||
* Team Admin > Tasks
|
||||
*/
|
||||
const { html } = window;
|
||||
const { useState, useEffect, useCallback } = hooks;
|
||||
|
||||
export default function TasksSection({ teamId }) {
|
||||
const [tasks, setTasks] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const data = await sw.api.teams.tasks(teamId);
|
||||
setTasks(data || []);
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
finally { setLoading(false); }
|
||||
}, [teamId]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
async function createTask(e) {
|
||||
e.preventDefault();
|
||||
const form = e.target;
|
||||
try {
|
||||
await sw.api.teams.createTask(teamId, {
|
||||
name: form.name.value.trim(),
|
||||
schedule: form.schedule.value.trim(),
|
||||
description: form.description.value.trim(),
|
||||
});
|
||||
sw.toast('Task created', 'success');
|
||||
setShowCreate(false);
|
||||
load();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function runTask(id) {
|
||||
try {
|
||||
await sw.api.teams.runTask(teamId, id);
|
||||
sw.toast('Task triggered', 'success');
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function killTask(id) {
|
||||
try {
|
||||
await sw.api.teams.killTask(teamId, id);
|
||||
sw.toast('Task cancelled', 'success');
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function deleteTask(id) {
|
||||
const ok = await sw.confirm('Delete this task?', true);
|
||||
if (!ok) return;
|
||||
try {
|
||||
await sw.api.teams.deleteTask(teamId, id);
|
||||
sw.toast('Task deleted', 'success');
|
||||
load();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
function fmtDate(d) {
|
||||
if (!d) return '\u2014';
|
||||
return new Date(d).toLocaleString(undefined, { dateStyle: 'short', timeStyle: 'short' });
|
||||
}
|
||||
|
||||
if (loading) return html`<div class="settings-placeholder">Loading\u2026</div>`;
|
||||
|
||||
return html`
|
||||
<div>
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;">
|
||||
<div style="flex:1"></div>
|
||||
<button class="btn-md btn-primary" onClick=${() => setShowCreate(true)}>+ Add</button>
|
||||
</div>
|
||||
|
||||
${showCreate && html`
|
||||
<form class="admin-inline-form" style="margin-bottom:16px;" onSubmit=${createTask}>
|
||||
<div class="form-row">
|
||||
<div class="form-group"><label>Name</label><input name="name" placeholder="Daily Sync" /></div>
|
||||
<div class="form-group"><label>Schedule</label><input name="schedule" placeholder="0 9 * * *" /></div>
|
||||
</div>
|
||||
<div class="form-group"><label>Description</label><input name="description" placeholder="Optional" /></div>
|
||||
<div class="form-row" style="margin-top:8px;gap:8px;">
|
||||
<button type="submit" class="btn-small btn-primary">Create</button>
|
||||
<button type="button" class="btn-small" onClick=${() => setShowCreate(false)}>Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
`}
|
||||
|
||||
<div class="admin-list">
|
||||
${tasks.length === 0 && html`<div class="empty-hint">No tasks</div>`}
|
||||
${tasks.map(t => html`
|
||||
<div class="admin-surface-row" key=${t.id}>
|
||||
<div style="flex:1;min-width:0;">
|
||||
<strong>${t.name}</strong>
|
||||
${t.schedule && html`<span class="badge" style="margin-left:6px;">${t.schedule}</span>`}
|
||||
</div>
|
||||
<span class="text-muted" style="font-size:11px;">
|
||||
last: ${fmtDate(t.last_run_at)} \u2022 next: ${fmtDate(t.next_run_at)}
|
||||
</span>
|
||||
<span class="badge ${t.is_active ? 'badge-active' : 'badge-inactive'}">${t.is_active ? 'active' : 'paused'}</span>
|
||||
<div class="admin-actions-cell">
|
||||
<button class="btn-small btn-primary" onClick=${() => runTask(t.id)}>Run</button>
|
||||
<button class="btn-small" onClick=${() => killTask(t.id)}>Kill</button>
|
||||
<button class="btn-small btn-danger" onClick=${() => deleteTask(t.id)}>Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
/**
|
||||
* Team Admin > Usage
|
||||
*/
|
||||
const { html } = window;
|
||||
const { useState, useEffect, useCallback } = hooks;
|
||||
|
||||
export default function UsageSection({ teamId }) {
|
||||
const [usage, setUsage] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const data = await sw.api.teams.usage(teamId);
|
||||
setUsage(data);
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
finally { setLoading(false); }
|
||||
}, [teamId]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
function fmtNum(n) {
|
||||
if (n == null) return '\u2014';
|
||||
return n.toLocaleString();
|
||||
}
|
||||
|
||||
if (loading) return html`<div class="settings-placeholder">Loading\u2026</div>`;
|
||||
|
||||
return html`
|
||||
<div>
|
||||
${usage?.totals && html`
|
||||
<div class="stat-cards-grid" style="margin-bottom:16px;">
|
||||
<div class="stat-card"><div class="stat-value">${fmtNum(usage.totals.total_tokens)}</div><div class="stat-label">Total Tokens</div></div>
|
||||
<div class="stat-card"><div class="stat-value">${fmtNum(usage.totals.total_requests)}</div><div class="stat-label">Total Requests</div></div>
|
||||
<div class="stat-card"><div class="stat-value">$${(usage.totals.total_cost || 0).toFixed(2)}</div><div class="stat-label">Total Cost</div></div>
|
||||
</div>
|
||||
`}
|
||||
|
||||
${usage?.results && usage.results.length > 0 && html`
|
||||
<h4 style="margin:16px 0 8px;">Usage by Day</h4>
|
||||
<div class="admin-list">
|
||||
${usage.results.map((r, i) => html`
|
||||
<div class="admin-surface-row" key=${i}>
|
||||
<span style="width:100px;">${r.date || r.day || '\u2014'}</span>
|
||||
<span style="flex:1;">${fmtNum(r.tokens)} tokens</span>
|
||||
<span class="text-muted" style="font-size:12px;">${r.requests || 0} requests</span>
|
||||
<span class="text-muted" style="font-size:12px;">$${(r.cost || 0).toFixed(2)}</span>
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
`}
|
||||
|
||||
${(!usage?.totals && !usage?.results) && html`
|
||||
<div class="empty-hint">No usage data available</div>
|
||||
`}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
Reference in New Issue
Block a user