Changeset 0.37.5 (#217)

Co-authored-by: gobha <jasafpro@gmail.com>
Co-committed-by: gobha <jasafpro@gmail.com>
This commit is contained in:
2026-03-21 13:41:25 +00:00
committed by xcaliber
parent 05b5affdac
commit 74f3cb84e9
27 changed files with 2211 additions and 667 deletions

View File

@@ -237,6 +237,7 @@ export function createAuth() {
window.location.href = base + '/login';
},
_setAuth(data) { _setAuth(data); },
_setRestClient(client) { _restClient = client; },
_setEmit(fn) { _emit = fn; },
};

View File

@@ -93,10 +93,21 @@ export async function boot() {
sw.pipe = pipe;
sw.theme = theme;
// Shell helpers — imperative confirm/prompt backed by DialogStack
// Shell helpers — imperative confirm/prompt backed by primitives
sw.confirm = confirm;
sw.prompt = prompt;
// Toast — dynamic import to avoid module resolution issues
try {
const toastMod = await import('../primitives/toast.js');
sw.toast = toastMod.toast;
events.on('toast', ({ message, variant, duration }) => {
toastMod.toast(message, variant, duration);
});
} catch (e) {
console.warn('[sw] Toast import failed:', e.message);
}
// UserMenu render helper — surfaces call sw.userMenu(container, opts)
sw.userMenu = function (container, opts = {}) {
import('../shell/user-menu.js').then(({ UserMenu }) => {
@@ -106,7 +117,7 @@ export async function boot() {
};
// Marker for idempotency
sw._sdk = '0.37.4';
sw._sdk = '0.37.5';
// 8. Expose globally
window.sw = sw;
@@ -128,7 +139,7 @@ export async function boot() {
// 10. Signal ready
events.emit('sdk.ready', {}, { localOnly: true });
document.dispatchEvent(new CustomEvent('sw:ready', { detail: { sw } }));
console.log('[sw] SDK v0.37.4 ready');
console.log('[sw] SDK v0.37.5 ready');
return sw;
}

View File

@@ -0,0 +1,55 @@
/**
* Hero — left panel with branding, feature pills, version
*
* Props:
* base — base path for assets (string)
* version — app version (string)
* env — environment label or null
*/
const { html } = window;
const PILLS = [
{ icon: '\u{1F916}', label: 'Multi-Provider AI', cls: 'ac' },
{ icon: '\u{1F511}', label: 'Bring Your Own Key', cls: 'pu' },
{ icon: '\u{1F465}', label: 'Teams & Projects', cls: 'ac' },
{ icon: '\u{1F9E9}', label: 'Extensions', cls: 'pu' },
{ icon: '\u{1F50D}', label: 'Web Search Tools', cls: 'ac' },
{ icon: '\u{1F4DD}', label: 'Notes & Memory', cls: 'pu' },
];
export function Hero({ base = '', version = '', env = null }) {
return html`
<div class="login-hero">
<div class="login-hero-grid"></div>
<div class="login-hero-orb"></div>
<div class="login-hero-content">
<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" />
<span class="login-hero-brand-text">
Chat <span class="hl">Switchboard</span>
</span>
</div>
<h1 class="login-anim login-anim-2">
Your AI conversations,${html`<br />`}your infrastructure
</h1>
<p class="login-hero-sub login-anim login-anim-3">
Self-hosted multi-provider chat with team collaboration,
BYOK privacy, and an extensible plugin system.
</p>
<div class="login-hero-pills login-anim login-anim-4">
${PILLS.map(p => html`
<span class="login-hero-pill ${p.cls}">
<span class="login-hero-pill-icon">${p.icon}</span>
${' '}${p.label}
</span>
`)}
</div>
<p class="login-hero-version login-anim login-anim-5">
v${version}${env ? html` \u00B7 ${env}` : ''}
</p>
</div>
</div>
`;
}

View File

@@ -0,0 +1,116 @@
/**
* LoginSurface — root login surface component
*
* Reads globals from Go template:
* __BASE__ — base path
* __AUTH_MODE__ — 'builtin' | 'oidc' | 'mtls'
* __REGISTRATION_OPEN__ — boolean
* __BANNER__ — { visible, text, background, color } or null
* __VERSION__ — app version string
* __ENVIRONMENT__ — environment label or null
*
* Handles OIDC callback (#oidc_result=<base64>).
*/
const { html } = window;
const { useState, useEffect, useCallback } = hooks;
const { render } = preact;
import { Hero } from './hero.js';
import { LoginForm } from './login-form.js';
import { RegisterForm } from './register-form.js';
import { SSOPanel } from './sso-panel.js';
function LoginSurface() {
const BASE = window.__BASE__ || '';
const authMode = window.__AUTH_MODE__ || 'builtin';
const regOpen = window.__REGISTRATION_OPEN__ || false;
const banner = window.__BANNER__ || null;
const version = window.__VERSION__ || '';
const env = window.__ENVIRONMENT__ || null;
const [tab, setTab] = useState('login'); // 'login' | 'register'
// ── OIDC callback handler (runs once on mount) ──
useEffect(() => {
const hash = window.location.hash;
if (!hash.startsWith('#oidc_result=')) return;
try {
const encoded = hash.slice('#oidc_result='.length);
const json = atob(encoded);
const data = JSON.parse(json);
sw.auth._setAuth(data);
window.location.replace(BASE + '/');
} catch (e) {
console.error('[login] Failed to process OIDC callback:', e);
}
}, []);
const switchToLogin = useCallback(() => setTab('login'), []);
const heading = tab === 'login'
? { title: 'Welcome back', sub: 'Sign in to your Switchboard instance' }
: { title: 'Create account', sub: 'Join your team on Switchboard' };
return html`
<div class="login-shell">
${banner?.visible && html`
<div class="login-banner"
style="background:${banner.background};color:${banner.color};">
${banner.text}
</div>
`}
<div class="login-main">
<${Hero} base=${BASE} version=${version} env=${env} />
<div class="login-auth-panel">
<div class="login-auth-inner">
<div class="login-auth-heading login-anim login-anim-a1">
<h2>${heading.title}</h2>
<p>${heading.sub}</p>
</div>
${authMode === 'oidc' || authMode === 'mtls'
? html`<${SSOPanel} mode=${authMode} base=${BASE} />`
: html`
${regOpen && html`
<div class="login-auth-tabs login-anim login-anim-a2">
<button class="login-auth-tab ${tab === 'login' ? 'active' : ''}"
onClick=${() => setTab('login')}>Log In</button>
<button class="login-auth-tab ${tab === 'register' ? 'active' : ''}"
onClick=${() => setTab('register')}>Register</button>
</div>
`}
${tab === 'login'
? html`<${LoginForm} />`
: html`<${RegisterForm} onPending=${switchToLogin} />`
}
`
}
<div class="login-auth-footer">
<p>Self-hosted instance \u00B7 Your data stays on your infrastructure</p>
</div>
</div>
</div>
</div>
${banner?.visible && html`
<div class="login-banner"
style="background:${banner.background};color:${banner.color};">
${banner.text}
</div>
`}
</div>
`;
}
// ── Mount ────────────────────────────────────
const mount = document.getElementById('login-mount');
if (mount) {
render(html`<${LoginSurface} />`, mount);
console.log('[login] Surface mounted');
}
export { LoginSurface };

View File

@@ -0,0 +1,68 @@
/**
* LoginForm — username/password form using sw.auth.login()
*
* Props:
* onSuccess — called after successful login (optional, default: redirect to /)
*/
const { html } = window;
const { useState, useRef, useCallback } = hooks;
export function LoginForm({ onSuccess }) {
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const userRef = useRef(null);
const passRef = useRef(null);
const BASE = window.__BASE__ || '';
const doLogin = useCallback(async () => {
const username = userRef.current?.value?.trim();
const password = passRef.current?.value;
if (!username || !password) {
setError('Enter username and password');
return;
}
setError('');
setLoading(true);
try {
await sw.auth.login(username, password);
if (onSuccess) {
onSuccess();
} else {
window.location.href = BASE + '/';
}
} catch (e) {
setError(e.message || 'Login failed');
setLoading(false);
}
}, [onSuccess]);
const onUserKey = useCallback((e) => {
if (e.key === 'Enter') passRef.current?.focus();
}, []);
const onPassKey = useCallback((e) => {
if (e.key === 'Enter') doLogin();
}, [doLogin]);
return html`
<div class="login-anim login-anim-a3">
<div class="login-form-field">
<label for="loginUsername">Username</label>
<input type="text" id="loginUsername" ref=${userRef}
placeholder="Enter username" autocomplete="username"
autofocus onKeyDown=${onUserKey} />
</div>
<div class="login-form-field">
<label for="loginPassword">Password</label>
<input type="password" id="loginPassword" ref=${passRef}
placeholder="Enter password" autocomplete="current-password"
onKeyDown=${onPassKey} />
</div>
${error && html`<p class="login-auth-error">${error}</p>`}
<button class="login-btn" disabled=${loading} onClick=${doLogin}>
${loading ? 'Logging in\u2026' : 'Log In'}
</button>
</div>
`;
}

View File

@@ -0,0 +1,102 @@
/**
* RegisterForm — registration flow using sw.api.auth.register()
*
* Props:
* onPending — called when account needs admin approval
* onSuccess — called after auto-login on successful registration
*/
const { html } = window;
const { useState, useRef, useCallback } = hooks;
export function RegisterForm({ onPending, onSuccess }) {
const [error, setError] = useState('');
const [errorCls, setErrorCls] = useState('');
const [loading, setLoading] = useState(false);
const userRef = useRef(null);
const emailRef = useRef(null);
const passRef = useRef(null);
const confirmRef = useRef(null);
const BASE = window.__BASE__ || '';
const doRegister = useCallback(async () => {
const username = userRef.current?.value?.trim();
const email = emailRef.current?.value?.trim();
const password = passRef.current?.value;
const confirm = confirmRef.current?.value;
if (!username || !password) {
setError('Username and password are required');
setErrorCls('');
return;
}
if (password.length < 8) {
setError('Password must be at least 8 characters');
setErrorCls('');
return;
}
if (password !== confirm) {
setError('Passwords do not match');
setErrorCls('');
return;
}
setError('');
setLoading(true);
try {
const data = await sw.api.auth.register(username, email, password);
if (data.pending) {
setError('Account created \u2014 pending admin approval.');
setErrorCls('success');
if (onPending) setTimeout(onPending, 3000);
} else {
// Auto-login: store tokens via auth module
sw.auth._setAuth(data);
if (onSuccess) {
onSuccess();
} else {
window.location.href = BASE + '/';
}
}
} catch (e) {
setError(e.message || 'Registration failed');
setErrorCls('');
} finally {
setLoading(false);
}
}, [onPending, onSuccess]);
const onConfirmKey = useCallback((e) => {
if (e.key === 'Enter') doRegister();
}, [doRegister]);
return html`
<div>
<div class="login-form-field">
<label for="regUsername">Username</label>
<input type="text" id="regUsername" ref=${userRef}
placeholder="Choose a username" autocomplete="username" />
</div>
<div class="login-form-field">
<label for="regEmail">Email</label>
<input type="email" id="regEmail" ref=${emailRef}
placeholder="you@example.com" autocomplete="email" />
</div>
<div class="login-form-field">
<label for="regPassword">Password</label>
<input type="password" id="regPassword" ref=${passRef}
placeholder="Min 8 characters" autocomplete="new-password" />
</div>
<div class="login-form-field">
<label for="regConfirm">Confirm Password</label>
<input type="password" id="regConfirm" ref=${confirmRef}
placeholder="Re-enter password" autocomplete="new-password"
onKeyDown=${onConfirmKey} />
</div>
${error && html`<p class="login-auth-error ${errorCls}">${error}</p>`}
<button class="login-btn" disabled=${loading} onClick=${doRegister}>
${loading ? 'Creating account\u2026' : 'Create Account'}
</button>
</div>
`;
}

View File

@@ -0,0 +1,41 @@
/**
* SSOPanel — OIDC or mTLS auth panels
*
* Props:
* mode — 'oidc' | 'mtls'
* base — base path
*/
const { html } = window;
export function SSOPanel({ mode, base = '' }) {
if (mode === 'oidc') {
return html`
<div class="login-anim login-anim-a2">
<p class="login-sso-desc">
This instance uses Single Sign-On. Click below to
authenticate with your identity provider.
</p>
<a class="login-btn login-btn--link"
href="${base}/api/v1/auth/oidc/login">
Sign in with SSO
</a>
</div>
`;
}
if (mode === 'mtls') {
return html`
<div class="login-anim login-anim-a2">
<p class="login-sso-desc">
This instance uses client certificate authentication.
Ensure your browser has a valid certificate installed.
</p>
<a class="login-btn login-btn--link" href="${base}/">
Continue with Certificate
</a>
</div>
`;
}
return null;
}

View File

@@ -0,0 +1,83 @@
/**
* AppearanceSection — theme, UI scale, message font size
*/
const { html } = window;
const { useState, useCallback } = hooks;
const THEMES = [
{ key: 'light', icon: '\u2600', label: 'Light' },
{ key: 'dark', icon: '\u263E', label: 'Dark' },
{ key: 'system', icon: '\u2395', label: 'System' },
];
function getPrefs() {
try { return JSON.parse(localStorage.getItem('cs-appearance') || '{}'); }
catch { return {}; }
}
export function AppearanceSection() {
const prefs = getPrefs();
const [theme, setTheme] = useState(sw.theme?.current || 'system');
const [scale, setScale] = useState(prefs.scale || 100);
const [msgFont, setMsgFont] = useState(prefs.msgFont || 14);
const applyTheme = useCallback((mode) => {
setTheme(mode);
sw.theme.set(mode);
sw.emit('theme.changed', { mode, resolved: sw.theme.current });
}, []);
const save = useCallback(() => {
const p = getPrefs();
p.scale = scale;
p.msgFont = msgFont;
localStorage.setItem('cs-appearance', JSON.stringify(p));
// Apply zoom + font size
document.documentElement.style.zoom = scale !== 100 ? (scale / 100) : '';
document.documentElement.style.setProperty('--msg-font-size', msgFont + 'px');
sw.emit('toast', { message: 'Appearance saved', variant: 'success' });
}, [scale, msgFont]);
return html`
<div class="settings-section">
<h3>Theme</h3>
<div class="toggle-group" style="display:flex;gap:0;">
${THEMES.map(t => html`
<button class="toggle-btn ${theme === t.key ? 'active' : ''}"
onClick=${() => applyTheme(t.key)}>
${t.icon} ${t.label}
</button>
`)}
</div>
</div>
<div class="settings-section">
<h3>UI Scale</h3>
<div class="form-group">
<div style="display:flex;align-items:center;gap:8px;">
<input type="range" min="80" max="175" step="5"
value=${scale}
onInput=${e => setScale(parseInt(e.target.value))}
style="flex:1;" />
<span style="font-size:12px;color:var(--text-2);font-family:var(--mono);min-width:32px;">
${scale}%
</span>
</div>
</div>
<h3 style="margin-top:16px;">Message Font Size</h3>
<div class="form-group">
<div style="display:flex;align-items:center;gap:8px;">
<input type="range" min="12" max="20" step="1"
value=${msgFont}
onInput=${e => setMsgFont(parseInt(e.target.value))}
style="flex:1;" />
<span style="font-size:12px;color:var(--text-2);font-family:var(--mono);min-width:32px;">
${msgFont}px
</span>
</div>
</div>
</div>
<button class="btn-md btn-primary" onClick=${save}>Save</button>
`;
}

View File

@@ -0,0 +1,32 @@
/**
* BridgeSection — thin Preact wrapper for deferred settings sections
*
* Renders a container <div> with the expected DOM IDs, then calls
* the old JS loader function so legacy code can populate it.
*
* Props:
* id — mount div ID (e.g. 'settingsTasksMount')
* loader — function to call after mount (e.g. () => _loadSettingsTasks?.())
*/
const { html } = window;
const { useEffect, useRef } = hooks;
export function BridgeSection({ id, loader }) {
const ref = useRef(null);
useEffect(() => {
if (loader) {
// Defer to next tick so the DOM node is committed
requestAnimationFrame(() => {
try { loader(); }
catch (e) { console.warn('[settings/bridge]', id, e.message); }
});
}
}, [id, loader]);
return html`
<div ref=${ref} id=${id}>
<div class="settings-placeholder">Loading\u2026</div>
</div>
`;
}

View File

@@ -0,0 +1,130 @@
/**
* GeneralSection — chat defaults: model, system prompt, max tokens, temperature, thinking
*/
const { html } = window;
const { useState, useEffect, useCallback, useRef } = hooks;
export function GeneralSection() {
const [models, setModels] = useState([]);
const [settings, setSettings] = useState({});
const [saving, setSaving] = useState(false);
const [maxHint, setMaxHint] = useState('');
const tempRef = useRef(null);
// Load settings + models
useEffect(() => {
(async () => {
try {
const [s, m] = await Promise.all([
sw.api.profile.settings(),
sw.api.models.enabled(),
]);
const sObj = s || {};
setSettings({
model: sObj.model || '',
system_prompt: sObj.system_prompt || '',
max_tokens: sObj.max_tokens || 0,
temperature: sObj.temperature ?? 0.7,
show_thinking: sObj.show_thinking || false,
});
const modelList = (m?.data || m || []).filter(x => !x.hidden);
setModels(modelList);
// Set initial max hint
const active = modelList.find(x => x.id === sObj.model);
if (active?.capabilities?.max_output_tokens > 0) {
setMaxHint(`(model max: ${(active.capabilities.max_output_tokens / 1000).toFixed(0)}K)`);
}
} catch (e) {
console.warn('[settings/general] Load failed:', e.message);
}
})();
}, []);
const onModelChange = useCallback((e) => {
const id = e.target.value;
setSettings(s => ({ ...s, model: id }));
const m = models.find(x => x.id === id);
const cap = m?.capabilities || {};
setMaxHint(cap.max_output_tokens > 0
? `(model max: ${(cap.max_output_tokens / 1000).toFixed(0)}K)` : '');
}, [models]);
const onTempInput = useCallback((e) => {
setSettings(s => ({ ...s, temperature: parseFloat(e.target.value) }));
}, []);
const save = useCallback(async () => {
setSaving(true);
try {
await sw.api.profile.updateSettings({
model: settings.model,
system_prompt: settings.system_prompt,
max_tokens: settings.max_tokens,
temperature: settings.temperature,
show_thinking: settings.show_thinking,
});
sw.emit('toast', { message: 'Settings saved', variant: 'success' });
} catch (e) {
sw.emit('toast', { message: e.message, variant: 'error' });
} finally {
setSaving(false);
}
}, [settings]);
return html`
<div class="settings-section">
<h3>Chat Defaults</h3>
<div class="form-group">
<label>Default Model</label>
<select value=${settings.model} onChange=${onModelChange}>
${models.map(m => html`
<option value=${m.id}>
${m.name || m.id}${m.provider ? ` (${m.provider})` : ''}
</option>
`)}
</select>
</div>
<div class="form-group">
<label>System Prompt</label>
<textarea rows="3" placeholder="Optional system prompt\u2026"
style="max-width:100%;"
value=${settings.system_prompt}
onInput=${e => setSettings(s => ({ ...s, system_prompt: e.target.value }))} />
</div>
<div style="display:flex;gap:16px;">
<div class="form-group" style="flex:1;">
<label>Max Tokens${' '}
<span style="font-weight:400;color:var(--text-3);">${maxHint}</span>
</label>
<input type="number" placeholder="default"
value=${settings.max_tokens || ''}
onInput=${e => setSettings(s => ({ ...s, max_tokens: parseInt(e.target.value) || 0 }))} />
</div>
<div class="form-group" style="flex:1;">
<label>Temperature</label>
<div style="display:flex;align-items:center;gap:8px;">
<input type="range" min="0" max="2" step="0.1"
ref=${tempRef}
value=${settings.temperature}
onInput=${onTempInput}
style="flex:1;" />
<span style="font-size:12px;color:var(--text-2);font-family:var(--mono);">
${settings.temperature}
</span>
</div>
</div>
</div>
<label style="display:flex;align-items:center;gap:8px;font-size:13px;color:var(--text-2);cursor:pointer;margin-top:4px;">
<input type="checkbox"
checked=${settings.show_thinking}
onChange=${e => setSettings(s => ({ ...s, show_thinking: e.target.checked }))} />
Show thinking blocks
</label>
</div>
<button class="btn-md btn-primary" style="margin-top:12px;"
disabled=${saving} onClick=${save}>
${saving ? 'Saving\u2026' : 'Save'}
</button>
`;
}

View File

@@ -0,0 +1,223 @@
/**
* SettingsSurface — root settings surface
*
* Reads globals:
* __SECTION__ — active section name (string)
* __BASE__ — base path
* __PAGE_DATA__ — { BYOKEnabled, ... } from Go template
*
* Layout: topbar + left nav + content area (same CSS classes as before).
* Preact sections are loaded lazily. Bridge sections delegate to old JS.
*/
const { html } = window;
const { useState, useEffect, useCallback, useMemo } = hooks;
const { render } = preact;
import { BridgeSection } from './bridge-section.js';
import { ToastContainer } from '../../primitives/toast.js';
import { DialogStack } from '../../shell/dialog-stack.js';
// ── Lazy section imports ────────────────────
// Each returns a Promise<{ default: Component }> or { SectionName: Component }
const sectionModules = {
general: () => import('./general.js'),
appearance: () => import('./appearance.js'),
profile: () => import('./profile.js'),
teams: () => import('./teams.js'),
models: () => import('./models.js'),
providers: () => import('./providers.js'),
personas: () => import('./personas.js'),
roles: () => import('./roles.js'),
usage: () => import('./usage.js'),
};
// ── Bridge section config (deferred to old JS) ──
const bridgeSections = {
workflows: { id: 'settingsDynamic', loader: () => typeof loadTeamWorkflows === 'function' && loadTeamWorkflows() },
tasks: { id: 'settingsTasksMount', loader: () => typeof _loadSettingsTasks === 'function' && _loadSettingsTasks() },
gitkeys: { id: 'settingsDynamic', loader: () => typeof _loadSettingsGitKeys === 'function' && _loadSettingsGitKeys() },
data: { id: 'dpMount', loader: () => typeof loadDataPrivacy === 'function' && loadDataPrivacy() },
knowledge: { id: 'settingsDynamic', loader: () => typeof KnowledgeUI !== 'undefined' && KnowledgeUI.openManagePanel?.() },
memory: { id: 'settingsDynamic', loader: () => typeof MemoryUI !== 'undefined' && MemoryUI.openSettingsPanel?.() },
notifications: { id: 'settingsDynamic', loader: () => typeof NotifPrefs !== 'undefined' && NotifPrefs.load?.() },
};
// ── 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: 'Workflows' },
{ key: 'tasks', label: 'Tasks' },
{ key: 'gitkeys', label: 'Git Keys' },
{ key: 'data', label: 'Data & Privacy' },
];
const BYOK_ITEMS = [
{ key: 'providers', label: 'My Providers' },
{ key: 'roles', label: 'Model Roles' },
{ key: 'usage', label: 'My Usage' },
];
// ── Section title map ───────────────────────
const SECTION_TITLES = {
general: 'General', appearance: 'Appearance', models: 'Models',
personas: 'Personas', profile: 'Profile', teams: 'Teams',
workflows: 'Workflows', tasks: 'Tasks', gitkeys: 'Git Keys',
data: 'Data & Privacy', providers: 'My Providers', roles: 'Model Roles',
usage: 'My Usage', knowledge: 'Knowledge Bases', memory: 'Memory',
notifications: 'Notifications',
};
function SettingsSurface() {
const BASE = window.__BASE__ || '';
const section = window.__SECTION__ || 'general';
const pageData = window.__PAGE_DATA__ || {};
const [byokEnabled, setByokEnabled] = useState(!!pageData.BYOKEnabled);
const [personasEnabled, setPersonasEnabled] = useState(!!pageData.UserPersonasEnabled);
const [SectionComponent, setSectionComponent] = useState(null);
// Fetch policies if not provided by template
useEffect(() => {
if (!pageData.BYOKEnabled || !pageData.UserPersonasEnabled) {
sw.api.admin?.settings?.public?.().then(data => {
const policies = data?.policies || {};
if (policies.allow_user_byok === 'true') setByokEnabled(true);
if (policies.allow_user_personas === 'true') setPersonasEnabled(true);
}).catch(() => {});
}
}, []);
// Load the section component
useEffect(() => {
setSectionComponent(null);
if (sectionModules[section]) {
sectionModules[section]().then(mod => {
// Module exports the component as the first named export
const Comp = mod.default || Object.values(mod)[0];
setSectionComponent(() => Comp);
}).catch(e => {
console.error('[settings] Failed to load section:', section, e);
});
}
// Bridge sections are handled inline
}, [section]);
const onNavClick = useCallback((e) => {
e.preventDefault();
const href = e.currentTarget.getAttribute('href');
if (href) location.replace(href);
}, []);
const backClick = useCallback((e) => {
e.preventDefault();
const RETURN_KEY = 'sb_settings_return';
const returnURL = sessionStorage.getItem(RETURN_KEY);
sessionStorage.removeItem(RETURN_KEY);
location.href = returnURL || BASE + '/';
}, []);
// Stash return URL on first load
useEffect(() => {
const RETURN_KEY = 'sb_settings_return';
if (!sessionStorage.getItem(RETURN_KEY)) {
const ref = document.referrer;
if (ref && ref.startsWith(location.origin) && !ref.includes('/settings')) {
sessionStorage.setItem(RETURN_KEY, ref);
} else {
sessionStorage.setItem(RETURN_KEY, location.origin + BASE + '/');
}
}
}, []);
const title = SECTION_TITLES[section] || 'Settings';
return html`
<div class="surface-settings" style="flex-direction:column;">
${/* Top Bar */''}
<div class="settings-topbar">
<a href="${BASE}/" class="settings-topbar-back" onClick=${backClick}>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="19" y1="12" x2="5" y2="12"/>
<polyline points="12 19 5 12 12 5"/>
</svg>
Back
</a>
<div class="settings-topbar-sep"></div>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
style="color:var(--text-2)">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
</svg>
<span class="settings-topbar-title">Settings</span>
</div>
<div style="display:flex;flex:1;min-height:0;">
${/* Left Nav */''}
<div class="settings-nav">
${NAV_ITEMS
.filter(item => !item.gate || (item.gate === 'personas' && personasEnabled))
.map(item => html`
<a href="${BASE}/settings/${item.key}"
class="settings-nav-link ${section === item.key ? 'active' : ''}"
onClick=${onNavClick}>
${item.label}
</a>
`)}
${byokEnabled && html`
<div>
<div class="settings-nav-sep"></div>
${BYOK_ITEMS.map(item => html`
<a href="${BASE}/settings/${item.key}"
class="settings-nav-link ${section === item.key ? 'active' : ''}"
onClick=${onNavClick}>
${item.label}
</a>
`)}
</div>
<div class="settings-nav-footer">
<div style="display:flex;align-items:center;gap:6px;margin-bottom:4px;">
<span style="font-size:11px;font-weight:600;color:var(--success);">BYOK Enabled</span>
</div>
<p style="font-size:10px;color:var(--text-3);margin:0;line-height:1.5;">
Admin has enabled Bring Your Own Key
</p>
</div>
`}
</div>
${/* Content */''}
<div class="settings-content">
<h2>${title}</h2>
${bridgeSections[section]
? html`<${BridgeSection} id=${bridgeSections[section].id}
loader=${bridgeSections[section].loader} />`
: SectionComponent
? html`<${SectionComponent} />`
: html`<div class="settings-placeholder">Loading\u2026</div>`
}
</div>
</div>
</div>
<${ToastContainer} />
<${DialogStack} />
`;
}
// ── Mount ────────────────────────────────────
const mount = document.getElementById('settings-mount');
if (mount) {
render(html`<${SettingsSurface} />`, mount);
console.log('[settings] Surface mounted');
}
export { SettingsSurface };

View File

@@ -0,0 +1,110 @@
/**
* 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 || m || []);
// Build prefs map: model_id → { hidden }
const map = {};
(p?.data || 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>
`;
}

View File

@@ -0,0 +1,154 @@
/**
* 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?.data || 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>`;
}
return 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>
<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>
`}
`;
}

View File

@@ -0,0 +1,180 @@
/**
* ProfileSection — display name, email, avatar, password change
*/
const { html } = window;
const { useState, useEffect, useCallback, useRef } = hooks;
export function ProfileSection() {
const [displayName, setDisplayName] = useState('');
const [email, setEmail] = useState('');
const [avatarUrl, setAvatarUrl] = useState(null);
const [savingProfile, setSavingProfile] = useState(false);
// Password state
const [curPw, setCurPw] = useState('');
const [newPw, setNewPw] = useState('');
const [confirmPw, setConfirmPw] = useState('');
const [savingPw, setSavingPw] = useState(false);
const fileRef = useRef(null);
// Load profile
useEffect(() => {
(async () => {
try {
const p = await sw.api.profile.get();
setDisplayName(p.display_name || p.username || '');
setEmail(p.email || '');
setAvatarUrl(p.avatar || p.avatar_url || null);
} catch (e) {
// Fall back to auth user
const u = sw.auth.user;
if (u) {
setDisplayName(u.display_name || u.username || '');
setEmail(u.email || '');
setAvatarUrl(u.avatar || null);
}
}
})();
}, []);
const saveProfile = useCallback(async () => {
if (!displayName.trim()) {
sw.emit('toast', { message: 'Display name is required', variant: 'error' });
return;
}
setSavingProfile(true);
try {
await sw.api.profile.update({ display_name: displayName.trim() });
sw.emit('toast', { message: 'Profile saved', variant: 'success' });
} catch (e) {
sw.emit('toast', { message: e.message, variant: 'error' });
} finally {
setSavingProfile(false);
}
}, [displayName]);
const changePassword = useCallback(async () => {
if (!curPw || !newPw) {
sw.emit('toast', { message: 'All password fields are required', variant: 'error' });
return;
}
if (newPw !== confirmPw) {
sw.emit('toast', { message: 'Passwords do not match', variant: 'error' });
return;
}
if (newPw.length < 8) {
sw.emit('toast', { message: 'Password must be at least 8 characters', variant: 'error' });
return;
}
setSavingPw(true);
try {
await sw.api.profile.password(curPw, newPw);
sw.emit('toast', { message: 'Password changed', variant: 'success' });
setCurPw(''); setNewPw(''); setConfirmPw('');
} catch (e) {
sw.emit('toast', { message: e.message, variant: 'error' });
} finally {
setSavingPw(false);
}
}, [curPw, newPw, confirmPw]);
// Avatar upload
const onAvatarFile = useCallback(async (e) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = async () => {
try {
await sw.api.profile.avatar(reader.result);
setAvatarUrl(reader.result);
sw.emit('toast', { message: 'Avatar updated', variant: 'success' });
} catch (err) {
sw.emit('toast', { message: err.message, variant: 'error' });
}
};
reader.readAsDataURL(file);
}, []);
const removeAvatar = useCallback(async () => {
try {
await sw.api.profile.deleteAvatar();
setAvatarUrl(null);
sw.emit('toast', { message: 'Avatar removed', variant: 'success' });
} catch (e) {
sw.emit('toast', { message: e.message, variant: 'error' });
}
}, []);
const initial = (displayName || '?')[0].toUpperCase();
return html`
<div class="settings-section">
<h3>Profile</h3>
${/* Avatar */''}
<div style="display:flex;align-items:center;gap:16px;margin-bottom:16px;">
<div class="avatar-preview" style="width:56px;height:56px;border-radius:50%;overflow:hidden;
background:var(--bg-raised);display:flex;align-items:center;justify-content:center;
font-size:24px;font-weight:600;color:var(--text-2);flex-shrink:0;">
${avatarUrl
? html`<img src=${avatarUrl} alt="Avatar"
style="width:100%;height:100%;object-fit:cover;" />`
: initial
}
</div>
<div style="display:flex;gap:8px;">
<button class="btn-sm btn-secondary"
onClick=${() => fileRef.current?.click()}>
Upload
</button>
${avatarUrl && html`
<button class="btn-sm btn-secondary" onClick=${removeAvatar}>
Remove
</button>
`}
<input type="file" ref=${fileRef} accept="image/*"
style="display:none;" onChange=${onAvatarFile} />
</div>
</div>
<div class="form-group">
<label>Display Name</label>
<input type="text" placeholder="Your display name"
value=${displayName}
onInput=${e => setDisplayName(e.target.value)} />
</div>
<div class="form-group">
<label>Email</label>
<input type="email" disabled value=${email} />
</div>
<button class="btn-md btn-primary" disabled=${savingProfile}
onClick=${saveProfile}>
${savingProfile ? 'Saving\u2026' : 'Save Profile'}
</button>
</div>
<div class="settings-section" style="margin-top:20px;">
<h3>Change Password</h3>
<div class="form-group">
<label>Current Password</label>
<input type="password" autocomplete="current-password"
value=${curPw} onInput=${e => setCurPw(e.target.value)} />
</div>
<div class="form-group">
<label>New Password</label>
<input type="password" autocomplete="new-password"
value=${newPw} onInput=${e => setNewPw(e.target.value)} />
</div>
<div class="form-group">
<label>Confirm New Password</label>
<input type="password" autocomplete="new-password"
value=${confirmPw} onInput=${e => setConfirmPw(e.target.value)} />
</div>
<button class="btn-md btn-primary" disabled=${savingPw}
onClick=${changePassword}>
${savingPw ? 'Updating\u2026' : 'Update Password'}
</button>
</div>
`;
}

View File

@@ -0,0 +1,199 @@
/**
* 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?.configs || resp?.data || 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 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="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>
`}
`;
}

View File

@@ -0,0 +1,127 @@
/**
* 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?.configs || provResp?.data || [])
.filter(c => c.scope === 'personal');
setProviders(provs);
setModels(modResp?.data || modResp || []);
// 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>
`;
}

View File

@@ -0,0 +1,47 @@
/**
* TeamsSection — read-only team membership list
*/
const { html } = window;
const { useState, useEffect } = hooks;
function esc(s) { return s == null ? '' : String(s); }
export function TeamsSection() {
const [teams, setTeams] = useState(null);
useEffect(() => {
sw.api.teams.mine().then(resp => {
setTeams(resp?.data || resp || []);
}).catch(() => setTeams([]));
}, []);
if (teams === null) {
return html`<div class="settings-placeholder">Loading teams\u2026</div>`;
}
if (!teams.length) {
return html`<div class="empty-hint">You are not a member of any teams.</div>`;
}
return html`
${teams.map(t => html`
<div class="settings-section" style="margin-bottom:16px;">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;">
<h3 style="margin:0">${esc(t.name)}</h3>
<span class="badge-${t.my_role === 'admin' ? 'success' : 'muted'}"
style="font-size:11px;">
${t.my_role}
</span>
</div>
${t.description && html`
<p style="color:var(--text-2);font-size:13px;margin:0 0 8px;">
${esc(t.description)}
</p>
`}
<div style="font-size:12px;color:var(--text-3);">
${t.member_count} member${t.member_count !== 1 ? 's' : ''}
</div>
</div>
`)}
`;
}

View File

@@ -0,0 +1,65 @@
/**
* 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>
`}
`;
}