项目文件夹

文件
wehub-resource-sync e9a2f726c9
CI / test (3.11) (push) Has been cancelled
CI / test (3.12) (push) Has been cancelled
CI / test (3.13) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:29:51 +08:00

1409 行
131 KiB
HTML

<div x-show="mainTab === 'settings'" x-cloak>
<!-- Tab Navigation -->
<div class="flex items-center gap-2 mb-10 animate-fade-in-up">
<button
@click="setSettingsTab('global')"
:class="activeTab === 'global'
? 'bg-neutral-900 text-white'
: 'text-neutral-600 hover:text-neutral-900 hover:bg-neutral-100'"
class="px-5 py-2.5 rounded-full text-sm font-medium transition-all duration-200"
>
{{ t('settings.tab.global') }}
</button>
<button
@click="setSettingsTab('models')"
:class="activeTab === 'models'
? 'bg-neutral-900 text-white'
: 'text-neutral-600 hover:text-neutral-900 hover:bg-neutral-100'"
class="px-5 py-2.5 rounded-full text-sm font-medium transition-all duration-200"
>
{{ t('settings.tab.models') }}
</button>
<button
@click="setSettingsTab('integrations')"
:class="activeTab === 'integrations'
? 'bg-neutral-900 text-white'
: 'text-neutral-600 hover:text-neutral-900 hover:bg-neutral-100'"
class="px-5 py-2.5 rounded-full text-sm font-medium transition-all duration-200"
>
{{ t('settings.tab.integrations') }}
</button>
</div>
<!-- Global Settings Tab -->
<div x-show="activeTab === 'global'" x-cloak>
<div class="animate-fade-in-up">
<!-- Header -->
<div class="mb-8">
<h2 class="text-xs font-bold uppercase tracking-widest text-neutral-400 mb-2">{{ t('settings.global.section_label') }}</h2>
<h3 class="text-2xl font-bold tracking-tight text-neutral-900">{{ t('settings.global.heading') }}</h3>
<p class="text-sm text-neutral-500 mt-2">
{{ t('settings.global.restart_notice') }} <span class="px-1 py-0.5 text-[9px] font-medium rounded bg-amber-50 text-amber-600 border border-amber-200">{{ t('settings.global.restart_badge') }}</span> {{ t('settings.global.restart_suffix') }}
</p>
</div>
<!-- Settings Sections -->
<div class="space-y-4">
<!-- Language Section -->
<div class="bg-white rounded-2xl border border-neutral-200 overflow-hidden">
<div class="flex items-center justify-between px-6 py-4 bg-neutral-100 border-b border-neutral-200">
<div class="flex items-center gap-3">
<i data-lucide="globe" class="w-4 h-4 text-neutral-500"></i>
<span class="text-xs font-bold uppercase tracking-wider text-neutral-600">{{ t('settings.language.section_label') }}</span>
</div>
</div>
<div class="divide-y divide-neutral-100">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 px-4 sm:px-6 py-4">
<label class="text-sm text-neutral-700">{{ t('settings.language.label') }}</label>
<select x-model="globalSettings.ui.language"
@change="saveLanguage(globalSettings.ui.language)"
class="px-3 py-2 text-sm border border-neutral-200 rounded-lg focus:ring-2 focus:ring-neutral-900 focus:border-transparent transition-all bg-white">
<option value="en">{{ t('settings.language.en') }}</option>
<option value="zh">{{ t('settings.language.zh') }}</option>
<option value="zh-TW">{{ t('settings.language.zh-TW') }}</option>
<option value="ko">{{ t('settings.language.ko') }}</option>
<option value="ja">{{ t('settings.language.ja') }}</option>
<option value="ru">{{ t('settings.language.ru') }}</option>
<option value="es">{{ t('settings.language.es') }}</option>
<option value="fr">{{ t('settings.language.fr') }}</option>
<option value="pt-BR">{{ t('settings.language.pt-BR') }}</option>
</select>
</div>
</div>
</div>
<!-- Auth & Info Section -->
<div class="bg-white rounded-2xl border border-neutral-200 overflow-hidden">
<div class="flex items-center justify-between px-6 py-4 bg-neutral-100 border-b border-neutral-200">
<div class="flex items-center gap-3">
<i data-lucide="shield" class="w-4 h-4 text-neutral-500"></i>
<span class="text-xs font-bold uppercase tracking-wider text-neutral-600">{{ t('settings.auth.section_label') }}</span>
</div>
</div>
<div class="divide-y divide-neutral-100">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 px-4 sm:px-6 py-4">
<div>
<label class="text-sm text-neutral-700">{{ t('settings.auth.base_path') }}</label>
<p class="text-xs text-neutral-400 mt-0.5">{{ t('settings.auth.base_path_hint') }}</p>
</div>
<span class="text-sm text-neutral-700 font-mono" x-text="globalSettings.base_path || '~/.omlx'"></span>
</div>
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 px-4 sm:px-6 py-4">
<div>
<label class="text-sm text-neutral-700">{{ t('settings.auth.api_key') }}</label>
<p class="text-xs text-neutral-400 mt-0.5">{{ t('settings.auth.api_key_hint') }}</p>
</div>
<div class="flex items-center gap-2">
<input :type="showApiKey ? 'text' : 'password'"
x-model="globalSettings.auth.api_key"
placeholder="{{ t('settings.auth.api_key_placeholder') }}"
class="w-full sm:w-64 px-3 py-2 text-sm text-right border border-neutral-200 rounded-lg focus:ring-2 focus:ring-neutral-900 focus:border-transparent transition-all font-mono text-xs">
<button type="button" @click="showApiKey = !showApiKey"
class="p-2 rounded-lg hover:bg-neutral-100 transition-colors text-neutral-400 hover:text-neutral-600"
title="{{ t('settings.auth.toggle_visibility_tooltip') }}">
<template x-if="!showApiKey">
<svg class="w-4 h-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0"/><circle cx="12" cy="12" r="3"/></svg>
</template>
<template x-if="showApiKey">
<svg class="w-4 h-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.733 5.076a10.744 10.744 0 0 1 11.205 6.575 1 1 0 0 1 0 .696 10.747 10.747 0 0 1-1.444 2.49"/><path d="M14.084 14.158a3 3 0 0 1-4.242-4.242"/><path d="M17.479 17.499a10.75 10.75 0 0 1-15.417-5.151 1 1 0 0 1 0-.696 10.75 10.75 0 0 1 4.446-5.143"/><path d="m2 2 20 20"/></svg>
</template>
</button>
</div>
</div>
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 px-4 sm:px-6 py-4">
<div>
<label class="text-sm text-neutral-700">{{ t('settings.auth.skip_verification') }}</label>
<p class="text-xs text-neutral-400 mt-0.5">{{ t('settings.auth.skip_verification_hint') }}</p>
<p class="text-xs text-red-500 mt-0.5"
x-show="globalSettings.server.host !== '127.0.0.1'" x-cloak>{{ t('settings.auth.skip_verification_warning') }}</p>
</div>
<button type="button"
@click="globalSettings.auth.skip_api_key_verification = !globalSettings.auth.skip_api_key_verification"
:class="globalSettings.auth.skip_api_key_verification ? 'bg-black' : 'bg-neutral-200'"
class="relative w-11 h-6 rounded-full transition-colors duration-300 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-black">
<span :class="globalSettings.auth.skip_api_key_verification ? 'translate-x-5' : 'translate-x-0'"
class="block w-5 h-5 bg-white rounded-full shadow-sm transform transition-transform duration-300 absolute top-0.5 left-0.5"></span>
</button>
</div>
<!-- Sub Keys Section -->
<div class="px-6 py-4">
<div class="flex items-center justify-between mb-3">
<div>
<label class="text-sm text-neutral-700">{{ t('settings.auth.sub_keys_label') }}</label>
<p class="text-xs text-neutral-400 mt-0.5">{{ t('settings.auth.sub_keys_hint') }}</p>
</div>
<button type="button" @click="showNewSubKeyForm = !showNewSubKeyForm"
class="px-3 py-1.5 text-xs font-medium text-neutral-600 bg-neutral-100 rounded-lg hover:bg-neutral-200 transition-colors">
<span x-show="!showNewSubKeyForm">+ {{ t('settings.auth.sub_keys_add') }}</span>
<span x-show="showNewSubKeyForm">{{ t('settings.auth.sub_keys_cancel') }}</span>
</button>
</div>
<!-- New Sub Key Form -->
<div x-show="showNewSubKeyForm" x-cloak class="mb-3 p-3 bg-neutral-50 rounded-xl border border-neutral-200">
<div class="flex flex-col sm:flex-row gap-2 mb-2">
<input type="text" x-model="newSubKeyName"
placeholder="{{ t('settings.auth.sub_keys_name_placeholder') }}"
class="w-full sm:w-1/3 px-3 py-2 text-sm border border-neutral-200 rounded-lg focus:ring-2 focus:ring-neutral-900 focus:border-transparent">
<input :type="showNewSubKey ? 'text' : 'password'" x-model="newSubKeyValue"
placeholder="{{ t('settings.auth.sub_keys_key_placeholder') }}"
class="flex-1 px-3 py-2 text-sm border border-neutral-200 rounded-lg focus:ring-2 focus:ring-neutral-900 focus:border-transparent font-mono">
<button type="button" @click="showNewSubKey = !showNewSubKey"
class="px-2.5 py-2 text-neutral-500 bg-white border border-neutral-200 rounded-lg hover:bg-neutral-100 transition-colors"
title="{{ t('settings.auth.toggle_visibility_tooltip') }}">
<template x-if="!showNewSubKey">
<svg class="w-4 h-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0"/><circle cx="12" cy="12" r="3"/></svg>
</template>
<template x-if="showNewSubKey">
<svg class="w-4 h-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.733 5.076a10.744 10.744 0 0 1 11.205 6.575 1 1 0 0 1 0 .696 10.747 10.747 0 0 1-1.444 2.49"/><path d="M14.084 14.158a3 3 0 0 1-4.242-4.242"/><path d="M17.479 17.499a10.75 10.75 0 0 1-15.417-5.151 1 1 0 0 1 0-.696 10.75 10.75 0 0 1 4.446-5.143"/><path d="m2 2 20 20"/></svg>
</template>
</button>
<button type="button" @click="generateSubKey()"
class="px-2.5 py-2 text-neutral-500 bg-white border border-neutral-200 rounded-lg hover:bg-neutral-100 transition-colors"
title="{{ t('settings.auth.sub_keys_generate_tooltip') }}">
<svg class="w-4 h-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21.64 3.64-1.28-1.28a1.21 1.21 0 0 0-1.72 0L2.36 18.64a1.21 1.21 0 0 0 0 1.72l1.28 1.28a1.2 1.2 0 0 0 1.72 0L21.64 5.36a1.2 1.2 0 0 0 0-1.72"/><path d="m14 7 3 3"/><path d="M5 6v4"/><path d="M19 14v4"/><path d="M10 2v2"/><path d="M7 8H3"/><path d="M21 16h-4"/><path d="M11 3H9"/></svg>
</button>
</div>
<div x-show="subKeyError" class="text-xs text-red-500 mb-2" x-text="subKeyError"></div>
<button type="button" @click="createSubKey()"
class="px-4 py-1.5 text-xs font-medium text-white bg-neutral-900 rounded-lg hover:bg-neutral-800 transition-colors">
{{ t('settings.auth.sub_keys_create') }}
</button>
</div>
<!-- Existing Sub Keys List -->
<div class="space-y-2">
<template x-for="sk in globalSettings.auth.sub_keys" :key="sk.key">
<div class="flex items-center justify-between p-3 bg-neutral-50 rounded-xl border border-neutral-100">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<span class="text-sm font-medium text-neutral-700" x-text="sk.name || '{{ t('settings.auth.sub_keys_unnamed') }}'"></span>
</div>
<div class="flex items-center gap-2 mt-1">
<code class="text-xs text-neutral-400 font-mono" x-text="showSubKeys[sk.key] ? sk.key : sk.key.substring(0, 4) + '••••••••'"></code>
<button type="button" @click="showSubKeys[sk.key] = !showSubKeys[sk.key]"
class="text-neutral-400 hover:text-neutral-600 transition-colors">
<svg class="w-3.5 h-3.5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0"/><circle cx="12" cy="12" r="3"/></svg>
</button>
</div>
<div class="text-[10px] text-neutral-400 mt-1" x-text="sk.created_at ? new Date(sk.created_at).toLocaleDateString() : ''"></div>
</div>
<button type="button" @click="deleteSubKey(sk.key)"
class="p-1.5 text-neutral-400 hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors ml-2"
title="{{ t('settings.auth.sub_keys_delete') }}">
<svg class="w-4 h-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/></svg>
</button>
</div>
</template>
<div x-show="!globalSettings.auth.sub_keys || globalSettings.auth.sub_keys.length === 0"
class="text-xs text-neutral-400 text-center py-2">
{{ t('settings.auth.sub_keys_empty') }}
</div>
</div>
</div>
</div>
</div>
<!-- Server Section -->
<div class="bg-white rounded-2xl border border-neutral-200 overflow-hidden">
<div class="flex items-center justify-between px-6 py-4 bg-neutral-100 border-b border-neutral-200">
<div class="flex items-center gap-3">
<i data-lucide="server" class="w-4 h-4 text-neutral-500"></i>
<span class="text-xs font-bold uppercase tracking-wider text-neutral-600">{{ t('settings.server.section_label') }}</span>
</div>
</div>
<div class="divide-y divide-neutral-100">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 px-4 sm:px-6 py-4"
x-data="{
_mode: (globalSettings.server.host === '127.0.0.1' || globalSettings.server.host === '0.0.0.0') ? null : 'custom',
get hostMode() {
if (this._mode === 'custom') return 'custom';
if (globalSettings.server.host === '127.0.0.1') return 'localhost';
if (globalSettings.server.host === '0.0.0.0') return 'public';
return 'custom';
},
setHostMode(mode) {
if (mode === 'localhost') { this._mode = null; globalSettings.server.host = '127.0.0.1'; }
else if (mode === 'public') { this._mode = null; globalSettings.server.host = '0.0.0.0'; }
else { this._mode = 'custom'; globalSettings.server.host = ''; }
}
}">
<div class="flex items-center gap-2">
<label class="text-sm text-neutral-700">{{ t('settings.server.host') }}</label>
<span class="px-1.5 py-0.5 text-[9px] font-medium rounded bg-amber-50 text-amber-600 border border-amber-200">{{ t('settings.global.restart_badge') }}</span>
</div>
<div class="flex flex-col items-end gap-1">
<div class="flex items-center gap-2">
<select :value="hostMode"
@change="setHostMode($event.target.value)"
class="px-3 py-2 text-sm border border-neutral-200 rounded-lg focus:ring-2 focus:ring-neutral-900 focus:border-transparent transition-all bg-white">
<option value="localhost">{{ t('settings.server.host_localhost') }}</option>
<option value="public">{{ t('settings.server.host_public') }}</option>
<option value="custom">{{ t('settings.server.host_custom') }}</option>
</select>
<input x-show="hostMode === 'custom'" x-cloak
type="text" x-model="globalSettings.server.host" placeholder="{{ t('settings.server.host_placeholder') }}"
class="w-full sm:w-40 px-3 py-2 text-sm text-right border border-neutral-200 rounded-lg focus:ring-2 focus:ring-neutral-900 focus:border-transparent transition-all">
</div>
<p x-show="hostMode === 'custom'" x-cloak
class="text-xs text-neutral-400">{{ t('settings.server.host_multi_hint') }}</p>
</div>
</div>
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 px-4 sm:px-6 py-4">
<div class="flex items-center gap-2">
<label class="text-sm text-neutral-700">{{ t('settings.server.port') }}</label>
<span class="px-1.5 py-0.5 text-[9px] font-medium rounded bg-amber-50 text-amber-600 border border-amber-200">{{ t('settings.global.restart_badge') }}</span>
</div>
<input type="number" x-model.number="globalSettings.server.port"
class="w-full sm:w-48 px-3 py-2 text-sm text-right border border-neutral-200 rounded-lg focus:ring-2 focus:ring-neutral-900 focus:border-transparent transition-all">
</div>
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 px-4 sm:px-6 py-4">
<div class="flex items-center gap-2">
<label class="text-sm text-neutral-700">{{ t('settings.server.log_level') }}</label>
</div>
<select x-model="globalSettings.server.log_level"
class="w-full sm:w-48 px-3 py-2 text-sm border border-neutral-200 rounded-lg focus:ring-2 focus:ring-neutral-900 focus:border-transparent transition-all bg-white">
<option value="error">{{ t('settings.server.log_level_error') }}</option>
<option value="warning">{{ t('settings.server.log_level_warning') }}</option>
<option value="info">{{ t('settings.server.log_level_info') }}</option>
<option value="debug">{{ t('settings.server.log_level_debug') }}</option>
<option value="trace">{{ t('settings.server.log_level_trace') }}</option>
</select>
</div>
</div>
</div>
<!-- Model Section -->
<div class="bg-white rounded-2xl border border-neutral-200 overflow-hidden">
<div class="flex items-center justify-between px-6 py-4 bg-neutral-100 border-b border-neutral-200">
<div class="flex items-center gap-3">
<i data-lucide="box" class="w-4 h-4 text-neutral-500"></i>
<span class="text-xs font-bold uppercase tracking-wider text-neutral-600">{{ t('settings.model.section_label') }}</span>
</div>
</div>
<div class="divide-y divide-neutral-100">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 px-4 sm:px-6 py-4">
<div>
<label class="text-sm text-neutral-700">{{ t('settings.model.model_fallback') }}</label>
<p class="text-xs text-neutral-400 mt-0.5">{{ t('settings.model.model_fallback_desc') }}</p>
</div>
<button type="button"
@click="globalSettings.model.model_fallback = !globalSettings.model.model_fallback"
:class="globalSettings.model.model_fallback ? 'bg-black' : 'bg-neutral-200'"
class="relative w-11 h-6 rounded-full transition-colors duration-300 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-black">
<span :class="globalSettings.model.model_fallback ? 'translate-x-5' : 'translate-x-0'"
class="block w-5 h-5 bg-white rounded-full shadow-sm transform transition-transform duration-300 absolute top-0.5 left-0.5"></span>
</button>
</div>
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 px-4 sm:px-6 py-4">
<div>
<label class="text-sm text-neutral-700">{{ t('settings.model.hide_helper_models') }}</label>
<p class="text-xs text-neutral-400 mt-0.5">{{ t('settings.model.hide_helper_models_desc') }}</p>
</div>
<button type="button"
@click="globalSettings.model.hide_helper_models = !globalSettings.model.hide_helper_models"
:class="globalSettings.model.hide_helper_models ? 'bg-black' : 'bg-neutral-200'"
class="relative w-11 h-6 rounded-full transition-colors duration-300 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-black">
<span :class="globalSettings.model.hide_helper_models ? 'translate-x-5' : 'translate-x-0'"
class="block w-5 h-5 bg-white rounded-full shadow-sm transform transition-transform duration-300 absolute top-0.5 left-0.5"></span>
</button>
</div>
<div class="px-6 py-4">
<div class="flex items-center justify-between mb-3">
<div class="flex items-center gap-2">
<label class="text-sm text-neutral-700">{{ t('settings.model.model_directories') }}</label>
</div>
<button @click="globalSettings.model.model_dirs.push('')"
class="flex items-center gap-1 text-xs px-2 py-1 text-neutral-500 hover:text-neutral-700 hover:bg-neutral-100 rounded-lg transition-colors">
<i data-lucide="plus" class="w-3.5 h-3.5"></i>
<span>{{ t('settings.model.add') }}</span>
</button>
</div>
<div class="flex flex-col gap-2">
<template x-for="(dir, index) in globalSettings.model.model_dirs" :key="index">
<div class="flex items-center gap-2">
<input type="text" x-model="globalSettings.model.model_dirs[index]"
class="flex-1 px-3 py-2 text-sm border border-neutral-200 rounded-lg focus:ring-2 focus:ring-neutral-900 focus:border-transparent transition-all font-mono text-xs"
:placeholder="index === 0 ? '{{ t('settings.model.placeholder_primary') }}' : '{{ t('settings.model.placeholder_additional') }}'">
<button @click="if (globalSettings.model.model_dirs.length > 1) globalSettings.model.model_dirs.splice(index, 1)"
class="flex-shrink-0 p-1.5 rounded-lg transition-colors"
:class="globalSettings.model.model_dirs.length > 1 ? 'text-neutral-400 hover:text-red-500 hover:bg-red-50' : 'text-neutral-200 cursor-not-allowed'">
<i data-lucide="minus" class="w-3.5 h-3.5"></i>
</button>
</div>
</template>
</div>
</div>
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 px-4 sm:px-6 py-4">
<div>
<label class="text-sm text-neutral-700">{{ t('settings.model.hf_cache') }}</label>
<p class="text-xs text-neutral-400 mt-0.5">{{ t('settings.model.hf_cache_desc') }}</p>
<p class="text-xs text-neutral-500 mt-1 font-mono break-all"
x-show="globalSettings.huggingface.hf_cache_path"
x-text="globalSettings.huggingface.hf_cache_path"></p>
</div>
<button type="button"
@click="globalSettings.huggingface.hf_cache_enabled = !globalSettings.huggingface.hf_cache_enabled"
:class="globalSettings.huggingface.hf_cache_enabled ? 'bg-black' : 'bg-neutral-200'"
class="relative w-11 h-6 rounded-full transition-colors duration-300 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-black">
<span :class="globalSettings.huggingface.hf_cache_enabled ? 'translate-x-5' : 'translate-x-0'"
class="block w-5 h-5 bg-white rounded-full shadow-sm transform transition-transform duration-300 absolute top-0.5 left-0.5"></span>
</button>
</div>
</div>
</div>
<!-- Memory Management Section -->
<div class="bg-white rounded-2xl border border-neutral-200 overflow-hidden">
<div class="flex items-center justify-between px-6 py-4 bg-neutral-100 border-b border-neutral-200">
<div class="flex items-center gap-3">
<i data-lucide="gauge" class="w-4 h-4 text-neutral-500"></i>
<span class="text-xs font-bold uppercase tracking-wider text-neutral-600">{{ t('settings.resource.section_label') }}</span>
</div>
<span class="px-2 py-0.5 text-[10px] font-medium rounded-full bg-green-50 text-green-600 border border-green-200">
{{ t('settings.resource.live_badge') }}
</span>
</div>
<div class="divide-y divide-neutral-100">
<!-- Memory Guard + Tier -->
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 px-4 sm:px-6 py-4">
<div>
<div class="flex items-center gap-2">
<label class="text-sm text-neutral-700">{{ t('settings.resource.prefill_memory_guard') }}</label>
<span class="px-1.5 py-0.5 text-[9px] font-medium rounded bg-amber-50 text-amber-600 border border-amber-200">{{ t('settings.resource.restart_badge') }}</span>
</div>
<p class="text-xs text-neutral-400 mt-0.5">{{ t('settings.resource.prefill_memory_guard_description') }}</p>
<p class="text-xs text-neutral-500 mt-1"
x-show="globalSettings.memory.prefill_memory_guard"
x-text="memoryGuardTierDescription"></p>
<template x-if="globalSettings.memory.prefill_memory_guard && memoryGuardShowWiredLimitWarning">
<div class="mt-1">
<p class="text-xs font-bold text-red-600"
x-html="DOMPurify.sanitize(memoryGuardWiredLimitWarningHTML)"></p>
<div class="flex items-center justify-between gap-2 mt-1 px-3 py-2 bg-code rounded-md">
<code class="text-xs font-mono text-fg break-all" x-text="memoryGuardWiredLimitCommand"></code>
<button type="button"
@click="copyWiredLimitCommand()"
:class="wiredLimitCopied ? 'text-green-500' : 'text-fg-tertiary hover:text-fg'"
class="transition-colors flex-shrink-0"
:title="wiredLimitCopied ? 'Copied' : 'Copy'">
<i data-lucide="copy" class="w-3.5 h-3.5"></i>
</button>
</div>
</div>
</template>
<p class="text-xs text-neutral-400 mt-1"
x-show="globalSettings.memory.prefill_memory_guard"
x-html="DOMPurify.sanitize(memoryGuardBreakdownHTML)"></p>
</div>
<div class="flex items-center gap-3">
<select x-model="globalSettings.memory.memory_guard_tier"
x-show="globalSettings.memory.prefill_memory_guard"
class="px-2.5 py-1.5 text-xs border border-neutral-200 rounded-lg focus:ring-2 focus:ring-neutral-900 focus:border-transparent bg-white">
<option value="safe">{{ t('settings.resource.guard_tier.safe') }}</option>
<option value="balanced">{{ t('settings.resource.guard_tier.balanced') }}</option>
<option value="aggressive">{{ t('settings.resource.guard_tier.aggressive') }}</option>
<option value="custom">{{ t('settings.resource.guard_tier.custom') }}</option>
</select>
<div x-show="globalSettings.memory.prefill_memory_guard && globalSettings.memory.memory_guard_tier === 'custom'"
class="flex items-center gap-1">
<input type="number"
x-model.number="globalSettings.memory.memory_guard_custom_ceiling_gb"
min="1"
step="1"
placeholder="{{ t('settings.resource.guard_tier.custom_input_placeholder') }}"
class="w-20 px-2 py-1 text-xs border border-neutral-200 rounded-lg focus:ring-2 focus:ring-neutral-900 focus:border-transparent bg-white">
<span class="text-xs text-neutral-500">GB</span>
</div>
<button type="button"
@click="globalSettings.memory.prefill_memory_guard = !globalSettings.memory.prefill_memory_guard"
:class="globalSettings.memory.prefill_memory_guard ? 'bg-black' : 'bg-neutral-200'"
class="relative w-11 h-6 rounded-full transition-colors duration-300 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-black">
<span :class="globalSettings.memory.prefill_memory_guard ? 'translate-x-5' : 'translate-x-0'"
class="block w-5 h-5 bg-white rounded-full shadow-sm transform transition-transform duration-300 absolute top-0.5 left-0.5"></span>
</button>
</div>
</div>
<!-- Memory Limit (In-Memory Hot Cache) -->
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 px-4 sm:px-6 py-4">
<div>
<label class="text-sm text-neutral-700">{{ t('settings.resource.hot_cache_limit') }}</label>
<p class="text-xs text-neutral-400 mt-0.5">
{{ t('settings.resource.hot_cache_description') }}
<br>
{{ t('settings.resource.hot_cache_zero_note') }} <strong>{{ t('settings.resource.hot_cache_disabled') }}</strong> {{ t('settings.resource.hot_cache_disabled_label') }}
</p>
</div>
<div class="flex flex-wrap items-center gap-2 sm:gap-3">
<input type="range" min="0" max="50" step="1"
x-model.number="hotCachePercent"
@input="updateHotCacheFromSlider()"
class="w-32 h-1.5 bg-neutral-200 rounded-full appearance-none cursor-pointer
[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3
[&::-webkit-slider-thumb]:bg-black [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:cursor-pointer
[&::-webkit-slider-thumb]:shadow-sm">
<span class="text-sm text-neutral-600 w-28 text-right inline-flex items-center justify-end gap-0">
<span x-text="hotCachePercent === 0 ? window.t('settings.resource.off') : hotCachePercent + '%'"></span>
<span class="text-neutral-400 ml-1">(</span><span x-show="!editingHotCache" @click="editingHotCache = true" class="cursor-pointer hover:text-neutral-900" x-text="hotCacheSizeGB"></span><input x-show="editingHotCache"
type="number"
:value="hotCacheSizeGB"
@change="updateHotCacheFromInput($event.target.value); editingHotCache = false"
@blur="editingHotCache = false"
@keydown.enter="updateHotCacheFromInput($event.target.value); editingHotCache = false"
@keydown.escape="editingHotCache = false"
x-init="if (editingHotCache) { $nextTick(() => $el.focus()) }"
class="w-12 px-1 text-sm text-right border border-neutral-900 rounded focus:outline-none"
min="0"
step="1"><span class="text-neutral-400">GB)</span>
</span>
</div>
</div>
<!-- SSD Cache Limit (Cold Cache) -->
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 px-4 sm:px-6 py-4">
<div>
<label class="text-sm text-neutral-700">{{ t('settings.resource.cold_cache_limit') }}</label>
<p class="text-xs text-neutral-400 mt-0.5">
{{ t('settings.resource.cold_cache_description') }}
</p>
</div>
<div class="flex flex-wrap items-center gap-2 sm:gap-3">
<input type="range" min="0" max="100" step="1"
x-model.number="cachePercent"
@input="updateCacheFromSlider()"
class="w-32 h-1.5 bg-neutral-200 rounded-full appearance-none cursor-pointer
[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3
[&::-webkit-slider-thumb]:bg-black [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:cursor-pointer
[&::-webkit-slider-thumb]:shadow-sm">
<span class="text-sm text-neutral-600 w-28 text-right inline-flex items-center justify-end gap-0">
<span x-text="cachePercent + '%'"></span>
<span class="text-neutral-400 ml-1">(</span><span x-show="!editingCache" @click="editingCache = true" class="cursor-pointer hover:text-neutral-900" x-text="cacheSizeGB"></span><input x-show="editingCache"
type="number"
:value="cacheSizeGB"
@change="updateCacheFromInput($event.target.value); editingCache = false"
@blur="editingCache = false"
@keydown.enter="updateCacheFromInput($event.target.value); editingCache = false"
@keydown.escape="editingCache = false"
x-init="if (editingCache) { $nextTick(() => $el.focus()) }"
class="w-12 px-1 text-sm text-right border border-neutral-900 rounded focus:outline-none"
min="0"
step="1"><span class="text-neutral-400">GB)</span>
</span>
</div>
</div>
<!-- Max Concurrent Requests -->
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 px-4 sm:px-6 py-4">
<div>
<div class="flex items-center gap-2">
<label class="text-sm text-neutral-700">{{ t('settings.resource.max_concurrent_requests') }}</label>
<span class="px-1.5 py-0.5 text-[9px] font-medium rounded-full bg-amber-50 text-amber-600 border border-amber-200">
{{ t('settings.resource.restart_badge') }}
</span>
</div>
<p class="text-xs text-neutral-400 mt-0.5">{{ t('settings.resource.max_concurrent_requests_hint') }}</p>
</div>
<input type="number" x-model.number="globalSettings.scheduler.max_concurrent_requests"
min="1"
class="w-full sm:w-48 px-3 py-2 text-sm text-right border border-neutral-200 rounded-lg focus:ring-2 focus:ring-neutral-900 focus:border-transparent transition-all">
</div>
<!-- Embedding Batch Size -->
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 px-4 sm:px-6 py-4">
<div>
<label class="text-sm text-neutral-700">{{ t('settings.resource.embedding_batch_size') }}</label>
<p class="text-xs text-neutral-400 mt-0.5">{{ t('settings.resource.embedding_batch_size_hint') }}</p>
</div>
<input type="number" x-model.number="globalSettings.scheduler.embedding_batch_size"
min="1"
class="w-full sm:w-48 px-3 py-2 text-sm text-right border border-neutral-200 rounded-lg focus:ring-2 focus:ring-neutral-900 focus:border-transparent transition-all">
</div>
<!-- Chunked Prefill -->
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 px-4 sm:px-6 py-4">
<div>
<label class="text-sm text-neutral-700">{{ t('settings.resource.chunked_prefill') }}</label>
<p class="text-xs text-neutral-400 mt-0.5">{{ t('settings.resource.chunked_prefill_description') }}</p>
</div>
<button type="button"
@click="globalSettings.scheduler.chunked_prefill = !globalSettings.scheduler.chunked_prefill"
:class="globalSettings.scheduler.chunked_prefill ? 'bg-black' : 'bg-neutral-200'"
class="relative w-11 h-6 rounded-full transition-colors duration-300 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-black">
<span :class="globalSettings.scheduler.chunked_prefill ? 'translate-x-5' : 'translate-x-0'"
class="block w-5 h-5 bg-white rounded-full shadow-sm transform transition-transform duration-300 absolute top-0.5 left-0.5"></span>
</button>
</div>
<!-- Idle Timeout -->
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 px-4 sm:px-6 py-4">
<div>
<label class="text-sm text-neutral-700">{{ t('settings.model.idle_timeout') }}</label>
<p class="text-xs text-neutral-400 mt-0.5">{{ t('settings.model.idle_timeout_desc') }}</p>
</div>
<select x-model="idleTimeoutValue"
@change="globalSettings.idle_timeout.idle_timeout_seconds = idleTimeoutValue === '' ? null : Number(idleTimeoutValue); saveGlobalSettings()"
class="px-3 py-2 text-sm border border-neutral-200 rounded-lg focus:ring-2 focus:ring-neutral-900 focus:border-transparent transition-all bg-white">
<option value="">{{ t('settings.model.idle_timeout_none') }}</option>
<option value="900">{{ t('settings.model.idle_timeout_15m') }}</option>
<option value="1800">{{ t('settings.model.idle_timeout_30m') }}</option>
<option value="3600">{{ t('settings.model.idle_timeout_1h') }}</option>
<option value="7200">{{ t('settings.model.idle_timeout_2h') }}</option>
<option value="28800">{{ t('settings.model.idle_timeout_8h') }}</option>
<option value="86400">{{ t('settings.model.idle_timeout_24h') }}</option>
</select>
</div>
</div>
</div>
<!-- Cache Section -->
<div class="bg-white rounded-2xl border border-neutral-200 overflow-hidden">
<div class="flex items-center justify-between px-6 py-4 bg-neutral-100 border-b border-neutral-200">
<div class="flex items-center gap-3">
<i data-lucide="hard-drive" class="w-4 h-4 text-neutral-500"></i>
<span class="text-xs font-bold uppercase tracking-wider text-neutral-600">{{ t('settings.cache.section_label') }}</span>
</div>
</div>
<div class="divide-y divide-neutral-100">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 px-4 sm:px-6 py-4">
<div class="flex items-center gap-2">
<label class="text-sm text-neutral-700">{{ t('settings.cache.enabled') }}</label>
</div>
<button type="button" @click="globalSettings.cache.enabled = !globalSettings.cache.enabled"
:class="globalSettings.cache.enabled ? 'bg-black' : 'bg-neutral-200'"
class="relative w-11 h-6 rounded-full transition-colors duration-300 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-black">
<span :class="globalSettings.cache.enabled ? 'translate-x-5' : 'translate-x-0'"
class="block w-5 h-5 bg-white rounded-full shadow-sm transform transition-transform duration-300 absolute top-0.5 left-0.5"></span>
</button>
</div>
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 px-4 sm:px-6 py-4">
<div>
<div class="flex items-center gap-2">
<label class="text-sm text-neutral-700">{{ t('settings.cache.hot_cache_only') }}</label>
<span class="px-1.5 py-0.5 text-[9px] font-medium rounded-full bg-amber-50 text-amber-600 border border-amber-200">
{{ t('settings.global.restart_badge') }}
</span>
</div>
<p class="text-xs text-neutral-400 mt-0.5">{{ t('settings.cache.hot_cache_only_hint') }}</p>
</div>
<button type="button" @click="globalSettings.cache.hot_cache_only = !globalSettings.cache.hot_cache_only"
:class="globalSettings.cache.hot_cache_only ? 'bg-black' : 'bg-neutral-200'"
class="relative w-11 h-6 rounded-full transition-colors duration-300 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-black">
<span :class="globalSettings.cache.hot_cache_only ? 'translate-x-5' : 'translate-x-0'"
class="block w-5 h-5 bg-white rounded-full shadow-sm transform transition-transform duration-300 absolute top-0.5 left-0.5"></span>
</button>
</div>
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 px-4 sm:px-6 py-4">
<div class="flex items-center gap-2">
<label class="text-sm text-neutral-700">{{ t('settings.cache.ssd_directory') }}</label>
</div>
<input type="text" x-model="globalSettings.cache.ssd_cache_dir"
class="w-full sm:w-64 px-3 py-2 text-sm text-right border border-neutral-200 rounded-lg focus:ring-2 focus:ring-neutral-900 focus:border-transparent transition-all font-mono text-xs">
</div>
</div>
</div>
<!-- Sampling Defaults Section -->
<div class="bg-white rounded-2xl border border-neutral-200 overflow-hidden">
<div class="flex items-center justify-between px-6 py-4 bg-neutral-100 border-b border-neutral-200">
<div class="flex items-center gap-3">
<i data-lucide="sliders" class="w-4 h-4 text-neutral-500"></i>
<span class="text-xs font-bold uppercase tracking-wider text-neutral-600">{{ t('settings.generation.section_label') }}</span>
</div>
</div>
<div class="divide-y divide-neutral-100">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 px-4 sm:px-6 py-4">
<div>
<label class="text-sm text-neutral-700">{{ t('settings.generation.max_context_window_policy') }}</label>
<p class="text-xs text-neutral-400 mt-0.5">{{ t('settings.generation.max_context_window_policy_hint') }}</p>
</div>
<input type="number" x-model.number="globalSettings.sampling.max_context_window_policy" min="1" max="2097152" placeholder="None"
class="w-full sm:w-48 px-3 py-2 text-sm text-right border border-neutral-200 rounded-lg focus:ring-2 focus:ring-neutral-900 focus:border-transparent transition-all">
</div>
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 px-4 sm:px-6 py-4">
<div>
<label class="text-sm text-neutral-700">{{ t('settings.generation.max_context_window') }}</label>
<p class="text-xs text-neutral-400 mt-0.5">{{ t('settings.generation.max_context_window_hint') }}</p>
</div>
<input type="number" x-model.number="globalSettings.sampling.max_context_window" min="1" max="2097152"
class="w-full sm:w-48 px-3 py-2 text-sm text-right border border-neutral-200 rounded-lg focus:ring-2 focus:ring-neutral-900 focus:border-transparent transition-all">
</div>
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 px-4 sm:px-6 py-4">
<label class="text-sm text-neutral-700">{{ t('settings.generation.max_tokens') }}</label>
<input type="number" x-model.number="globalSettings.sampling.max_tokens" min="1" max="131072"
class="w-full sm:w-48 px-3 py-2 text-sm text-right border border-neutral-200 rounded-lg focus:ring-2 focus:ring-neutral-900 focus:border-transparent transition-all">
</div>
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 px-4 sm:px-6 py-4">
<div>
<label class="text-sm text-neutral-700">{{ t('settings.generation.temperature') }}</label>
<p class="text-xs text-neutral-400 mt-0.5">{{ t('settings.generation.temperature_hint') }}</p>
</div>
<div class="flex items-center gap-3">
<input type="range" min="0" max="2" step="0.1"
x-model.number="globalSettings.sampling.temperature"
class="w-24 h-1.5 bg-neutral-200 rounded-full appearance-none cursor-pointer
[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3
[&::-webkit-slider-thumb]:bg-black [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:cursor-pointer">
<input type="number" x-model.number="globalSettings.sampling.temperature" step="0.1" min="0" max="2"
class="w-20 px-3 py-2 text-sm text-right border border-neutral-200 rounded-lg focus:ring-2 focus:ring-neutral-900 focus:border-transparent transition-all">
</div>
</div>
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 px-4 sm:px-6 py-4">
<div>
<label class="text-sm text-neutral-700">{{ t('settings.generation.top_p') }}</label>
<p class="text-xs text-neutral-400 mt-0.5">{{ t('settings.generation.top_p_hint') }}</p>
</div>
<div class="flex items-center gap-3">
<input type="range" min="0" max="1" step="0.05"
x-model.number="globalSettings.sampling.top_p"
class="w-24 h-1.5 bg-neutral-200 rounded-full appearance-none cursor-pointer
[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3
[&::-webkit-slider-thumb]:bg-black [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:cursor-pointer">
<input type="number" x-model.number="globalSettings.sampling.top_p" step="0.05" min="0" max="1"
class="w-20 px-3 py-2 text-sm text-right border border-neutral-200 rounded-lg focus:ring-2 focus:ring-neutral-900 focus:border-transparent transition-all">
</div>
</div>
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 px-4 sm:px-6 py-4">
<div>
<label class="text-sm text-neutral-700">{{ t('settings.generation.top_k') }}</label>
<p class="text-xs text-neutral-400 mt-0.5">{{ t('settings.generation.top_k_hint') }}</p>
</div>
<input type="number" x-model.number="globalSettings.sampling.top_k" min="0"
class="w-full sm:w-48 px-3 py-2 text-sm text-right border border-neutral-200 rounded-lg focus:ring-2 focus:ring-neutral-900 focus:border-transparent transition-all">
</div>
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 px-4 sm:px-6 py-4">
<div>
<label class="text-sm text-neutral-700">{{ t('settings.generation.repetition_penalty') }}</label>
<p class="text-xs text-neutral-400 mt-0.5">{{ t('settings.generation.repetition_penalty_hint') }}</p>
</div>
<input type="number" x-model.number="globalSettings.sampling.repetition_penalty" step="0.05" min="1"
class="w-full sm:w-48 px-3 py-2 text-sm text-right border border-neutral-200 rounded-lg focus:ring-2 focus:ring-neutral-900 focus:border-transparent transition-all">
</div>
</div>
</div>
<!-- MCP Section -->
<div class="bg-white rounded-2xl border border-neutral-200 overflow-hidden">
<div class="flex items-center justify-between px-6 py-4 bg-neutral-100 border-b border-neutral-200">
<div class="flex items-center gap-3">
<i data-lucide="plug" class="w-4 h-4 text-neutral-500"></i>
<span class="text-xs font-bold uppercase tracking-wider text-neutral-600">{{ t('settings.mcp.section_label') }}</span>
</div>
<span class="px-2 py-0.5 text-[10px] font-medium rounded-full bg-amber-50 text-amber-600 border border-amber-200">
{{ t('settings.mcp.restart_badge') }}
</span>
</div>
<div class="divide-y divide-neutral-100">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 px-4 sm:px-6 py-4">
<label class="text-sm text-neutral-700">{{ t('settings.mcp.config_path') }}</label>
<input type="text" x-model="globalSettings.mcp.config_path" placeholder="{{ t('settings.mcp.config_placeholder') }}"
class="w-full sm:w-64 px-3 py-2 text-sm text-right border border-neutral-200 rounded-lg focus:ring-2 focus:ring-neutral-900 focus:border-transparent transition-all font-mono text-xs">
</div>
</div>
</div>
<!-- Network Section -->
<div class="bg-white rounded-2xl border border-neutral-200 overflow-hidden">
<div class="flex items-center justify-between px-6 py-4 bg-neutral-100 border-b border-neutral-200">
<div class="flex items-center gap-3">
<i data-lucide="network" class="w-4 h-4 text-neutral-500"></i>
<span class="text-xs font-bold uppercase tracking-wider text-neutral-600">Network</span>
</div>
<span class="px-2 py-0.5 text-[10px] font-medium rounded-full bg-green-50 text-green-600 border border-green-200">
Live
</span>
</div>
<div class="divide-y divide-neutral-100">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 px-4 sm:px-6 py-4">
<div>
<label class="text-sm text-neutral-700">HTTP Proxy</label>
<p class="text-xs text-neutral-400 mt-0.5">Example: http://proxy.company.com:8080</p>
</div>
<input type="text" x-model="globalSettings.network.http_proxy"
class="w-full sm:w-64 px-3 py-2 text-sm text-right border border-neutral-200 rounded-lg focus:ring-2 focus:ring-neutral-900 focus:border-transparent transition-all font-mono text-xs">
</div>
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 px-4 sm:px-6 py-4">
<div>
<label class="text-sm text-neutral-700">HTTPS Proxy</label>
<p class="text-xs text-neutral-400 mt-0.5">Example: http://proxy.company.com:8080</p>
</div>
<input type="text" x-model="globalSettings.network.https_proxy"
class="w-full sm:w-64 px-3 py-2 text-sm text-right border border-neutral-200 rounded-lg focus:ring-2 focus:ring-neutral-900 focus:border-transparent transition-all font-mono text-xs">
</div>
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 px-4 sm:px-6 py-4">
<div>
<label class="text-sm text-neutral-700">No Proxy</label>
<p class="text-xs text-neutral-400 mt-0.5">Comma-separated hosts to bypass proxy</p>
</div>
<input type="text" x-model="globalSettings.network.no_proxy"
class="w-full sm:w-64 px-3 py-2 text-sm text-right border border-neutral-200 rounded-lg focus:ring-2 focus:ring-neutral-900 focus:border-transparent transition-all font-mono text-xs">
</div>
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 px-4 sm:px-6 py-4">
<div>
<label class="text-sm text-neutral-700">CA Bundle</label>
<p class="text-xs text-neutral-400 mt-0.5">Path to PEM file for corporate TLS interception</p>
</div>
<input type="text" x-model="globalSettings.network.ca_bundle"
class="w-full sm:w-64 px-3 py-2 text-sm text-right border border-neutral-200 rounded-lg focus:ring-2 focus:ring-neutral-900 focus:border-transparent transition-all font-mono text-xs">
</div>
</div>
</div>
<!-- Advanced Settings (collapsible, collapsed by default) -->
<div class="bg-white rounded-2xl border border-neutral-200 overflow-hidden"
x-data="{ advancedOpen: false }">
<button @click="advancedOpen = !advancedOpen"
class="w-full flex items-center justify-between px-6 py-4 bg-neutral-50 hover:bg-neutral-100 transition-colors cursor-pointer">
<div class="flex items-center gap-3">
<i data-lucide="settings-2" class="w-4 h-4 text-neutral-400"></i>
<span class="text-xs font-bold uppercase tracking-wider text-neutral-400">{{ t('settings.advanced.section_label') }}</span>
</div>
<div class="flex items-center gap-2">
<span class="px-2 py-0.5 text-[10px] font-medium rounded-full bg-amber-50 text-amber-600 border border-amber-200">
{{ t('settings.advanced.restart_badge') }}
</span>
<i data-lucide="chevron-down" class="w-4 h-4 text-neutral-400 transition-transform duration-200"
:class="advancedOpen && 'rotate-180'"></i>
</div>
</button>
<div x-show="advancedOpen" x-collapse x-cloak>
<div class="border-t border-neutral-200">
<!-- Performance subsection -->
<div class="px-6 py-3 bg-neutral-50">
<div class="flex items-center gap-2">
<i data-lucide="zap" class="w-3.5 h-3.5 text-neutral-400"></i>
<span class="text-[10px] font-bold uppercase tracking-wider text-neutral-400">{{ t('settings.advanced.performance') }}</span>
</div>
</div>
<div class="divide-y divide-neutral-100">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 px-4 sm:px-6 py-4">
<div class="flex-1">
<label class="text-sm text-neutral-700">{{ t('settings.advanced.burst_decode') }}</label>
<p class="text-xs text-neutral-400 mt-0.5">{{ t('settings.advanced.burst_decode_hint') }}</p>
</div>
<select x-model="globalSettings.server.burst_decode_mode"
class="w-full sm:w-64 px-3 py-2 text-sm border border-neutral-200 rounded-lg focus:ring-2 focus:ring-neutral-900 focus:border-transparent transition-all">
<option value="off">{{ t('settings.advanced.burst_decode_off') }}</option>
<option value="light">{{ t('settings.advanced.burst_decode_light') }}</option>
<option value="balanced">{{ t('settings.advanced.burst_decode_balanced') }}</option>
<option value="aggressive">{{ t('settings.advanced.burst_decode_aggressive') }}</option>
</select>
</div>
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 px-4 sm:px-6 py-4">
<div class="flex-1">
<label class="text-sm text-neutral-700">{{ t('settings.advanced.preserve_mid_system_cache') }}</label>
<p class="text-xs text-neutral-400 mt-0.5">{{ t('settings.advanced.preserve_mid_system_cache_hint') }}</p>
</div>
<button type="button"
@click="globalSettings.server.preserve_mid_system_cache = !globalSettings.server.preserve_mid_system_cache"
:class="globalSettings.server.preserve_mid_system_cache ? 'bg-black' : 'bg-neutral-200'"
class="relative w-11 h-6 rounded-full transition-colors duration-300 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-black">
<span :class="globalSettings.server.preserve_mid_system_cache ? 'translate-x-5' : 'translate-x-0'"
class="block w-5 h-5 bg-white rounded-full shadow-sm transform transition-transform duration-300 absolute top-0.5 left-0.5"></span>
</button>
</div>
</div>
<!-- Cache subsection -->
<div class="px-6 py-3 bg-neutral-50 border-t border-neutral-200">
<div class="flex items-center gap-2">
<i data-lucide="hard-drive" class="w-3.5 h-3.5 text-neutral-400"></i>
<span class="text-[10px] font-bold uppercase tracking-wider text-neutral-400">{{ t('settings.advanced.cache') }}</span>
</div>
</div>
<div class="divide-y divide-neutral-100">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 px-4 sm:px-6 py-4">
<div>
<label class="text-sm text-neutral-700">{{ t('settings.advanced.initial_cache_blocks') }}</label>
<p class="text-xs text-neutral-400 mt-0.5">{{ t('settings.advanced.initial_cache_blocks_hint') }}</p>
</div>
<input type="number" x-model.number="globalSettings.cache.initial_cache_blocks"
min="1"
class="w-full sm:w-48 px-3 py-2 text-sm text-right border border-neutral-200 rounded-lg focus:ring-2 focus:ring-neutral-900 focus:border-transparent transition-all">
</div>
</div>
<!-- Streaming subsection -->
<div class="px-6 py-3 bg-neutral-50 border-t border-neutral-200">
<div class="flex items-center gap-2">
<i data-lucide="radio" class="w-3.5 h-3.5 text-neutral-400"></i>
<span class="text-[10px] font-bold uppercase tracking-wider text-neutral-400">{{ t('settings.advanced.streaming') }}</span>
</div>
</div>
<div class="divide-y divide-neutral-100">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 px-4 sm:px-6 py-4">
<div class="flex-1">
<label class="text-sm text-neutral-700">{{ t('settings.advanced.sse_keepalive_mode') }}</label>
<p class="text-xs text-neutral-400 mt-0.5">{{ t('settings.advanced.sse_keepalive_mode_hint') }}</p>
</div>
<select x-model="globalSettings.server.sse_keepalive_mode"
class="w-full sm:w-64 px-3 py-2 text-sm border border-neutral-200 rounded-lg focus:ring-2 focus:ring-neutral-900 focus:border-transparent transition-all">
<option value="chunk">{{ t('settings.advanced.sse_keepalive_mode_chunk') }}</option>
<option value="comment">{{ t('settings.advanced.sse_keepalive_mode_comment') }}</option>
<option value="off">{{ t('settings.advanced.sse_keepalive_mode_off') }}</option>
</select>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Save / Restart Buttons -->
<div class="mt-10 flex flex-col-reverse sm:flex-row sm:items-center sm:justify-between gap-4">
<div class="flex flex-col gap-1 min-w-0 sm:flex-1">
<!-- Save success -->
<div x-show="saveSuccess" x-cloak
x-transition:enter="transition ease-out duration-200"
x-transition:leave="transition ease-in duration-150"
class="flex items-start gap-2 text-sm text-neutral-700 min-w-0"
:title="saveMessage">
<i data-lucide="check-circle" class="w-4 h-4 shrink-0 mt-0.5"></i>
<span class="line-clamp-2" x-text="saveMessage"></span>
</div>
<!-- Save error -->
<div x-show="saveError" x-cloak
x-transition:enter="transition ease-out duration-200"
x-transition:leave="transition ease-in duration-150"
class="flex items-start gap-2 text-sm text-red-600 min-w-0"
:title="saveError">
<i data-lucide="alert-circle" class="w-4 h-4 shrink-0 mt-0.5"></i>
<span class="line-clamp-2" x-text="saveError"></span>
</div>
<!-- Restart status (any non-empty message) -->
<div x-show="restartServer.message" x-cloak
class="flex items-start gap-2 text-sm min-w-0"
:class="restartServer.status === 'error' ? 'text-red-600'
: restartServer.status === 'unsupported' ? 'text-amber-600'
: 'text-neutral-700'"
:title="restartServer.message">
<i x-show="restartServer.status === 'restarting' || restartServer.status === 'waiting'"
data-lucide="loader-2" class="w-4 h-4 animate-spin shrink-0 mt-0.5"></i>
<i x-show="restartServer.status === 'error' || restartServer.status === 'unsupported'"
data-lucide="alert-circle" class="w-4 h-4 shrink-0 mt-0.5"></i>
<i x-show="restartServer.status === 'idle'"
data-lucide="check-circle" class="w-4 h-4 shrink-0 mt-0.5"></i>
<span class="line-clamp-2" x-text="restartServer.message"></span>
</div>
</div>
<div class="flex items-center gap-3 shrink-0">
<button type="button"
@click="restartServerStart()"
:disabled="restartServer.status === 'restarting' || restartServer.status === 'waiting'"
class="inline-flex items-center justify-center gap-2 px-6 py-3 bg-white text-neutral-700 text-sm font-semibold
border border-neutral-300 rounded-full hover:bg-neutral-50 focus:outline-none focus:ring-2
focus:ring-neutral-300 focus:ring-offset-2 transition-all duration-200
disabled:opacity-50 disabled:cursor-not-allowed">
<i x-show="restartServer.status === 'restarting' || restartServer.status === 'waiting'" x-cloak
data-lucide="loader-2" class="w-4 h-4 animate-spin shrink-0"></i>
<i x-show="restartServer.status !== 'restarting' && restartServer.status !== 'waiting'"
data-lucide="rotate-cw" class="w-4 h-4 shrink-0"></i>
<span x-text="restartServer.status === 'restarting'
? '{{ t('settings.server.restart_button_restarting') }}'
: (restartServer.status === 'waiting'
? '{{ t('settings.server.restart_button_waiting') }}'
: '{{ t('settings.server.restart_button') }}')"></span>
</button>
<button
@click="saveGlobalSettings"
:disabled="saving"
class="px-8 py-3 bg-neutral-900 text-white text-sm font-semibold
rounded-full hover:bg-neutral-800 focus:outline-none focus:ring-2
focus:ring-neutral-900 focus:ring-offset-2 transition-all duration-200
transform hover:scale-105 active:scale-95
disabled:opacity-50 disabled:cursor-not-allowed disabled:transform-none"
>
<span x-show="!saving">{{ t('settings.save.button') }}</span>
<span x-show="saving" class="flex items-center">
<svg class="animate-spin -ml-1 mr-2 h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
{{ t('settings.save.saving') }}
</span>
</button>
</div>
</div>
</div>
</div>
<!-- Integration Settings Tab -->
<div x-show="activeTab === 'integrations'" x-cloak>
<div class="animate-fade-in-up">
<div class="mb-8">
<h2 class="text-xs font-bold uppercase tracking-widest text-neutral-400 mb-2">{{ t('settings.integrations.section_label') }}</h2>
<h3 class="text-2xl font-bold tracking-tight text-neutral-900">{{ t('settings.integrations.heading') }}</h3>
<p class="text-sm text-neutral-500 mt-2">{{ t('settings.integrations.description') }}</p>
</div>
<div class="space-y-4">
<div class="bg-white rounded-2xl border border-neutral-200 overflow-hidden">
<div class="flex items-center justify-between px-6 py-4 bg-neutral-100 border-b border-neutral-200">
<div class="flex items-center gap-3">
<i data-lucide="file-text" class="w-4 h-4 text-neutral-500"></i>
<span class="text-xs font-bold uppercase tracking-wider text-neutral-600">{{ t('settings.integrations.markitdown.section_label') }}</span>
</div>
<span class="px-2 py-0.5 text-[10px] font-medium rounded bg-neutral-200 text-neutral-600 border border-neutral-300">PDF · DOCX · PPTX</span>
</div>
<div class="divide-y divide-neutral-100">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 px-4 sm:px-6 py-4">
<div>
<label class="text-sm text-neutral-700">{{ t('settings.integrations.markitdown.enabled') }}</label>
<p class="text-xs text-neutral-400 mt-0.5">{{ t('settings.integrations.markitdown.enabled_hint') }}</p>
</div>
<button type="button"
@click="globalSettings.integrations.markitdown_enabled = !globalSettings.integrations.markitdown_enabled; saveIntegrationSettings().then(() => loadModels())"
:class="globalSettings.integrations.markitdown_enabled ? 'bg-black' : 'bg-neutral-200'"
class="relative w-11 h-6 rounded-full transition-colors duration-300 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-black">
<span :class="globalSettings.integrations.markitdown_enabled ? 'translate-x-5' : 'translate-x-0'"
class="block w-5 h-5 bg-white rounded-full shadow-sm transform transition-transform duration-300 absolute top-0.5 left-0.5"></span>
</button>
</div>
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 px-4 sm:px-6 py-4">
<div>
<label class="text-sm text-neutral-700">{{ t('settings.integrations.markitdown.expose_model') }}</label>
<p class="text-xs text-neutral-400 mt-0.5">{{ t('settings.integrations.markitdown.expose_model_hint') }}</p>
</div>
<button type="button"
@click="globalSettings.integrations.markitdown_expose_model = !globalSettings.integrations.markitdown_expose_model; saveIntegrationSettings().then(() => loadModels())"
:disabled="!globalSettings.integrations.markitdown_enabled"
:class="[
globalSettings.integrations.markitdown_expose_model && globalSettings.integrations.markitdown_enabled ? 'bg-black' : 'bg-neutral-200',
!globalSettings.integrations.markitdown_enabled ? 'opacity-40 cursor-not-allowed' : ''
]"
class="relative w-11 h-6 rounded-full transition-colors duration-300 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-black">
<span :class="globalSettings.integrations.markitdown_expose_model && globalSettings.integrations.markitdown_enabled ? 'translate-x-5' : 'translate-x-0'"
class="block w-5 h-5 bg-white rounded-full shadow-sm transform transition-transform duration-300 absolute top-0.5 left-0.5"></span>
</button>
</div>
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 px-4 sm:px-6 py-4">
<div>
<label class="text-sm text-neutral-700">{{ t('settings.integrations.markitdown.max_file_size') }}</label>
<p class="text-xs text-neutral-400 mt-0.5">{{ t('settings.integrations.markitdown.max_file_size_hint') }}</p>
</div>
<div class="flex items-center gap-2">
<input type="number"
min="1"
x-model.number="globalSettings.integrations.markitdown_max_file_size_mb"
@change="saveIntegrationSettings()"
class="w-24 px-3 py-2 text-sm text-right border border-neutral-200 rounded-lg focus:ring-2 focus:ring-neutral-900 focus:border-transparent transition-all">
<span class="text-sm text-neutral-400">MB</span>
</div>
</div>
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 px-4 sm:px-6 py-4">
<div>
<label class="text-sm text-neutral-700">{{ t('settings.integrations.markitdown.max_files') }}</label>
<p class="text-xs text-neutral-400 mt-0.5">{{ t('settings.integrations.markitdown.max_files_hint') }}</p>
</div>
<input type="number"
min="1"
x-model.number="globalSettings.integrations.markitdown_max_files_per_request"
@change="saveIntegrationSettings()"
class="w-24 px-3 py-2 text-sm text-right border border-neutral-200 rounded-lg focus:ring-2 focus:ring-neutral-900 focus:border-transparent transition-all">
</div>
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 px-4 sm:px-6 py-4">
<div>
<label class="text-sm text-neutral-700">{{ t('settings.integrations.markitdown.pdf_processing_engine') }}</label>
<p class="text-xs text-neutral-400 mt-0.5">{{ t('settings.integrations.markitdown.pdf_processing_engine_hint') }}</p>
<p x-show="globalSettings.integrations.markitdown_pdf_processing_engine === 'markitdown'"
class="text-xs text-amber-600 mt-1">
{{ t('settings.integrations.markitdown.pdf_processing_markitdown_warning') }}
</p>
</div>
<select x-model="globalSettings.integrations.markitdown_pdf_processing_engine"
@change="saveIntegrationSettings()"
class="w-full sm:w-64 px-3 py-2 text-sm border border-neutral-200 rounded-lg focus:ring-2 focus:ring-neutral-900 focus:border-transparent transition-all bg-white">
<option value="markitdown">MarkItDown</option>
<template x-for="model in markitdownOcrModels" :key="model.id">
<option :value="model.id" x-text="model.id"></option>
</template>
</select>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Model Settings Tab -->
<div x-show="activeTab === 'models'" x-cloak>
<div class="animate-fade-in-up">
<!-- Header -->
<div class="mb-8">
<h2 class="text-xs font-bold uppercase tracking-widest text-neutral-400 mb-2">{{ t('settings.models.section_label') }}</h2>
<div class="flex items-center gap-3">
<h3 class="text-2xl font-bold tracking-tight text-neutral-900">{{ t('settings.models.heading') }}</h3>
<button @click="reloadModels()"
:disabled="reloading"
class="px-3 py-1.5 text-xs font-medium text-neutral-500 hover:text-neutral-700
hover:bg-neutral-100 rounded-lg transition-all flex items-center gap-1.5
disabled:opacity-50 disabled:cursor-not-allowed">
<span class="inline-flex" :class="reloading && 'animate-spin'"><i data-lucide="rotate-cw" class="w-3.5 h-3.5"></i></span>
<span x-text="reloading ? window.t('models.manager.reloading') : window.t('models.manager.reload')"></span>
</button>
</div>
<p class="text-sm text-neutral-500 mt-2">{{ t('settings.models.description') }}</p>
</div>
<!-- Search + reset sort toolbar -->
<div x-show="!loadingModels && models.length > 0" class="flex flex-col sm:flex-row sm:items-center gap-3 mb-4">
<div class="relative flex-1">
<i data-lucide="search" class="w-4 h-4 text-neutral-400 absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none"></i>
<input type="text" x-model="modelSearch"
:placeholder="window.t('settings.models.search_placeholder')"
class="w-full pl-9 pr-9 py-2 text-sm border border-neutral-200 rounded-lg focus:ring-2 focus:ring-neutral-900 focus:border-transparent transition-all bg-white">
<button x-show="modelSearch" @click="modelSearch = ''"
class="absolute right-2.5 top-1/2 -translate-y-1/2 p-0.5 rounded text-neutral-400 hover:text-neutral-700 hover:bg-neutral-100 transition-all">
<i data-lucide="x" class="w-3.5 h-3.5"></i>
</button>
</div>
<button @click="resetSort()" :disabled="isModelsSortDefault"
class="px-3 py-2 text-xs font-medium text-neutral-500 hover:text-neutral-700 hover:bg-neutral-100 rounded-lg transition-all flex items-center gap-1.5 disabled:opacity-40 disabled:cursor-not-allowed whitespace-nowrap">
<i data-lucide="arrow-up-down" class="w-3.5 h-3.5"></i>
<span x-text="window.t('settings.models.reset_sort')"></span>
</button>
</div>
<!-- Loading State -->
<div x-show="loadingModels" class="p-12 text-center">
<svg class="animate-spin h-8 w-8 text-neutral-400 mx-auto" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<p class="mt-3 text-sm text-neutral-500">{{ t('settings.models.loading') }}</p>
</div>
<!-- Empty State -->
<div x-show="!loadingModels && models.length === 0" class="bg-white rounded-2xl border border-neutral-200 p-12 text-center">
<div class="w-16 h-16 bg-neutral-100 rounded-2xl flex items-center justify-center mx-auto mb-4">
<i data-lucide="package-x" class="w-8 h-8 text-neutral-400"></i>
</div>
<h4 class="text-lg font-semibold text-neutral-900 mb-2">{{ t('settings.models.no_models_heading') }}</h4>
<p class="text-sm text-neutral-500">{{ t('settings.models.no_models_description') }}</p>
</div>
<!-- Model Table -->
<div x-show="!loadingModels && models.length > 0" class="bg-white rounded-2xl border border-neutral-200 overflow-hidden">
<div class="overflow-x-auto">
<!-- Table Header -->
<div class="flex items-center gap-4 px-4 sm:px-6 py-3 bg-neutral-50 border-b border-neutral-200 text-xs font-bold uppercase tracking-wider text-neutral-500">
<div class="flex-1 min-w-0 flex items-center gap-1 cursor-pointer hover:text-neutral-700 select-none" @click="toggleSort('id')">
{{ t('settings.models.table.model') }}
<span x-show="sortBy === 'id'" x-text="sortOrder === 'asc' ? '▲' : '▼'" class="text-[10px]"></span>
</div>
<div class="hidden sm:flex w-20 text-center items-center justify-center gap-1 cursor-pointer hover:text-neutral-700 select-none" @click="toggleSort('type')">
{{ t('settings.models.table.type') }}
<span x-show="sortBy === 'type'" x-text="sortOrder === 'asc' ? '▲' : '▼'" class="text-[10px]"></span>
</div>
<div class="hidden sm:flex w-60 text-center items-center justify-center gap-1 cursor-pointer hover:text-neutral-700 select-none" @click="toggleSort('size')">
{{ t('settings.models.table.size') }}
<span x-show="sortBy === 'size'" x-text="sortOrder === 'asc' ? '▲' : '▼'" class="text-[10px]"></span>
</div>
<div class="w-20 text-center flex items-center justify-center gap-1 cursor-pointer hover:text-neutral-700 select-none" @click="toggleSort('loaded')">
{{ t('settings.models.table.status') }}
<span x-show="sortBy === 'loaded'" x-text="sortOrder === 'asc' ? '▲' : '▼'" class="text-[10px]"></span>
</div>
<div class="hidden sm:flex w-12 text-center items-center justify-center gap-1 cursor-pointer hover:text-neutral-700 select-none" @click="toggleSort('pinned')">
{{ t('settings.models.table.pin') }}
<span x-show="sortBy === 'pinned'" x-text="sortOrder === 'asc' ? '▲' : '▼'" class="text-[10px]"></span>
</div>
<div class="hidden sm:flex w-12 text-center items-center justify-center gap-1 cursor-pointer hover:text-neutral-700 select-none" @click="toggleSort('is_hidden')">
{{ t('settings.models.table.visible') }}
<span x-show="sortBy === 'is_hidden'" x-text="sortOrder === 'asc' ? '▲' : '▼'" class="text-[10px]"></span>
</div>
<div class="hidden sm:flex w-14 text-center items-center justify-center gap-1 cursor-pointer hover:text-neutral-700 select-none" @click="toggleSort('is_default')">
{{ t('settings.models.table.default') }}
<span x-show="sortBy === 'is_default'" x-text="sortOrder === 'asc' ? '▲' : '▼'" class="text-[10px]"></span>
</div>
<div class="w-10 text-center"></div>
</div>
<!-- Table Body -->
<div class="divide-y divide-neutral-100">
<div x-show="sortedModels.length === 0" class="px-6 py-10 text-center text-sm text-neutral-500"
x-text="window.t('settings.models.no_match')"></div>
<template x-for="model in sortedModels" :key="model.id">
<div class="hover:bg-neutral-50 transition-colors">
<!-- Main Row -->
<div class="flex items-center gap-4 px-6 py-4">
<!-- Model Name -->
<div class="flex-1 min-w-0 flex items-center gap-3">
<template x-if="!model.virtual">
<button
@click="updateModelSetting(model.id, 'is_favorite', !model.is_favorite)"
@mouseenter="showTip($event.currentTarget, model.is_favorite ? window.t('settings.models.table.favorite_on_tooltip') : window.t('settings.models.table.favorite_off_tooltip'))"
@mouseleave="hideTip()"
@focus="showTip($event.currentTarget, model.is_favorite ? window.t('settings.models.table.favorite_on_tooltip') : window.t('settings.models.table.favorite_off_tooltip'))"
@blur="hideTip()"
:class="model.is_favorite ? 'text-amber-400' : 'text-neutral-300 hover:text-neutral-600'"
class="hidden sm:flex w-8 h-8 items-center justify-center flex-shrink-0 rounded-lg hover:bg-neutral-100 transition-colors focus:outline-none">
<svg class="w-4 h-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" :fill="model.is_favorite ? 'currentColor' : 'none'" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>
</button>
</template>
<template x-if="model.virtual">
<div class="hidden sm:flex w-8 h-8 bg-neutral-100 rounded-lg items-center justify-center flex-shrink-0">
<i data-lucide="box" class="w-4 h-4 text-neutral-500"></i>
</div>
</template>
<p class="text-sm font-medium text-neutral-900 break-all sm:truncate"
:title="model.display_name || model.id"
x-text="model.display_name || model.id"></p>
<span x-show="model.settings?.model_alias" x-cloak
class="px-1.5 py-0.5 text-[10px] font-medium rounded bg-neutral-100 text-neutral-500 border border-neutral-200 truncate max-w-[120px]"
x-text="model.settings?.model_alias"></span>
<span x-show="model.is_helper" x-cloak
class="px-1.5 py-0.5 text-[10px] font-medium rounded bg-amber-50 text-amber-600 border border-amber-200"
@mouseenter="showTip($event.currentTarget, window.t('settings.models.table.helper_tooltip'))"
@mouseleave="hideTip()"
x-text="window.t('settings.models.table.helper_badge')"></span>
<span x-show="model.is_hidden" x-cloak
class="px-1.5 py-0.5 text-[10px] font-medium rounded bg-neutral-100 text-neutral-500 border border-neutral-200"
@mouseenter="showTip($event.currentTarget, window.t('settings.models.table.hidden_tooltip'))"
@mouseleave="hideTip()"
x-text="window.t('settings.models.table.hidden_badge')"></span>
<button x-data="{ copied: false }"
@click.stop="copyToClipboard(model.id); copied = true; setTimeout(() => copied = false, 2000)"
class="p-1 rounded-md transition-all flex-shrink-0"
:class="copied ? 'text-green-500' : 'text-neutral-300 hover:text-neutral-600 hover:bg-neutral-100'"
:title="window.t('settings.models.table.copy_model_name_tooltip')"
<svg x-show="!copied" class="w-3.5 h-3.5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>
<svg x-show="copied" x-cloak class="w-3.5 h-3.5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>
</button>
</div>
<!-- Type -->
<div class="hidden sm:block w-20 text-center flex-shrink-0">
<span class="px-2 py-0.5 text-xs font-medium rounded-full bg-neutral-100 text-neutral-600"
x-text="model.model_type || 'llm'"></span>
</div>
<!-- Size -->
<div class="hidden sm:block w-60 text-center flex-shrink-0">
<span class="text-sm text-neutral-600" x-text="modelSizeLabel(model)"></span>
</div>
<!-- Status -->
<div class="w-20 text-center flex-shrink-0"
x-data="{ hover: false }"
@mouseenter="hover = true" @mouseleave="hover = false">
<!-- Loading state -->
<template x-if="model.is_loading">
<span class="inline-flex items-center px-2 py-0.5 text-xs font-medium rounded-full gap-1 bg-amber-100 text-amber-700">
<svg class="w-3 h-3 animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
<span x-text="window.t('settings.models.table.status_loading')"></span>
</span>
</template>
<!-- Loaded state -->
<template x-if="!model.virtual && !model.is_loading && model.loaded">
<button @click="unloadModel(model.id)"
:class="hover
? 'bg-red-100 text-red-700 cursor-pointer'
: 'bg-neutral-900 text-white'"
class="inline-flex items-center px-2 py-0.5 text-xs font-medium rounded-full gap-1 transition-all">
<span :class="hover ? 'bg-red-500' : 'bg-white'" class="w-1.5 h-1.5 rounded-full"></span>
<span x-text="hover ? window.t('settings.models.table.status_unload') : window.t('settings.models.table.status_loaded')"></span>
</button>
</template>
<template x-if="model.virtual && !model.is_loading && model.loaded">
<span class="inline-flex items-center px-2 py-0.5 text-xs font-medium rounded-full gap-1 bg-neutral-900 text-white">
<span class="w-1.5 h-1.5 rounded-full bg-white"></span>
<span x-text="window.t('settings.models.table.status_loaded')"></span>
</span>
</template>
<!-- Ready state -->
<template x-if="!model.virtual && !model.is_loading && !model.loaded">
<button @click="loadModel(model.id)"
:class="hover
? 'bg-green-100 text-green-700 border-green-200 cursor-pointer'
: 'bg-white text-neutral-500 border-neutral-200'"
class="inline-flex items-center px-2 py-0.5 text-xs font-medium rounded-full gap-1 border transition-all">
<span :class="hover ? 'bg-green-500' : 'bg-neutral-400'" class="w-1.5 h-1.5 rounded-full"></span>
<span x-text="hover ? window.t('settings.models.table.status_load') : window.t('settings.models.table.status_ready')"></span>
</button>
</template>
</div>
<!-- Pin Toggle -->
<div class="hidden sm:flex w-12 justify-center flex-shrink-0">
<template x-if="!model.virtual">
<button
@click="updateModelSetting(model.id, 'is_pinned', !model.pinned)"
@mouseenter="showTip($event.currentTarget, model.pinned ? window.t('settings.models.table.pin_on_tooltip') : window.t('settings.models.table.pin_off_tooltip'))"
@mouseleave="hideTip()"
@focus="showTip($event.currentTarget, model.pinned ? window.t('settings.models.table.pin_on_tooltip') : window.t('settings.models.table.pin_off_tooltip'))"
@blur="hideTip()"
:class="model.pinned ? 'bg-black' : 'bg-neutral-200'"
class="relative w-9 h-5 rounded-full transition-colors duration-300 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-black"
>
<span
:class="model.pinned ? 'translate-x-4' : 'translate-x-0'"
class="block w-4 h-4 bg-white rounded-full shadow-sm transform transition-transform duration-300 absolute top-0.5 left-0.5"
></span>
</button>
</template>
<template x-if="model.virtual">
<span class="text-xs text-neutral-300"></span>
</template>
</div>
<!-- Visibility Toggle (on = visible in /v1/models) -->
<div class="hidden sm:flex w-12 justify-center flex-shrink-0">
<template x-if="!model.virtual">
<button
@click="updateModelSetting(model.id, 'is_hidden', !model.is_hidden)"
@mouseenter="showTip($event.currentTarget, !model.is_hidden ? window.t('settings.models.table.visible_on_tooltip') : window.t('settings.models.table.visible_off_tooltip'))"
@mouseleave="hideTip()"
@focus="showTip($event.currentTarget, !model.is_hidden ? window.t('settings.models.table.visible_on_tooltip') : window.t('settings.models.table.visible_off_tooltip'))"
@blur="hideTip()"
:class="!model.is_hidden ? 'bg-black' : 'bg-neutral-200'"
class="relative w-9 h-5 rounded-full transition-colors duration-300 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-black"
>
<span
:class="!model.is_hidden ? 'translate-x-4' : 'translate-x-0'"
class="block w-4 h-4 bg-white rounded-full shadow-sm transform transition-transform duration-300 absolute top-0.5 left-0.5"
></span>
</button>
</template>
<template x-if="model.virtual">
<span class="text-xs text-neutral-300"></span>
</template>
</div>
<!-- Default Radio (only for LLM models) -->
<div class="hidden sm:flex w-14 justify-center flex-shrink-0">
<template x-if="!model.virtual && (model.model_type === 'llm' || model.model_type === 'vlm' || !model.model_type)">
<button
@click="updateModelSetting(model.id, 'is_default', true)"
@mouseenter="showTip($event.currentTarget, model.is_default ? window.t('settings.models.table.default_on_tooltip') : window.t('settings.models.table.default_off_tooltip'))"
@mouseleave="hideTip()"
@focus="showTip($event.currentTarget, model.is_default ? window.t('settings.models.table.default_on_tooltip') : window.t('settings.models.table.default_off_tooltip'))"
@blur="hideTip()"
:class="model.is_default
? 'bg-black border-black'
: 'bg-white border-neutral-300 hover:border-neutral-400'"
class="w-4 h-4 rounded-full border-2 flex items-center justify-center transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-black"
>
<span x-show="model.is_default" class="w-1.5 h-1.5 bg-white rounded-full"></span>
</button>
</template>
<template x-if="model.virtual || (model.model_type && model.model_type !== 'llm' && model.model_type !== 'vlm')">
<span class="text-xs text-neutral-300"></span>
</template>
</div>
<!-- Actions (icon only) -->
<div class="w-10 flex justify-center flex-shrink-0">
<template x-if="!model.virtual">
<button
@click="openModelSettings(model)"
class="p-2 text-neutral-400 hover:text-neutral-900 hover:bg-neutral-100 rounded-lg transition-all"
:title="window.t('settings.models.table.sampling_settings_tooltip')"
>
<i data-lucide="settings" class="w-4 h-4"></i>
</button>
</template>
<template x-if="model.virtual">
<span class="text-xs text-neutral-300"></span>
</template>
</div>
</div>
<!-- Profile / preset / per-field chips -->
<div x-show="(!model.model_type || model.model_type === 'llm' || model.model_type === 'vlm') && ((model.exposed_profiles && model.exposed_profiles.length) || (model.settings && (model.settings.max_context_window || model.settings.max_tokens || model.settings.temperature !== null || model.settings.top_p !== null || model.settings.top_k !== null || model.settings.min_p !== null || model.settings.repetition_penalty !== null || model.settings.presence_penalty !== null || model.settings.force_sampling || model.settings.max_tool_result_tokens || model.settings.reasoning_parser || model.settings.chat_template_kwargs || model.settings.enable_thinking === true)))"
class="px-6 pb-4 -mt-2">
<div class="ml-11 flex flex-wrap gap-2">
<!-- 1. API-exposed profile pills -->
<template x-for="profile in (model.exposed_profiles || [])" :key="profile.name">
<span class="inline-flex items-center gap-1 px-2.5 py-0.5 text-xs font-medium rounded-full bg-emerald-50 text-emerald-700 border border-emerald-200"
@mouseenter="showTip($event.currentTarget, profileTooltip(profile))"
@mouseleave="hideTip()">
<span class="px-1 py-0.5 text-[9px] font-bold rounded bg-white/70"
:class="profile.has_engine_fields ? 'text-amber-700' : 'text-emerald-700'">API</span>
<span x-text="profile.api_name || profile.name"></span>
</span>
</template>
<!-- 2. Preset pill (cyan) if settings match a preset exactly -->
<template x-if="!model.settings?.active_profile_name && matchedPreset(model.settings)">
<span class="inline-flex items-center gap-1 px-2.5 py-0.5 text-xs font-medium rounded-full bg-cyan-50 text-cyan-700 border border-cyan-200">
<i data-lucide="settings" class="w-3 h-3"></i>
<span x-text="matchedPreset(model.settings).display_name || matchedPreset(model.settings).name"></span>
</span>
</template>
<!-- 3. Per-field chips (original colors, rounded-full shape) -->
<template x-if="model.settings?.max_context_window">
<span class="inline-flex items-center px-2.5 py-0.5 text-xs font-medium rounded-full bg-amber-50 text-amber-700 border border-amber-200">
ctx_window: <span class="ml-1" x-text="model.settings.max_context_window"></span>
</span>
</template>
<template x-if="model.settings?.max_tokens">
<span class="inline-flex items-center px-2.5 py-0.5 text-xs font-medium rounded-full bg-blue-50 text-blue-700 border border-blue-200">
max_tokens: <span class="ml-1" x-text="model.settings.max_tokens"></span>
</span>
</template>
<template x-if="model.settings?.temperature !== null && model.settings?.temperature !== undefined">
<span class="inline-flex items-center px-2.5 py-0.5 text-xs font-medium rounded-full bg-orange-50 text-orange-700 border border-orange-200">
temp: <span class="ml-1" x-text="model.settings.temperature"></span>
</span>
</template>
<template x-if="model.settings?.top_p !== null && model.settings?.top_p !== undefined">
<span class="inline-flex items-center px-2.5 py-0.5 text-xs font-medium rounded-full bg-green-50 text-green-700 border border-green-200">
top_p: <span class="ml-1" x-text="model.settings.top_p"></span>
</span>
</template>
<template x-if="model.settings?.top_k !== null && model.settings?.top_k !== undefined">
<span class="inline-flex items-center px-2.5 py-0.5 text-xs font-medium rounded-full bg-purple-50 text-purple-700 border border-purple-200">
top_k: <span class="ml-1" x-text="model.settings.top_k"></span>
</span>
</template>
<template x-if="model.settings?.min_p !== null && model.settings?.min_p !== undefined">
<span class="inline-flex items-center px-2.5 py-0.5 text-xs font-medium rounded-full bg-indigo-50 text-indigo-700 border border-indigo-200">
min_p: <span class="ml-1" x-text="model.settings.min_p"></span>
</span>
</template>
<template x-if="model.settings?.repetition_penalty !== null && model.settings?.repetition_penalty !== undefined">
<span class="inline-flex items-center px-2.5 py-0.5 text-xs font-medium rounded-full bg-pink-50 text-pink-700 border border-pink-200">
rep_penalty: <span class="ml-1" x-text="model.settings.repetition_penalty"></span>
</span>
</template>
<template x-if="model.settings?.presence_penalty !== null && model.settings?.presence_penalty !== undefined">
<span class="inline-flex items-center px-2.5 py-0.5 text-xs font-medium rounded-full bg-rose-50 text-rose-700 border border-rose-200">
presence_penalty: <span class="ml-1" x-text="model.settings.presence_penalty"></span>
</span>
</template>
<template x-if="model.settings?.max_tool_result_tokens">
<span class="inline-flex items-center px-2.5 py-0.5 text-xs font-medium rounded-full bg-teal-50 text-teal-700 border border-teal-200">
tool_result_tokens: <span class="ml-1" x-text="model.settings.max_tool_result_tokens"></span>
</span>
</template>
<template x-if="model.settings?.reasoning_parser">
<span class="inline-flex items-center px-2.5 py-0.5 text-xs font-medium rounded-full bg-violet-50 text-violet-700 border border-violet-200">
reasoning_parser: <span class="ml-1" x-text="model.settings.reasoning_parser"></span>
</span>
</template>
<template x-if="model.settings?.enable_thinking === true">
<span class="inline-flex items-center px-2.5 py-0.5 text-xs font-medium rounded-full bg-sky-50 text-sky-700 border border-sky-200">
thinking
</span>
</template>
<template x-if="model.settings?.force_sampling">
<span class="inline-flex items-center px-2.5 py-0.5 text-xs font-medium rounded-full bg-red-50 text-red-700 border border-red-200">
force_sampling
</span>
</template>
<template x-if="model.settings?.chat_template_kwargs">
<template x-for="[k, v] in Object.entries(model.settings.chat_template_kwargs)" :key="k">
<span class="inline-flex items-center px-2.5 py-0.5 text-xs font-medium rounded-full bg-indigo-50 text-indigo-700 border border-indigo-200">
<span x-text="k"></span>: <span class="ml-1" x-text="v"></span>
</span>
</template>
</template>
</div>
</div>
</div>
</template>
</div>
</div>
</div>
</div>
</div>
</div>