590 lines
22 KiB
JavaScript
590 lines
22 KiB
JavaScript
// ==========================================
|
||
// Chat Switchboard – Application
|
||
// ==========================================
|
||
// State, init, boot, auth, listener dispatch.
|
||
// Domain logic lives in chat.js, notes.js, settings-handlers.js,
|
||
// admin-handlers.js. UI rendering in ui-*.js files.
|
||
|
||
const App = {
|
||
chats: [],
|
||
currentChatId: null,
|
||
models: [],
|
||
hiddenModels: new Set(),
|
||
isGenerating: false,
|
||
abortController: null,
|
||
serverSettings: {},
|
||
policies: {},
|
||
stagedAttachments: [],
|
||
channelAttachments: {}, // messageId → [attachment, ...] — populated on chat load
|
||
storageConfigured: false,
|
||
|
||
// Find model by composite ID, with fallback to bare model_id match
|
||
findModel(id) {
|
||
if (!id) return null;
|
||
// Exact match on composite ID (configId:modelId)
|
||
let m = App.models.find(m => m.id === id);
|
||
if (m) return m;
|
||
// Fallback: bare model_id (backward compat with stored settings)
|
||
return App.models.find(m => m.baseModelId === id) || null;
|
||
},
|
||
|
||
settings: {
|
||
model: '',
|
||
stream: true,
|
||
showThinking: false,
|
||
systemPrompt: '',
|
||
maxTokens: 0, // 0 = auto from model capabilities
|
||
temperature: 0.7,
|
||
},
|
||
};
|
||
|
||
// ── Models ───────────────────────────────────
|
||
|
||
// ── Capability Resolution ───────────────────────────────────────
|
||
// The backend is the source of truth for model capabilities.
|
||
// Resolution chain on the server: catalog (provider API sync) → heuristic.
|
||
// The frontend does NOT maintain a static model table — the same model
|
||
// can have different capabilities on different providers (e.g. DeepSeek
|
||
// on Venice has no tool_calling, same model on OpenRouter does).
|
||
|
||
// resolveCapabilities returns backend caps as-is. No client-side override.
|
||
function resolveCapabilities(backendCaps, modelId) {
|
||
return backendCaps && Object.keys(backendCaps).length > 0 ? { ...backendCaps } : {};
|
||
}
|
||
|
||
async function fetchModels() {
|
||
try {
|
||
const data = await API.listEnabledModels();
|
||
App.defaultModel = data.default_model || '';
|
||
// Load user model preferences
|
||
try {
|
||
const prefData = await API.getModelPreferences();
|
||
App.hiddenModels = new Set(
|
||
(prefData.preferences || []).filter(p => p.hidden).map(p => p.model_id)
|
||
);
|
||
} catch (e) { App.hiddenModels = new Set(); }
|
||
|
||
App.models = (data.models || []).map(m => {
|
||
const isPreset = !!m.is_preset;
|
||
const baseModelId = m.model_id || m.id;
|
||
// Presets use preset_id; base models use configId:modelId to avoid
|
||
// collisions when the same model exists across team/personal/global providers
|
||
const id = isPreset
|
||
? (m.preset_id || m.id)
|
||
: ((m.config_id || m.provider_config_id) ? `${m.config_id || m.provider_config_id}:${baseModelId}` : baseModelId);
|
||
return {
|
||
id,
|
||
baseModelId,
|
||
name: m.display_name || baseModelId,
|
||
provider: m.provider_name || m.provider || '',
|
||
configId: m.config_id || m.provider_config_id || null,
|
||
model_type: m.model_type || 'chat',
|
||
capabilities: resolveCapabilities(m.capabilities, baseModelId),
|
||
isPreset,
|
||
presetId: m.preset_id || m.persona_id || null,
|
||
presetScope: m.preset_scope || (isPreset ? m.scope : null) || null,
|
||
presetAvatar: m.preset_avatar || (isPreset ? m.avatar : null) || null,
|
||
presetTeamName: m.preset_team_name || (isPreset ? m.team_name : null) || null,
|
||
source: m.source || (isPreset ? 'preset' : 'global'),
|
||
teamName: m.preset_team_name || null,
|
||
hidden: !isPreset && App.hiddenModels.has(baseModelId),
|
||
};
|
||
});
|
||
|
||
// Sort: presets first (global > team > personal), then regular models
|
||
const scopeOrder = { global: 0, team: 1, personal: 2 };
|
||
App.models.sort((a, b) => {
|
||
if (a.isPreset && !b.isPreset) return -1;
|
||
if (!a.isPreset && b.isPreset) return 1;
|
||
if (a.isPreset && b.isPreset) {
|
||
const sa = scopeOrder[a.presetScope] ?? 9;
|
||
const sb = scopeOrder[b.presetScope] ?? 9;
|
||
if (sa !== sb) return sa - sb;
|
||
}
|
||
return a.name.localeCompare(b.name);
|
||
});
|
||
console.log(`📋 Loaded ${App.models.length} models (${App.models.filter(m => m.isPreset).length} presets, ${App.models.filter(m => m.hidden).length} hidden)`);
|
||
} catch (e) { console.warn('Model fetch failed:', e.message); }
|
||
|
||
UI.updateModelSelector();
|
||
UI.updateCapabilityBadges();
|
||
}
|
||
|
||
async function init() {
|
||
console.log('🔀 Chat Switchboard initializing...');
|
||
initBranding(); // Apply branding before splash is visible
|
||
API.loadTokens();
|
||
|
||
let health = null;
|
||
try {
|
||
health = await API.health();
|
||
console.log('✅ Backend reachable:', health.version);
|
||
} catch (e) {
|
||
console.error('❌ Backend unreachable:', e.message);
|
||
const splashErr = document.getElementById('splashError');
|
||
if (e.proxyBlocked) {
|
||
splashErr.innerHTML =
|
||
`<strong>Network proxy blocked this request</strong><br>` +
|
||
`Proxy response: "${API._esc(e.proxyTitle)}"<br>` +
|
||
`<span class="splash-error-hint">Ask your network admin to whitelist this domain. ` +
|
||
`<a href="#" onclick="openDebugModal();return false">Run diagnostics</a></span>`;
|
||
} else if (e.name === 'TimeoutError' || e.name === 'AbortError') {
|
||
splashErr.innerHTML =
|
||
`<strong>Connection timed out</strong><br>` +
|
||
`<span class="splash-error-hint">Server may be starting up, or a proxy is blocking the connection. ` +
|
||
`<a href="#" onclick="openDebugModal();return false">Run diagnostics</a></span>`;
|
||
} else {
|
||
splashErr.innerHTML =
|
||
`<strong>Cannot reach server</strong><br>` +
|
||
`<span class="splash-error-hint">${API._esc(e.message)}. ` +
|
||
`<a href="#" onclick="openDebugModal();return false">Run diagnostics</a></span>`;
|
||
}
|
||
showSplash(null);
|
||
return;
|
||
}
|
||
|
||
if (API.isAuthed) {
|
||
try {
|
||
await API.getProfile();
|
||
console.log('✅ Session valid for', API.user?.username);
|
||
} catch (e) {
|
||
console.warn('⚠️ Session expired, clearing');
|
||
API.clearTokens();
|
||
}
|
||
}
|
||
|
||
if (API.isAuthed) {
|
||
await startApp();
|
||
} else {
|
||
showSplash(health);
|
||
}
|
||
}
|
||
|
||
async function startApp() {
|
||
hideSplash();
|
||
UI.restoreSidebar();
|
||
await loadSettings();
|
||
|
||
// Load extensions BEFORE chats so block renderers (mermaid, katex, csv, diff)
|
||
// are registered when messages are first rendered.
|
||
try {
|
||
await Extensions.loadAll(); // fetch manifests + inject <script> tags
|
||
await Extensions.initAll(); // build ctx, call init(), set up tool bridge
|
||
} catch (e) {
|
||
console.warn('Extension init error:', e.message);
|
||
}
|
||
|
||
await loadChats();
|
||
await fetchModels();
|
||
|
||
// Guard: if token was invalidated during startup (401 → refresh fail → clearTokens),
|
||
// kick back to login instead of rendering a broken UI.
|
||
if (!API.isAuthed) {
|
||
console.warn('⚠️ Auth lost during startup, returning to login');
|
||
showSplash(null);
|
||
return;
|
||
}
|
||
|
||
await initBanners();
|
||
initAttachments();
|
||
UI.renderChatList();
|
||
UI.updateModelSelector();
|
||
UI.updateUser();
|
||
UI.showAdminButton(API.isAdmin);
|
||
initListeners();
|
||
|
||
// Connect EventBus WebSocket (non-blocking, graceful if /ws not available yet)
|
||
try {
|
||
Events.connect((window.__BASE__ || '') + '/ws');
|
||
} catch (e) {
|
||
console.warn('EventBus WebSocket not available:', e.message);
|
||
}
|
||
|
||
console.log('✅ Chat Switchboard ready');
|
||
}
|
||
|
||
// ── Modal Helpers ───────────────────────────
|
||
|
||
function openModal(id) {
|
||
const el = document.getElementById(id);
|
||
if (!el) return;
|
||
el.classList.add('active');
|
||
// Defer overflow check — browser needs a frame to lay out the modal
|
||
requestAnimationFrame(() => {
|
||
el.querySelectorAll('.modal-tabs').forEach(checkTabsOverflow);
|
||
});
|
||
}
|
||
|
||
function closeModal(id) {
|
||
const el = document.getElementById(id);
|
||
if (el) el.classList.remove('active');
|
||
}
|
||
|
||
// Toggle .is-scrollable on tab bars that overflow horizontally
|
||
// and inject arrow navigation buttons when needed.
|
||
function checkTabsOverflow(tabs) {
|
||
if (!tabs) return;
|
||
const scrollable = tabs.scrollWidth > tabs.clientWidth + 1;
|
||
|
||
if (!scrollable) {
|
||
// Remove wrapper if overflow went away (e.g. zoom decreased)
|
||
const wrap = tabs.parentElement;
|
||
if (wrap && wrap.classList.contains('modal-tabs-wrap')) {
|
||
wrap.parentElement.insertBefore(tabs, wrap);
|
||
wrap.remove();
|
||
}
|
||
return;
|
||
}
|
||
|
||
// Wrap if not already wrapped
|
||
if (!tabs.parentElement.classList.contains('modal-tabs-wrap')) {
|
||
const wrap = document.createElement('div');
|
||
wrap.className = 'modal-tabs-wrap';
|
||
tabs.parentElement.insertBefore(wrap, tabs);
|
||
wrap.appendChild(tabs);
|
||
|
||
// Inject arrows
|
||
const left = document.createElement('button');
|
||
left.className = 'tab-arrow tab-arrow-left';
|
||
left.innerHTML = '‹';
|
||
left.addEventListener('click', () => { tabs.scrollLeft -= 120; });
|
||
|
||
const right = document.createElement('button');
|
||
right.className = 'tab-arrow tab-arrow-right';
|
||
right.innerHTML = '›';
|
||
right.addEventListener('click', () => { tabs.scrollLeft += 120; });
|
||
|
||
wrap.appendChild(left);
|
||
wrap.appendChild(right);
|
||
|
||
// Update arrow visibility on scroll
|
||
tabs.addEventListener('scroll', () => updateTabArrows(tabs), { passive: true });
|
||
}
|
||
|
||
updateTabArrows(tabs);
|
||
}
|
||
|
||
function updateTabArrows(tabs) {
|
||
const wrap = tabs.parentElement;
|
||
if (!wrap || !wrap.classList.contains('modal-tabs-wrap')) return;
|
||
|
||
const left = wrap.querySelector('.tab-arrow-left');
|
||
const right = wrap.querySelector('.tab-arrow-right');
|
||
if (!left || !right) return;
|
||
|
||
const atStart = tabs.scrollLeft <= 2;
|
||
const atEnd = tabs.scrollLeft + tabs.clientWidth >= tabs.scrollWidth - 2;
|
||
|
||
left.classList.toggle('visible', !atStart);
|
||
right.classList.toggle('visible', !atEnd);
|
||
}
|
||
|
||
// ── Auth Flow ────────────────────────────────
|
||
|
||
function showSplash(health) {
|
||
document.getElementById('splashGate').style.display = 'flex';
|
||
document.getElementById('appContainer').style.display = 'none';
|
||
if (health && health.registration_enabled === false) {
|
||
document.getElementById('authTabRegister').style.display = 'none';
|
||
}
|
||
}
|
||
|
||
function hideSplash() {
|
||
document.getElementById('splashGate').style.display = 'none';
|
||
document.getElementById('appContainer').style.display = '';
|
||
}
|
||
|
||
async function handleLogin() {
|
||
const login = document.getElementById('authLogin').value.trim();
|
||
const password = document.getElementById('authPassword').value;
|
||
if (!login || !password) return setAuthError('Fill in all fields');
|
||
setAuthLoading(true);
|
||
try { await API.login(login, password); await startApp(); }
|
||
catch (e) { setAuthError(e.message); }
|
||
finally { setAuthLoading(false); }
|
||
}
|
||
|
||
async function handleRegister() {
|
||
const username = document.getElementById('authUsername').value.trim();
|
||
const email = document.getElementById('authEmail').value.trim();
|
||
const password = document.getElementById('authRegPassword').value;
|
||
if (!username || !email || !password) return setAuthError('Fill in all fields');
|
||
if (password.length < 8) return setAuthError('Password must be at least 8 characters');
|
||
setAuthLoading(true);
|
||
try {
|
||
const resp = await API.register(username, email, password);
|
||
if (resp.pending) {
|
||
setAuthError('');
|
||
const errEl = document.getElementById('authError');
|
||
errEl.textContent = 'Account created — pending admin approval. You will be able to sign in once approved.';
|
||
errEl.style.color = 'var(--accent)';
|
||
setTimeout(() => { errEl.style.color = ''; switchAuthTab('login'); }, 5000);
|
||
} else {
|
||
await startApp();
|
||
}
|
||
}
|
||
catch (e) { setAuthError(e.message); }
|
||
finally { setAuthLoading(false); }
|
||
}
|
||
|
||
async function handleLogout() {
|
||
if (!await showConfirm('Sign out?')) return;
|
||
Events.disconnect();
|
||
Events.clear();
|
||
await API.logout();
|
||
App.chats = [];
|
||
App.currentChatId = null;
|
||
location.reload();
|
||
}
|
||
|
||
function switchAuthTab(tab) {
|
||
document.getElementById('authTabLogin').classList.toggle('active', tab === 'login');
|
||
document.getElementById('authTabRegister').classList.toggle('active', tab === 'register');
|
||
document.getElementById('authLoginForm').style.display = tab === 'login' ? '' : 'none';
|
||
document.getElementById('authRegisterForm').style.display = tab === 'register' ? '' : 'none';
|
||
document.getElementById('authLoginBtn').style.display = tab === 'login' ? '' : 'none';
|
||
document.getElementById('authRegisterBtn').style.display = tab === 'register' ? '' : 'none';
|
||
const hdr = document.querySelector('.auth-card-header');
|
||
if (hdr) {
|
||
hdr.querySelector('h2').textContent = tab === 'login' ? 'Welcome back' : 'Create account';
|
||
hdr.querySelector('p').textContent = tab === 'login'
|
||
? 'Sign in to continue to your workspace'
|
||
: 'Get started with Chat Switchboard';
|
||
}
|
||
setAuthError('');
|
||
}
|
||
|
||
function setAuthError(msg) { document.getElementById('authError').textContent = msg; }
|
||
function setAuthLoading(on) {
|
||
document.querySelectorAll('#authLoginBtn, #authRegisterBtn').forEach(btn => {
|
||
btn.disabled = on;
|
||
btn.textContent = on ? 'Please wait...' : btn.dataset.label;
|
||
});
|
||
}
|
||
|
||
// ── Event Listeners ─────────────────────────
|
||
// Thin dispatcher — domain listeners extracted to their own files.
|
||
|
||
let _listenersInit = false;
|
||
function initListeners() {
|
||
if (_listenersInit) return;
|
||
_listenersInit = true;
|
||
|
||
_initChatListeners(); // from chat.js
|
||
_initSettingsListeners(); // from settings-handlers.js
|
||
_initAdminListeners(); // from admin-handlers.js
|
||
_initNotesListeners(); // from notes.js
|
||
_initAttachmentListeners(); // from attachments.js
|
||
_initGlobalKeyboard(); // local: Escape, Ctrl+K, resize
|
||
_initSidePanelResize(); // from ui-format.js
|
||
}
|
||
|
||
function _initGlobalKeyboard() {
|
||
// Keyboard shortcuts
|
||
document.addEventListener('keydown', (e) => {
|
||
// Escape: stop generation → close command palette → close side panel → close topmost modal
|
||
if (e.key === 'Escape') {
|
||
if (App.isGenerating) { stopGeneration(); return; }
|
||
if (document.getElementById('lightbox')?.classList.contains('active')) {
|
||
closeLightbox(); return;
|
||
}
|
||
if (document.getElementById('cmdPalette')?.classList.contains('active')) {
|
||
closeCmdPalette(); return;
|
||
}
|
||
if (document.getElementById('sidePanel')?.classList.contains('open')) {
|
||
closeSidePanel(); return;
|
||
}
|
||
const open = [...document.querySelectorAll('.modal-overlay.active')];
|
||
if (open.length) closeModal(open[open.length - 1].id);
|
||
return;
|
||
}
|
||
|
||
// Ctrl/Cmd+K: command palette
|
||
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
|
||
e.preventDefault();
|
||
toggleCmdPalette();
|
||
return;
|
||
}
|
||
|
||
// Ctrl/Cmd+Shift+S: focus sidebar search
|
||
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 'S') {
|
||
e.preventDefault();
|
||
const sb = document.getElementById('sidebar');
|
||
if (sb.classList.contains('collapsed')) UI.toggleSidebar();
|
||
document.getElementById('chatSearchInput')?.focus();
|
||
return;
|
||
}
|
||
});
|
||
|
||
// Recheck tab overflow on resize / zoom changes
|
||
window.addEventListener('resize', () => {
|
||
document.querySelectorAll('.modal-overlay.active .modal-tabs').forEach(checkTabsOverflow);
|
||
});
|
||
}
|
||
|
||
// ── Branding ────────────────────────────────
|
||
|
||
function initBranding() {
|
||
const b = window.__BRANDING__;
|
||
if (!b || typeof b !== 'object') return;
|
||
|
||
const brandBase = (window.__BASE__ || '') + '/branding/';
|
||
|
||
// Accent color → CSS custom property
|
||
if (b.accent_color) {
|
||
document.documentElement.style.setProperty('--accent-color', b.accent_color);
|
||
}
|
||
|
||
// Page title
|
||
if (b.org_name) {
|
||
document.title = b.org_name;
|
||
}
|
||
|
||
// Sidebar brand text
|
||
const sidebarText = document.getElementById('brandSidebarText');
|
||
if (sidebarText && b.org_name) sidebarText.textContent = b.org_name;
|
||
|
||
// Splash hero wordmark
|
||
const wordmark = document.getElementById('brandWordmark');
|
||
if (wordmark && b.org_name) wordmark.textContent = b.org_name;
|
||
|
||
// Splash headline — replace if branding provides it
|
||
if (b.headline) {
|
||
const headline = document.getElementById('brandHeadline');
|
||
if (headline) headline.textContent = b.headline;
|
||
}
|
||
|
||
// Splash tagline
|
||
const tagline = document.getElementById('brandTagline');
|
||
if (tagline && b.tagline) tagline.textContent = b.tagline;
|
||
|
||
// Auth card text
|
||
if (b.org_name) {
|
||
const authHeader = document.querySelector('.auth-card-header h2');
|
||
if (authHeader) authHeader.textContent = 'Welcome to ' + b.org_name;
|
||
const authSub = document.querySelector('.auth-card-header p');
|
||
if (authSub) authSub.textContent = 'Sign in to continue';
|
||
}
|
||
const authFooter = document.getElementById('brandAuthFooter');
|
||
if (authFooter && b.tagline) authFooter.textContent = b.tagline;
|
||
|
||
// Logo — replace SVG with <img> in splash hero
|
||
if (b.logo) {
|
||
const logoUrl = brandBase + b.logo;
|
||
|
||
const logoEl = document.getElementById('brandLogo');
|
||
if (logoEl) {
|
||
const img = document.createElement('img');
|
||
img.src = logoUrl;
|
||
img.alt = b.org_name || 'Logo';
|
||
img.className = 'hero-logo-img';
|
||
img.onerror = function() {
|
||
this.style.display = 'none';
|
||
const svg = logoEl.querySelector('svg');
|
||
if (svg) svg.style.display = '';
|
||
};
|
||
const svg = logoEl.querySelector('svg');
|
||
if (svg) svg.style.display = 'none';
|
||
logoEl.appendChild(img);
|
||
}
|
||
|
||
// Sidebar logo — replace emoji with small img
|
||
const sidebarLogo = document.getElementById('brandSidebarLogo');
|
||
if (sidebarLogo) {
|
||
const originalEmoji = sidebarLogo.textContent;
|
||
const img = document.createElement('img');
|
||
img.src = logoUrl;
|
||
img.alt = '';
|
||
img.className = 'brand-logo-img';
|
||
img.onerror = function() {
|
||
this.remove();
|
||
sidebarLogo.textContent = originalEmoji;
|
||
};
|
||
sidebarLogo.textContent = '';
|
||
sidebarLogo.appendChild(img);
|
||
}
|
||
}
|
||
|
||
// Favicon
|
||
if (b.favicon) {
|
||
const favUrl = brandBase + b.favicon;
|
||
document.querySelectorAll('link[rel="icon"], link[rel="apple-touch-icon"]').forEach(link => {
|
||
link.href = favUrl;
|
||
});
|
||
}
|
||
|
||
// Feature pills — custom array replaces defaults, empty array hides them
|
||
if (b.pills !== undefined) {
|
||
const pillsEl = document.getElementById('brandPills');
|
||
if (pillsEl) {
|
||
if (!Array.isArray(b.pills) || b.pills.length === 0) {
|
||
pillsEl.style.display = 'none';
|
||
} else {
|
||
pillsEl.innerHTML = b.pills.map(p => {
|
||
const style = p.style === 'accent' ? ' accent' : p.style === 'purple' ? ' purple' : '';
|
||
return `<div class="hero-pill${style}"><span class="pill-icon">${p.icon || ''}</span> ${p.text}</div>`;
|
||
}).join('');
|
||
}
|
||
}
|
||
}
|
||
|
||
console.log('🎨 Branding applied:', b.org_name || '(defaults)');
|
||
}
|
||
|
||
// ── Banners ──────────────────────────────────
|
||
|
||
async function initBanners() {
|
||
try {
|
||
const data = await API.getPublicSettings?.() || {};
|
||
|
||
// Store policies for user-facing checks (allow_user_byok, etc.)
|
||
App.policies = data.policies || {};
|
||
|
||
// Storage capabilities (file upload, vision)
|
||
App.storageConfigured = !!data.storage_configured;
|
||
|
||
// Also flatten into serverSettings for backward compat
|
||
App.serverSettings = {};
|
||
if (data.banner) App.serverSettings.banner = data.banner;
|
||
if (data.branding) App.serverSettings.branding = data.branding;
|
||
|
||
const root = document.documentElement;
|
||
const banner = data.banner;
|
||
|
||
// Clear previous banner state
|
||
['bannerTop', 'bannerBottom'].forEach(id => {
|
||
const el = document.getElementById(id);
|
||
if (el) { el.classList.remove('active'); el.textContent = ''; }
|
||
});
|
||
root.style.setProperty('--banner-top-height', '0px');
|
||
root.style.setProperty('--banner-bottom-height', '0px');
|
||
|
||
if (!banner || !banner.enabled) return;
|
||
|
||
root.style.setProperty('--banner-bg', banner.bg || '#007a33');
|
||
root.style.setProperty('--banner-fg', banner.fg || '#ffffff');
|
||
|
||
const text = banner.text || '';
|
||
const pos = banner.position || 'both';
|
||
|
||
if (pos === 'top' || pos === 'both') {
|
||
const el = document.getElementById('bannerTop');
|
||
el.textContent = text;
|
||
el.classList.add('active');
|
||
root.style.setProperty('--banner-top-height', '22px');
|
||
}
|
||
if (pos === 'bottom' || pos === 'both') {
|
||
const el = document.getElementById('bannerBottom');
|
||
el.textContent = text;
|
||
el.classList.add('active');
|
||
root.style.setProperty('--banner-bottom-height', '22px');
|
||
}
|
||
} catch (e) {
|
||
// Banners are optional — non-admin users may not have access to settings
|
||
console.debug('Banner init skipped:', e.message);
|
||
}
|
||
}
|
||
|
||
// ── Boot ─────────────────────────────────────
|
||
document.addEventListener('DOMContentLoaded', init);
|