This commit is contained in:
Timothy Jaeryang Baek
2026-06-13 06:10:33 +01:00
Unverified
parent e7cacee0a3
commit c19383d7c5
8 changed files with 371 additions and 523 deletions
@@ -1,255 +0,0 @@
<script lang="ts">
import { toast } from 'svelte-sonner';
import { onMount } from 'svelte';
import { getAdminConfig, updateConfig as apiUpdateConfig } from '$lib/apis/admin';
import { t } from '$lib/i18n';
import ToggleSwitch from '$lib/components/common/ToggleSwitch.svelte';
import Spinner from '$lib/components/common/Spinner.svelte';
let config = $state<Record<string, any>>({});
let loading = $state(true);
let saving = $state(false);
// Local state for key inputs (save on blur, not every keystroke)
let exaKey = $state('');
let tavilyKey = $state('');
let braveKey = $state('');
let perplexityKey = $state('');
let ccKey = $state('');
let ccBaseUrl = $state('');
let ccModel = $state('');
async function loadConfig() {
try {
config = await getAdminConfig();
exaKey = config['web.exa_api_key'] || '';
tavilyKey = config['web.tavily_api_key'] || '';
braveKey = config['web.brave_api_key'] || '';
perplexityKey = config['web.perplexity_api_key'] || '';
ccKey = config['web.chat_completions_api_key'] || '';
ccBaseUrl = config['web.chat_completions_base_url'] || '';
ccModel = config['web.chat_completions_model'] || '';
} catch {
toast.error($t('admin.failedToLoadConfig'));
} finally {
loading = false;
}
}
async function updateConfig(key: string, value: any) {
saving = true;
try {
await apiUpdateConfig({ [key]: value });
config[key] = value;
toast.success($t('settings.saved'));
} catch {
toast.error($t('admin.failedToSave'));
} finally {
saving = false;
}
}
async function saveKey(key: string, value: string) {
if (value !== (config[key] || '')) {
await updateConfig(key, value);
}
}
onMount(loadConfig);
</script>
<div class="flex flex-col min-h-full">
<h2 class="text-sm font-medium text-gray-900 dark:text-white mb-4">{$t('admin.settings')}</h2>
{#if loading}
<div class="flex justify-center py-8">
<Spinner size={16} />
</div>
{:else}
<!-- Auth settings -->
<h3 class="text-xs text-gray-400 dark:text-gray-600 mb-2">{$t('admin.authentication')}</h3>
<div class="flex items-center justify-between h-8">
<span class="text-[13px] text-gray-700 dark:text-gray-300">{$t('admin.allowSignUp')}</span>
<ToggleSwitch
value={config['auth.signup_enabled']}
onchange={(v) => updateConfig('auth.signup_enabled', v)}
disabled={saving}
/>
</div>
<p class="text-[11px] text-gray-400 dark:text-gray-600 mt-0.5">
{config['auth.signup_enabled'] ? $t('admin.signUpEnabled') : $t('admin.signUpDisabled')}
</p>
<!-- Web settings -->
<h3 class="text-xs text-gray-400 dark:text-gray-600 mb-2 mt-6">{$t('admin.web')}</h3>
<!-- Enable/disable toggle -->
<div class="flex items-center justify-between h-8">
<span class="text-[13px] text-gray-700 dark:text-gray-300">{$t('admin.webEnabled')}</span>
<ToggleSwitch
value={config['web.enabled'] !== false}
onchange={() => updateConfig('web.enabled', config['web.enabled'] === false)}
disabled={saving}
/>
</div>
<p class="text-[11px] text-gray-400 dark:text-gray-600 mt-0.5">
{config['web.enabled'] !== false ? $t('admin.webEnabledHint') : $t('admin.webDisabledHint')}
</p>
{#if config['web.enabled'] !== false}
<!-- Provider selector -->
<div class="flex items-center justify-between h-8 mt-3">
<span class="text-[13px] text-gray-700 dark:text-gray-300"
>{$t('admin.webSearchProvider')}</span
>
<select
class="text-[13px] bg-transparent text-gray-700 dark:text-gray-300 text-right outline-none cursor-pointer"
value={config['web.search_provider'] || 'auto'}
onchange={(e) =>
updateConfig('web.search_provider', (e.target as HTMLSelectElement).value)}
disabled={saving}
>
<option value="auto">{$t('admin.webProviderAuto')}</option>
<option value="exa">Exa</option>
<option value="tavily">Tavily</option>
<option value="brave">Brave</option>
<option value="perplexity">Perplexity</option>
<option value="duckduckgo">DuckDuckGo</option>
<option value="chat_completions">{$t('admin.webChatCompletions')}</option>
</select>
</div>
<!-- Conditionally mounted API key fields -->
{@const provider = config['web.search_provider'] || 'auto'}
{#if provider === 'auto'}
<p class="text-[11px] text-gray-400 dark:text-gray-600 mt-2">{$t('admin.webAutoHint')}</p>
{:else if provider === 'exa'}
<div class="mt-3">
<label class="text-xs text-gray-600 dark:text-gray-400 mb-1"
>{$t('admin.webExaKey')}</label
>
<input
type="password"
class="w-full mt-1 h-7 px-2 rounded-lg text-xs bg-gray-100 dark:bg-white/6 text-gray-700 dark:text-gray-300 border border-gray-200 dark:border-white/8 outline-none focus:border-blue-400 dark:focus:border-blue-500 transition-colors"
placeholder="exa-..."
bind:value={exaKey}
onblur={() => saveKey('web.exa_api_key', exaKey)}
disabled={saving}
/>
<p class="text-[11px] text-gray-400 dark:text-gray-600 mt-0.5">
{$t('admin.webExaHint')}
</p>
</div>
{:else if provider === 'tavily'}
<div class="mt-3">
<label class="text-xs text-gray-600 dark:text-gray-400 mb-1"
>{$t('admin.webTavilyKey')}</label
>
<input
type="password"
class="w-full mt-1 h-7 px-2 rounded-lg text-xs bg-gray-100 dark:bg-white/6 text-gray-700 dark:text-gray-300 border border-gray-200 dark:border-white/8 outline-none focus:border-blue-400 dark:focus:border-blue-500 transition-colors"
placeholder="tvly-..."
bind:value={tavilyKey}
onblur={() => saveKey('web.tavily_api_key', tavilyKey)}
disabled={saving}
/>
<p class="text-[11px] text-gray-400 dark:text-gray-600 mt-0.5">
{$t('admin.webTavilyHint')}
</p>
</div>
{:else if provider === 'brave'}
<div class="mt-3">
<label class="text-xs text-gray-600 dark:text-gray-400 mb-1"
>{$t('admin.webBraveKey')}</label
>
<input
type="password"
class="w-full mt-1 h-7 px-2 rounded-lg text-xs bg-gray-100 dark:bg-white/6 text-gray-700 dark:text-gray-300 border border-gray-200 dark:border-white/8 outline-none focus:border-blue-400 dark:focus:border-blue-500 transition-colors"
placeholder="BSA..."
bind:value={braveKey}
onblur={() => saveKey('web.brave_api_key', braveKey)}
disabled={saving}
/>
<p class="text-[11px] text-gray-400 dark:text-gray-600 mt-0.5">
{$t('admin.webBraveHint')}
</p>
</div>
{:else if provider === 'duckduckgo'}
<p class="text-[11px] text-gray-400 dark:text-gray-600 mt-2">
{$t('admin.webDuckDuckGoNote')}
</p>
{:else if provider === 'perplexity'}
<div class="mt-3">
<label class="text-xs text-gray-600 dark:text-gray-400 mb-1"
>{$t('admin.webPerplexityKey')}</label
>
<input
type="password"
class="w-full mt-1 h-7 px-2 rounded-lg text-xs bg-gray-100 dark:bg-white/6 text-gray-700 dark:text-gray-300 border border-gray-200 dark:border-white/8 outline-none focus:border-blue-400 dark:focus:border-blue-500 transition-colors"
placeholder="pplx-..."
bind:value={perplexityKey}
onblur={() => saveKey('web.perplexity_api_key', perplexityKey)}
disabled={saving}
/>
<p class="text-[11px] text-gray-400 dark:text-gray-600 mt-0.5">
{$t('admin.webPerplexityHint')}
</p>
</div>
{:else if provider === 'chat_completions'}
<div class="mt-3 space-y-3">
<div>
<label class="text-xs text-gray-600 dark:text-gray-400 mb-1"
>{$t('admin.webCcBaseUrl')}</label
>
<input
type="text"
class="w-full mt-1 h-7 px-2 rounded-lg text-xs bg-gray-100 dark:bg-white/6 text-gray-700 dark:text-gray-300 border border-gray-200 dark:border-white/8 outline-none focus:border-blue-400 dark:focus:border-blue-500 transition-colors"
placeholder="https://api.perplexity.ai/v1"
bind:value={ccBaseUrl}
onblur={() => saveKey('web.chat_completions_base_url', ccBaseUrl)}
disabled={saving}
/>
</div>
<div>
<label class="text-xs text-gray-600 dark:text-gray-400 mb-1"
>{$t('admin.webCcKey')}</label
>
<input
type="password"
class="w-full mt-1 h-7 px-2 rounded-lg text-xs bg-gray-100 dark:bg-white/6 text-gray-700 dark:text-gray-300 border border-gray-200 dark:border-white/8 outline-none focus:border-blue-400 dark:focus:border-blue-500 transition-colors"
placeholder="sk-..."
bind:value={ccKey}
onblur={() => saveKey('web.chat_completions_api_key', ccKey)}
disabled={saving}
/>
</div>
<div>
<label class="text-xs text-gray-600 dark:text-gray-400 mb-1"
>{$t('admin.webCcModel')}</label
>
<input
type="text"
class="w-full mt-1 h-7 px-2 rounded-lg text-xs bg-gray-100 dark:bg-white/6 text-gray-700 dark:text-gray-300 border border-gray-200 dark:border-white/8 outline-none focus:border-blue-400 dark:focus:border-blue-500 transition-colors"
placeholder="sonar-pro"
bind:value={ccModel}
onblur={() => saveKey('web.chat_completions_model', ccModel)}
disabled={saving}
/>
</div>
<p class="text-[11px] text-gray-400 dark:text-gray-600">
{$t('admin.webCcHint')}
</p>
</div>
{/if}
{/if}
<div class="mt-auto pt-6 flex justify-end">
<button
class="text-[13px] text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors duration-100"
onclick={() => toast.success($t('settings.saved'))}>{$t('settings.save')}</button
>
</div>
{/if}
</div>
@@ -4,10 +4,11 @@
import CreateUserModal from './CreateUserModal.svelte';
import EditUserModal from './EditUserModal.svelte';
import { onMount } from 'svelte';
import { listUsers } from '$lib/apis/admin';
import { listUsers, getAdminConfig, updateConfig } from '$lib/apis/admin';
import { session } from '$lib/session';
import { t } from '$lib/i18n';
import Spinner from '$lib/components/common/Spinner.svelte';
import ToggleSwitch from '$lib/components/common/ToggleSwitch.svelte';
interface User {
user_id: string;
@@ -24,6 +25,10 @@
let loading = $state(true);
let page = $state(0);
// Auth config
let signupEnabled = $state(false);
let savingConfig = $state(false);
let showCreate = $state(false);
let editUser = $state<User | null>(null);
@@ -41,6 +46,26 @@
}
}
async function loadAuthConfig() {
try {
const config = await getAdminConfig();
signupEnabled = config['auth.signup_enabled'] === true;
} catch {}
}
async function toggleSignup(value: boolean) {
savingConfig = true;
try {
await updateConfig({ 'auth.signup_enabled': value });
signupEnabled = value;
toast.success($t('settings.saved'));
} catch {
toast.error($t('admin.failedToSave'));
} finally {
savingConfig = false;
}
}
function handleCreated() {
showCreate = false;
loadUsers();
@@ -52,7 +77,10 @@
if (page >= totalPages) page = Math.max(0, totalPages - 1);
}
onMount(loadUsers);
onMount(() => {
loadUsers();
loadAuthConfig();
});
</script>
<div class="flex items-center justify-between mb-4">
@@ -65,6 +93,21 @@
</button>
</div>
<!-- Sign-up toggle -->
<div class="flex flex-col gap-2.5 mb-3">
<label class="flex items-center justify-between cursor-pointer">
<span class="text-xs text-gray-600 dark:text-gray-400">{$t('admin.allowSignUp')}</span>
<ToggleSwitch
value={signupEnabled}
onchange={(v) => toggleSignup(v)}
disabled={savingConfig}
/>
</label>
<p class="text-[11px] text-gray-400 dark:text-gray-600 -mt-1">
{signupEnabled ? $t('admin.signUpEnabled') : $t('admin.signUpDisabled')}
</p>
</div>
{#if loading}
<div class="flex justify-center py-8">
<Spinner size={16} />
@@ -0,0 +1,314 @@
<script lang="ts">
import { toast } from 'svelte-sonner';
import { onMount } from 'svelte';
import { getAdminConfig, updateConfig } from '$lib/apis/admin';
import { t } from '$lib/i18n';
import ToggleSwitch from '$lib/components/common/ToggleSwitch.svelte';
import Spinner from '$lib/components/common/Spinner.svelte';
let loading = $state(true);
let saving = $state(false);
let testing = $state(false);
let testResult = $state<{ ok: boolean; message: string } | null>(null);
// ── Web search ────────────────────────────────────────
let webEnabled = $state(true);
let searchProvider = $state('auto');
let exaKey = $state('');
let tavilyKey = $state('');
let braveKey = $state('');
let perplexityKey = $state('');
let ccKey = $state('');
let ccBaseUrl = $state('');
let ccModel = $state('');
// ── Browser ───────────────────────────────────────────
let browserEnabled = $state(false);
let browserProvider = $state<'local' | 'firecrawl' | 'browser_use'>('local');
let cdpUrl = $state('http://localhost:9222');
let autoLaunch = $state(true);
let sessionTimeout = $state(10);
let firecrawlApiKey = $state('');
let firecrawlBaseUrl = $state('https://api.firecrawl.dev');
let browserUseApiKey = $state('');
let browserUseBaseUrl = $state('https://api.browser-use.com');
onMount(async () => {
try {
const config = await getAdminConfig();
// Web search
webEnabled = config['web.enabled'] !== false;
searchProvider = config['web.search_provider'] || 'auto';
exaKey = (config['web.exa_api_key'] as string) || '';
tavilyKey = (config['web.tavily_api_key'] as string) || '';
braveKey = (config['web.brave_api_key'] as string) || '';
perplexityKey = (config['web.perplexity_api_key'] as string) || '';
ccKey = (config['web.chat_completions_api_key'] as string) || '';
ccBaseUrl = (config['web.chat_completions_base_url'] as string) || '';
ccModel = (config['web.chat_completions_model'] as string) || '';
// Browser
browserEnabled = config['browser.enabled'] === true || config['browser.enabled'] === 'true';
browserProvider = (config['browser.provider'] as typeof browserProvider) || 'local';
cdpUrl = (config['browser.cdp_url'] as string) || 'http://localhost:9222';
autoLaunch = config['browser.auto_launch'] !== false && config['browser.auto_launch'] !== 'false';
sessionTimeout = Number(config['browser.session_timeout_minutes']) || 10;
firecrawlApiKey = (config['browser.firecrawl_api_key'] as string) || '';
firecrawlBaseUrl = (config['browser.firecrawl_base_url'] as string) || 'https://api.firecrawl.dev';
browserUseApiKey = (config['browser.browser_use_api_key'] as string) || '';
browserUseBaseUrl = (config['browser.browser_use_base_url'] as string) || 'https://api.browser-use.com';
} catch {
toast.error($t('admin.failedToLoadConfig'));
}
loading = false;
});
async function save() {
saving = true;
try {
const cfg: Record<string, unknown> = {
'web.enabled': webEnabled,
'web.search_provider': searchProvider,
'web.exa_api_key': exaKey,
'web.tavily_api_key': tavilyKey,
'web.brave_api_key': braveKey,
'web.perplexity_api_key': perplexityKey,
'web.chat_completions_api_key': ccKey,
'web.chat_completions_base_url': ccBaseUrl,
'web.chat_completions_model': ccModel,
'browser.enabled': browserEnabled,
'browser.provider': browserProvider,
'browser.cdp_url': cdpUrl,
'browser.auto_launch': autoLaunch,
'browser.session_timeout_minutes': sessionTimeout,
'browser.firecrawl_api_key': firecrawlApiKey,
'browser.firecrawl_base_url': firecrawlBaseUrl,
'browser.browser_use_api_key': browserUseApiKey,
'browser.browser_use_base_url': browserUseBaseUrl
};
await updateConfig(cfg);
toast.success($t('settings.saved'));
} catch {
toast.error($t('admin.failedToSave'));
} finally {
saving = false;
}
}
async function testConnection() {
testing = true;
testResult = null;
try {
const resp = await fetch(`${cdpUrl}/json/version`);
if (resp.ok) {
const data = await resp.json();
testResult = { ok: true, message: data.Browser || 'Connected' };
} else {
testResult = { ok: false, message: `HTTP ${resp.status}` };
}
} catch {
testResult = { ok: false, message: 'Could not connect' };
} finally {
testing = false;
}
}
</script>
<div class="flex flex-col min-h-full">
<h2 class="text-sm font-medium text-gray-900 dark:text-white mb-4">{$t('admin.web')}</h2>
{#if loading}
<div class="flex justify-center py-8"><Spinner size={16} /></div>
{:else}
<!-- Search -->
<h3 class="text-xs text-gray-400 dark:text-gray-600 mb-2">Search</h3>
<div class="flex flex-col gap-2.5">
<label class="flex items-center justify-between cursor-pointer">
<span class="text-xs text-gray-600 dark:text-gray-400">{$t('admin.webEnabled')}</span>
<ToggleSwitch value={webEnabled} onchange={(v) => { webEnabled = v; }} />
</label>
<p class="text-[11px] text-gray-400 dark:text-gray-600 -mt-1">
{webEnabled ? $t('admin.webEnabledHint') : $t('admin.webDisabledHint')}
</p>
{#if webEnabled}
<div class="flex items-center justify-between">
<span class="text-xs text-gray-600 dark:text-gray-400">{$t('admin.webSearchProvider')}</span>
<select
bind:value={searchProvider}
class="bg-transparent text-xs text-gray-600 dark:text-gray-400 outline-none cursor-pointer"
>
<option value="auto">{$t('admin.webProviderAuto')}</option>
<option value="exa">Exa</option>
<option value="tavily">Tavily</option>
<option value="brave">Brave</option>
<option value="perplexity">Perplexity</option>
<option value="duckduckgo">DuckDuckGo</option>
<option value="chat_completions">{$t('admin.webChatCompletions')}</option>
</select>
</div>
<p class="text-[11px] text-gray-400 dark:text-gray-600 -mt-1">
{#if searchProvider === 'auto'}
{$t('admin.webAutoHint')}
{:else if searchProvider === 'duckduckgo'}
{$t('admin.webDuckDuckGoNote')}
{/if}
</p>
{#if searchProvider === 'exa'}
<div>
<label class="text-xs text-gray-600 dark:text-gray-400" for="exa-key">{$t('admin.webExaKey')}</label>
<input id="exa-key" type="password" bind:value={exaKey} placeholder="exa-..."
class="w-full mt-1 h-7 px-2 rounded-lg text-xs bg-gray-100 dark:bg-white/6 text-gray-700 dark:text-gray-300 border border-gray-200 dark:border-white/8 outline-none focus:border-blue-400 dark:focus:border-blue-500 transition-colors" />
<p class="text-[11px] text-gray-400 dark:text-gray-600 mt-0.5">{$t('admin.webExaHint')}</p>
</div>
{:else if searchProvider === 'tavily'}
<div>
<label class="text-xs text-gray-600 dark:text-gray-400" for="tavily-key">{$t('admin.webTavilyKey')}</label>
<input id="tavily-key" type="password" bind:value={tavilyKey} placeholder="tvly-..."
class="w-full mt-1 h-7 px-2 rounded-lg text-xs bg-gray-100 dark:bg-white/6 text-gray-700 dark:text-gray-300 border border-gray-200 dark:border-white/8 outline-none focus:border-blue-400 dark:focus:border-blue-500 transition-colors" />
<p class="text-[11px] text-gray-400 dark:text-gray-600 mt-0.5">{$t('admin.webTavilyHint')}</p>
</div>
{:else if searchProvider === 'brave'}
<div>
<label class="text-xs text-gray-600 dark:text-gray-400" for="brave-key">{$t('admin.webBraveKey')}</label>
<input id="brave-key" type="password" bind:value={braveKey} placeholder="BSA..."
class="w-full mt-1 h-7 px-2 rounded-lg text-xs bg-gray-100 dark:bg-white/6 text-gray-700 dark:text-gray-300 border border-gray-200 dark:border-white/8 outline-none focus:border-blue-400 dark:focus:border-blue-500 transition-colors" />
<p class="text-[11px] text-gray-400 dark:text-gray-600 mt-0.5">{$t('admin.webBraveHint')}</p>
</div>
{:else if searchProvider === 'perplexity'}
<div>
<label class="text-xs text-gray-600 dark:text-gray-400" for="pplx-key">{$t('admin.webPerplexityKey')}</label>
<input id="pplx-key" type="password" bind:value={perplexityKey} placeholder="pplx-..."
class="w-full mt-1 h-7 px-2 rounded-lg text-xs bg-gray-100 dark:bg-white/6 text-gray-700 dark:text-gray-300 border border-gray-200 dark:border-white/8 outline-none focus:border-blue-400 dark:focus:border-blue-500 transition-colors" />
<p class="text-[11px] text-gray-400 dark:text-gray-600 mt-0.5">{$t('admin.webPerplexityHint')}</p>
</div>
{:else if searchProvider === 'chat_completions'}
<div>
<label class="text-xs text-gray-600 dark:text-gray-400" for="cc-base-url">{$t('admin.webCcBaseUrl')}</label>
<input id="cc-base-url" type="text" bind:value={ccBaseUrl} placeholder="https://api.perplexity.ai/v1"
class="w-full mt-1 h-7 px-2 rounded-lg text-xs bg-gray-100 dark:bg-white/6 text-gray-700 dark:text-gray-300 border border-gray-200 dark:border-white/8 outline-none focus:border-blue-400 dark:focus:border-blue-500 transition-colors" />
</div>
<div>
<label class="text-xs text-gray-600 dark:text-gray-400" for="cc-key">{$t('admin.webCcKey')}</label>
<input id="cc-key" type="password" bind:value={ccKey} placeholder="sk-..."
class="w-full mt-1 h-7 px-2 rounded-lg text-xs bg-gray-100 dark:bg-white/6 text-gray-700 dark:text-gray-300 border border-gray-200 dark:border-white/8 outline-none focus:border-blue-400 dark:focus:border-blue-500 transition-colors" />
</div>
<div>
<label class="text-xs text-gray-600 dark:text-gray-400" for="cc-model">{$t('admin.webCcModel')}</label>
<input id="cc-model" type="text" bind:value={ccModel} placeholder="sonar-pro"
class="w-full mt-1 h-7 px-2 rounded-lg text-xs bg-gray-100 dark:bg-white/6 text-gray-700 dark:text-gray-300 border border-gray-200 dark:border-white/8 outline-none focus:border-blue-400 dark:focus:border-blue-500 transition-colors" />
</div>
<p class="text-[11px] text-gray-400 dark:text-gray-600 -mt-1">{$t('admin.webCcHint')}</p>
{/if}
{/if}
</div>
<!-- Browser -->
<h3 class="text-xs text-gray-400 dark:text-gray-600 mb-2 mt-5">Browser</h3>
<div class="flex flex-col gap-2.5">
<label class="flex items-center justify-between cursor-pointer">
<span class="text-xs text-gray-600 dark:text-gray-400">Browser tools</span>
<ToggleSwitch value={browserEnabled} onchange={(v) => { browserEnabled = v; }} />
</label>
<p class="text-[11px] text-gray-400 dark:text-gray-600 -mt-1">
Give the AI access to a web browser for navigating pages, clicking elements, and taking screenshots.
</p>
{#if browserEnabled}
<div class="flex items-center justify-between">
<span class="text-xs text-gray-600 dark:text-gray-400">Provider</span>
<select
bind:value={browserProvider}
class="bg-transparent text-xs text-gray-600 dark:text-gray-400 outline-none cursor-pointer"
>
<option value="local">Local CDP</option>
<option value="firecrawl">Firecrawl</option>
<option value="browser_use">Browser-Use</option>
</select>
</div>
<p class="text-[11px] text-gray-400 dark:text-gray-600 -mt-1">
{#if browserProvider === 'local'}
Connects to Chrome via DevTools Protocol. Full interactive browsing with clicking, typing, and screenshots.
{:else if browserProvider === 'firecrawl'}
Cloud API that converts web pages to markdown. Fast extraction, no interactive browsing.
{:else}
Cloud API for LLM-driven browser tasks. Describe what you need in natural language.
{/if}
</p>
{#if browserProvider === 'local'}
<label class="flex items-center justify-between cursor-pointer">
<div>
<span class="text-xs text-gray-600 dark:text-gray-400">Auto-launch Chrome</span>
<p class="text-[10px] text-gray-400 dark:text-gray-600">Start a headless Chrome if none is running</p>
</div>
<ToggleSwitch value={autoLaunch} onchange={(v) => { autoLaunch = v; }} />
</label>
<div>
<label class="text-xs text-gray-600 dark:text-gray-400" for="cdp-url">CDP URL</label>
<div class="flex gap-1.5 mt-1">
<input id="cdp-url" type="text" bind:value={cdpUrl} placeholder="http://localhost:9222"
class="flex-1 h-7 px-2 rounded-lg text-xs bg-gray-100 dark:bg-white/6 text-gray-700 dark:text-gray-300 border border-gray-200 dark:border-white/8 outline-none focus:border-blue-400 dark:focus:border-blue-500 transition-colors" />
<button
class="h-7 px-2.5 rounded-lg text-xs bg-gray-200/50 dark:bg-white/8 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors disabled:opacity-50"
onclick={() => testConnection()}
disabled={testing}
>{testing ? '...' : 'Test'}</button>
</div>
{#if testResult}
<p class="text-[11px] mt-1 {testResult.ok ? 'text-emerald-600 dark:text-emerald-400' : 'text-red-500'}">
{testResult.message}
</p>
{/if}
</div>
<div>
<label class="text-xs text-gray-600 dark:text-gray-400" for="session-timeout">Session timeout</label>
<div class="flex items-center gap-1.5 mt-1">
<input id="session-timeout" type="number" bind:value={sessionTimeout} min="1" max="120"
class="w-16 h-7 px-2 rounded-lg text-xs bg-gray-100 dark:bg-white/6 text-gray-700 dark:text-gray-300 border border-gray-200 dark:border-white/8 outline-none focus:border-blue-400 dark:focus:border-blue-500 transition-colors" />
<span class="text-[11px] text-gray-400 dark:text-gray-600">minutes</span>
</div>
</div>
{:else if browserProvider === 'firecrawl'}
<div>
<label class="text-xs text-gray-600 dark:text-gray-400" for="fc-key">API Key</label>
<input id="fc-key" type="password" bind:value={firecrawlApiKey} placeholder="fc-..."
class="w-full mt-1 h-7 px-2 rounded-lg text-xs bg-gray-100 dark:bg-white/6 text-gray-700 dark:text-gray-300 border border-gray-200 dark:border-white/8 outline-none focus:border-blue-400 dark:focus:border-blue-500 transition-colors" />
</div>
<div>
<label class="text-xs text-gray-600 dark:text-gray-400" for="fc-url">Base URL</label>
<input id="fc-url" type="text" bind:value={firecrawlBaseUrl} placeholder="https://api.firecrawl.dev"
class="w-full mt-1 h-7 px-2 rounded-lg text-xs bg-gray-100 dark:bg-white/6 text-gray-700 dark:text-gray-300 border border-gray-200 dark:border-white/8 outline-none focus:border-blue-400 dark:focus:border-blue-500 transition-colors" />
<p class="text-[11px] text-gray-400 dark:text-gray-600 mt-0.5">Change for self-hosted Firecrawl instances</p>
</div>
{:else if browserProvider === 'browser_use'}
<div>
<label class="text-xs text-gray-600 dark:text-gray-400" for="bu-key">API Key</label>
<input id="bu-key" type="password" bind:value={browserUseApiKey} placeholder="bu-..."
class="w-full mt-1 h-7 px-2 rounded-lg text-xs bg-gray-100 dark:bg-white/6 text-gray-700 dark:text-gray-300 border border-gray-200 dark:border-white/8 outline-none focus:border-blue-400 dark:focus:border-blue-500 transition-colors" />
</div>
<div>
<label class="text-xs text-gray-600 dark:text-gray-400" for="bu-url">Base URL</label>
<input id="bu-url" type="text" bind:value={browserUseBaseUrl} placeholder="https://api.browser-use.com"
class="w-full mt-1 h-7 px-2 rounded-lg text-xs bg-gray-100 dark:bg-white/6 text-gray-700 dark:text-gray-300 border border-gray-200 dark:border-white/8 outline-none focus:border-blue-400 dark:focus:border-blue-500 transition-colors" />
</div>
{/if}
{/if}
</div>
<!-- Save -->
<div class="mt-auto pt-6 flex justify-end">
<button
class="text-[13px] text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors duration-100 disabled:opacity-50"
onclick={() => save()}
disabled={saving}
>{$t('settings.save')}</button>
</div>
{/if}
</div>
@@ -360,6 +360,13 @@
<path d="M13 2.04932C13 2.04932 16 6 16 12C16 18 13 21.9507 13 21.9507" />
<path d="M11 21.9507C11 21.9507 8 18 8 12C8 6 11 2.04932 11 2.04932" />
<path d="M2 12H22" />
{:else if name === 'globe'}
<circle cx="12" cy="12" r="10" />
<path d="M2 12H22" />
<path d="M12 2C14.5 5.5 14.5 18.5 12 22" />
<path d="M12 2C9.5 5.5 9.5 18.5 12 22" />
<path d="M4 7H20" />
<path d="M4 17H20" />
{:else if name === 'microphone'}
<path
d="M12 1C10.3431 1 9 2.34315 9 4V12C9 13.6569 10.3431 15 12 15C13.6569 15 15 13.6569 15 12V4C15 2.34315 13.6569 1 12 1Z"
@@ -1,254 +0,0 @@
<script lang="ts">
import { toast } from 'svelte-sonner';
import ToggleSwitch from '../common/ToggleSwitch.svelte';
import Spinner from '../common/Spinner.svelte';
import { onMount } from 'svelte';
import { getAdminConfig, updateConfig } from '$lib/apis/admin';
import { t } from '$lib/i18n';
let loading = $state(true);
let saving = $state(false);
let testing = $state(false);
let testResult = $state<{ ok: boolean; message: string } | null>(null);
// Config state
let enabled = $state(false);
let provider = $state<'local' | 'firecrawl' | 'browser_use'>('local');
let cdpUrl = $state('http://localhost:9222');
let autoLaunch = $state(true);
let sessionTimeout = $state(10);
let firecrawlApiKey = $state('');
let firecrawlBaseUrl = $state('https://api.firecrawl.dev');
let browserUseApiKey = $state('');
let browserUseBaseUrl = $state('https://api.browser-use.com');
onMount(async () => {
try {
const config = await getAdminConfig();
enabled = config['browser.enabled'] === true || config['browser.enabled'] === 'true';
provider = (config['browser.provider'] as typeof provider) || 'local';
cdpUrl = (config['browser.cdp_url'] as string) || 'http://localhost:9222';
autoLaunch = config['browser.auto_launch'] !== false && config['browser.auto_launch'] !== 'false';
sessionTimeout = Number(config['browser.session_timeout_minutes']) || 10;
firecrawlApiKey = (config['browser.firecrawl_api_key'] as string) || '';
firecrawlBaseUrl = (config['browser.firecrawl_base_url'] as string) || 'https://api.firecrawl.dev';
browserUseApiKey = (config['browser.browser_use_api_key'] as string) || '';
browserUseBaseUrl = (config['browser.browser_use_base_url'] as string) || 'https://api.browser-use.com';
} catch {}
loading = false;
});
async function save() {
saving = true;
try {
await updateConfig({
'browser.enabled': enabled,
'browser.provider': provider,
'browser.cdp_url': cdpUrl,
'browser.auto_launch': autoLaunch,
'browser.session_timeout_minutes': sessionTimeout,
'browser.firecrawl_api_key': firecrawlApiKey,
'browser.firecrawl_base_url': firecrawlBaseUrl,
'browser.browser_use_api_key': browserUseApiKey,
'browser.browser_use_base_url': browserUseBaseUrl
});
toast.success($t('settings.saved'));
} catch {
toast.error('Failed to save browser settings');
} finally {
saving = false;
}
}
async function testConnection() {
testing = true;
testResult = null;
try {
const resp = await fetch(`${cdpUrl}/json/version`);
if (resp.ok) {
const data = await resp.json();
testResult = { ok: true, message: data.Browser || 'Connected' };
} else {
testResult = { ok: false, message: `HTTP ${resp.status}` };
}
} catch {
testResult = { ok: false, message: 'Could not connect' };
} finally {
testing = false;
}
}
</script>
<div class="flex flex-col min-h-full">
<h2 class="text-sm font-medium text-gray-900 dark:text-white mb-4">Browser</h2>
{#if loading}
<div class="flex justify-center py-8"><Spinner size={16} /></div>
{:else}
<!-- Enable -->
<h3 class="text-xs text-gray-400 dark:text-gray-600 mb-2">Enable</h3>
<div class="flex flex-col gap-2.5">
<label class="flex items-center justify-between cursor-pointer">
<span class="text-xs text-gray-600 dark:text-gray-400">Browser tools</span>
<ToggleSwitch value={enabled} onchange={(v) => { enabled = v; }} />
</label>
<p class="text-[11px] text-gray-400 dark:text-gray-600 -mt-1">
Give the AI access to a web browser for navigating pages, clicking elements, and taking screenshots.
</p>
</div>
{#if enabled}
<!-- Provider -->
<h3 class="text-xs text-gray-400 dark:text-gray-600 mb-2 mt-5">Provider</h3>
<div class="flex gap-1">
{#each [
{ value: 'local' as const, label: 'Local CDP' },
{ value: 'firecrawl' as const, label: 'Firecrawl' },
{ value: 'browser_use' as const, label: 'Browser-Use' }
] as opt}
<button
class="flex items-center gap-1.5 h-7 px-2.5 rounded-lg text-xs transition-colors duration-100
{provider === opt.value
? 'bg-gray-200/50 dark:bg-white/8 text-gray-900 dark:text-white font-medium'
: 'text-gray-500 hover:text-gray-700 dark:hover:text-gray-300'}"
onclick={() => { provider = opt.value; }}
>
{opt.label}
</button>
{/each}
</div>
<p class="text-[11px] text-gray-400 dark:text-gray-600 mt-1">
{#if provider === 'local'}
Connects to Chrome via DevTools Protocol. Full interactive browsing with clicking, typing, and screenshots.
{:else if provider === 'firecrawl'}
Cloud API that converts web pages to markdown. Fast extraction, no interactive browsing.
{:else}
Cloud API for LLM-driven browser tasks. Describe what you need in natural language.
{/if}
</p>
<!-- Local CDP settings -->
{#if provider === 'local'}
<h3 class="text-xs text-gray-400 dark:text-gray-600 mb-2 mt-5">Connection</h3>
<div class="flex flex-col gap-2.5">
<label class="flex items-center justify-between cursor-pointer">
<div>
<span class="text-xs text-gray-600 dark:text-gray-400">Auto-launch Chrome</span>
<p class="text-[10px] text-gray-400 dark:text-gray-600">Start a headless Chrome if none is running</p>
</div>
<ToggleSwitch value={autoLaunch} onchange={(v) => { autoLaunch = v; }} />
</label>
<div>
<label class="text-xs text-gray-600 dark:text-gray-400" for="cdp-url">CDP URL</label>
<div class="flex gap-1.5 mt-1">
<input
id="cdp-url"
type="text"
bind:value={cdpUrl}
placeholder="http://localhost:9222"
class="flex-1 h-7 px-2 rounded-lg text-xs bg-gray-100 dark:bg-white/6 text-gray-700 dark:text-gray-300 border border-gray-200 dark:border-white/8 outline-none focus:border-blue-400 dark:focus:border-blue-500 transition-colors"
/>
<button
class="h-7 px-2.5 rounded-lg text-xs bg-gray-200/50 dark:bg-white/8 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors disabled:opacity-50"
onclick={() => testConnection()}
disabled={testing}
>
{testing ? '...' : 'Test'}
</button>
</div>
{#if testResult}
<p class="text-[11px] mt-1 {testResult.ok ? 'text-emerald-600 dark:text-emerald-400' : 'text-red-500'}">
{testResult.message}
</p>
{/if}
</div>
<div>
<label class="text-xs text-gray-600 dark:text-gray-400" for="session-timeout">Session timeout</label>
<div class="flex items-center gap-1.5 mt-1">
<input
id="session-timeout"
type="number"
bind:value={sessionTimeout}
min="1"
max="120"
class="w-16 h-7 px-2 rounded-lg text-xs bg-gray-100 dark:bg-white/6 text-gray-700 dark:text-gray-300 border border-gray-200 dark:border-white/8 outline-none focus:border-blue-400 dark:focus:border-blue-500 transition-colors"
/>
<span class="text-[11px] text-gray-400 dark:text-gray-600">minutes</span>
</div>
</div>
</div>
{/if}
<!-- Firecrawl settings -->
{#if provider === 'firecrawl'}
<h3 class="text-xs text-gray-400 dark:text-gray-600 mb-2 mt-5">Firecrawl</h3>
<div class="flex flex-col gap-2.5">
<div>
<label class="text-xs text-gray-600 dark:text-gray-400" for="fc-key">API Key</label>
<input
id="fc-key"
type="password"
bind:value={firecrawlApiKey}
placeholder="fc-..."
class="w-full mt-1 h-7 px-2 rounded-lg text-xs bg-gray-100 dark:bg-white/6 text-gray-700 dark:text-gray-300 border border-gray-200 dark:border-white/8 outline-none focus:border-blue-400 dark:focus:border-blue-500 transition-colors"
/>
</div>
<div>
<label class="text-xs text-gray-600 dark:text-gray-400" for="fc-url">Base URL</label>
<input
id="fc-url"
type="text"
bind:value={firecrawlBaseUrl}
placeholder="https://api.firecrawl.dev"
class="w-full mt-1 h-7 px-2 rounded-lg text-xs bg-gray-100 dark:bg-white/6 text-gray-700 dark:text-gray-300 border border-gray-200 dark:border-white/8 outline-none focus:border-blue-400 dark:focus:border-blue-500 transition-colors"
/>
<p class="text-[11px] text-gray-400 dark:text-gray-600 mt-1">Change for self-hosted Firecrawl instances</p>
</div>
</div>
{/if}
<!-- Browser-Use settings -->
{#if provider === 'browser_use'}
<h3 class="text-xs text-gray-400 dark:text-gray-600 mb-2 mt-5">Browser-Use</h3>
<div class="flex flex-col gap-2.5">
<div>
<label class="text-xs text-gray-600 dark:text-gray-400" for="bu-key">API Key</label>
<input
id="bu-key"
type="password"
bind:value={browserUseApiKey}
placeholder="bu-..."
class="w-full mt-1 h-7 px-2 rounded-lg text-xs bg-gray-100 dark:bg-white/6 text-gray-700 dark:text-gray-300 border border-gray-200 dark:border-white/8 outline-none focus:border-blue-400 dark:focus:border-blue-500 transition-colors"
/>
</div>
<div>
<label class="text-xs text-gray-600 dark:text-gray-400" for="bu-url">Base URL</label>
<input
id="bu-url"
type="text"
bind:value={browserUseBaseUrl}
placeholder="https://api.browser-use.com"
class="w-full mt-1 h-7 px-2 rounded-lg text-xs bg-gray-100 dark:bg-white/6 text-gray-700 dark:text-gray-300 border border-gray-200 dark:border-white/8 outline-none focus:border-blue-400 dark:focus:border-blue-500 transition-colors"
/>
</div>
</div>
{/if}
{/if}
<!-- Save -->
<div class="mt-auto pt-6 flex justify-end">
<button
class="text-[13px] text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors duration-100 disabled:opacity-50"
onclick={() => save()}
disabled={saving}
>{$t('settings.save')}</button>
</div>
{/if}
</div>
@@ -4,7 +4,6 @@
import General from './Settings/General.svelte';
import Account from './Settings/Account.svelte';
import Keyboard from './Settings/Keyboard.svelte';
import Browser from './Settings/Browser.svelte';
import About from './Settings/About.svelte';
import Users from './Admin/Users.svelte';
import Connections from './Admin/Connections.svelte';
@@ -12,14 +11,13 @@
import Messaging from './Admin/Messaging.svelte';
import Gateway from './Admin/Gateway.svelte';
import AudioSettings from './Admin/AudioSettings.svelte';
import AdminSettings from './Admin/Settings.svelte';
import AdminWeb from './Admin/Web.svelte';
import { session } from '$lib/session';
import { t } from '$lib/i18n';
type Tab =
| 'general'
| 'keyboard'
| 'browser'
| 'account'
| 'about'
| 'users'
@@ -28,7 +26,7 @@
| 'messaging'
| 'gateway'
| 'audio'
| 'admin_settings';
| 'web';
interface Props {
onclose: () => void;
@@ -55,8 +53,7 @@
{ id: 'messaging', label: $t('admin.messaging'), icon: 'chat-bubble' },
{ id: 'gateway', label: $t('admin.gateway.tab'), icon: 'gateway' },
{ id: 'audio', label: 'Audio', icon: 'microphone' },
{ id: 'browser', label: 'Browser', icon: 'browser' },
{ id: 'admin_settings', label: $t('settings.configuration'), icon: 'shield' }
{ id: 'web', label: $t('admin.web'), icon: 'globe' }
]);
</script>
@@ -117,8 +114,6 @@
<General />
{:else if activeTab === 'keyboard'}
<Keyboard />
{:else if activeTab === 'browser'}
<Browser />
{:else if activeTab === 'account'}
<Account />
{:else if activeTab === 'about'}
@@ -135,8 +130,8 @@
<Gateway />
{:else if activeTab === 'audio'}
<AudioSettings />
{:else if activeTab === 'admin_settings'}
<AdminSettings />
{:else if activeTab === 'web'}
<AdminWeb />
{/if}
</div>
</Modal>
@@ -322,7 +322,6 @@
"general.interruptDesc": "Sending a message cancels the current generation.",
"settings.keyboard": "Keyboard",
"settings.configuration": "Configuration",
"keyboard.title": "Keyboard Shortcuts",
"keyboard.resetDefaults": "Reset defaults",
@@ -295,7 +295,6 @@
"general.queueDesc": "Les messages envoyés pendant la génération sont mis en file et envoyés après la fin.",
"general.interruptDesc": "L'envoi d'un message annule la génération en cours.",
"settings.keyboard": "Clavier",
"settings.configuration": "Configuration",
"keyboard.title": "Raccourcis clavier",
"keyboard.resetDefaults": "Réinitialiser",
"keyboard.command": "Commande",