AI-admin: modellkatalog fra OpenRouter med leverandør-akkordion
- Nytt endepunkt /api/admin/ai/models som proxyer OpenRouter med 1t cache - Nytt endepunkt /api/admin/ai/keys for API-nøkkelstatus - API-nøkkel-pills (GEMINI/OPENROUTER/ANTHROPIC/XAI) øverst på siden - Browsbar modellkatalog gruppert per leverandør i trekkspill-format - Globalt søkefelt, sortert synkende etter pris per leverandør - "Legg til →" fra katalog velger alias og oppretter provider - Katalog-picker i eksisterende add-provider-form Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
aafb121bf2
commit
b1a7e55fff
3 changed files with 627 additions and 22 deletions
16
web/src/routes/api/admin/ai/keys/+server.ts
Normal file
16
web/src/routes/api/admin/ai/keys/+server.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { env } from '$env/dynamic/private';
|
||||
|
||||
const KEY_NAMES = ['GEMINI_API_KEY', 'OPENROUTER_API_KEY', 'ANTHROPIC_API_KEY', 'XAI_API_KEY'];
|
||||
|
||||
export const GET: RequestHandler = async ({ locals }) => {
|
||||
if (!locals.workspace || !locals.user) error(401);
|
||||
|
||||
const keys = KEY_NAMES.map((name) => ({
|
||||
name,
|
||||
configured: !!env[name]
|
||||
}));
|
||||
|
||||
return json({ keys });
|
||||
};
|
||||
68
web/src/routes/api/admin/ai/models/+server.ts
Normal file
68
web/src/routes/api/admin/ai/models/+server.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { env } from '$env/dynamic/private';
|
||||
|
||||
interface OpenRouterModel {
|
||||
id: string;
|
||||
name: string;
|
||||
context_length: number;
|
||||
pricing: { prompt: string; completion: string };
|
||||
top_provider?: { max_completion_tokens?: number };
|
||||
architecture?: { modality?: string };
|
||||
}
|
||||
|
||||
export interface CatalogModel {
|
||||
id: string;
|
||||
name: string;
|
||||
provider: string;
|
||||
context_length: number;
|
||||
prompt_price_per_m: number;
|
||||
completion_price_per_m: number;
|
||||
modality: string;
|
||||
max_completion: number | null;
|
||||
}
|
||||
|
||||
let cache: { models: CatalogModel[]; fetched_at: number } | null = null;
|
||||
const CACHE_TTL = 60 * 60 * 1000; // 1 time
|
||||
|
||||
function toPerMillion(pricePerToken: string): number {
|
||||
const n = parseFloat(pricePerToken);
|
||||
if (isNaN(n)) return 0;
|
||||
return Math.round(n * 1_000_000 * 100) / 100;
|
||||
}
|
||||
|
||||
export const GET: RequestHandler = async ({ locals }) => {
|
||||
if (!locals.workspace || !locals.user) error(401);
|
||||
|
||||
const apiKey = env.OPENROUTER_API_KEY;
|
||||
if (!apiKey) {
|
||||
error(500, 'OPENROUTER_API_KEY er ikke konfigurert');
|
||||
}
|
||||
|
||||
if (cache && Date.now() - cache.fetched_at < CACHE_TTL) {
|
||||
return json(cache.models);
|
||||
}
|
||||
|
||||
const res = await fetch('https://openrouter.ai/api/v1/models', {
|
||||
headers: { Authorization: `Bearer ${apiKey}` }
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
error(502, `OpenRouter returnerte ${res.status}`);
|
||||
}
|
||||
|
||||
const body = await res.json();
|
||||
const models: CatalogModel[] = (body.data as OpenRouterModel[]).map((m) => ({
|
||||
id: m.id,
|
||||
name: m.name,
|
||||
provider: m.id.split('/')[0],
|
||||
context_length: m.context_length,
|
||||
prompt_price_per_m: toPerMillion(m.pricing?.prompt ?? '0'),
|
||||
completion_price_per_m: toPerMillion(m.pricing?.completion ?? '0'),
|
||||
modality: m.architecture?.modality ?? 'text',
|
||||
max_completion: m.top_provider?.max_completion_tokens ?? null
|
||||
}));
|
||||
|
||||
cache = { models, fetched_at: Date.now() };
|
||||
return json(models);
|
||||
};
|
||||
|
|
@ -41,6 +41,22 @@
|
|||
total_tokens: number;
|
||||
}
|
||||
|
||||
interface CatalogModel {
|
||||
id: string;
|
||||
name: string;
|
||||
provider: string;
|
||||
context_length: number;
|
||||
prompt_price_per_m: number;
|
||||
completion_price_per_m: number;
|
||||
modality: string;
|
||||
max_completion: number | null;
|
||||
}
|
||||
|
||||
interface ApiKey {
|
||||
name: string;
|
||||
configured: boolean;
|
||||
}
|
||||
|
||||
let aliases = $state<Alias[]>(data.aliases as Alias[]);
|
||||
let providers = $state<Provider[]>(data.providers as Provider[]);
|
||||
let routing = $state<Route[]>(data.routing as Route[]);
|
||||
|
|
@ -55,6 +71,23 @@
|
|||
let editPromptText = $state('');
|
||||
let expandedAlias = $state<string | null>(null);
|
||||
|
||||
// API-nøkler
|
||||
let apiKeys = $state<ApiKey[]>([]);
|
||||
let keysLoaded = $state(false);
|
||||
|
||||
// Modellkatalog
|
||||
let catalogModels = $state<CatalogModel[]>([]);
|
||||
let catalogLoading = $state(false);
|
||||
let catalogLoaded = $state(false);
|
||||
let catalogSearch = $state('');
|
||||
let expandedProviders = $state<Set<string>>(new Set());
|
||||
let addingFromCatalog = $state<string | null>(null);
|
||||
let catalogAddAlias = $state('');
|
||||
|
||||
// Katalog-picker i add-provider-form
|
||||
let showCatalogPicker = $state(false);
|
||||
let catalogPickerSearch = $state('');
|
||||
|
||||
// Ny provider-form
|
||||
let newProvider = $state<{ alias_id: string; litellm_model: string; api_key_env: string }>({
|
||||
alias_id: '',
|
||||
|
|
@ -79,6 +112,124 @@
|
|||
}, 2000);
|
||||
}
|
||||
|
||||
function formatCtx(n: number): string {
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(0)}M`;
|
||||
if (n >= 1000) return `${(n / 1000).toFixed(0)}K`;
|
||||
return String(n);
|
||||
}
|
||||
|
||||
function formatPrice(n: number): string {
|
||||
if (n === 0) return 'Gratis';
|
||||
return `$${n.toFixed(2)}`;
|
||||
}
|
||||
|
||||
// API-nøkler
|
||||
async function loadKeys() {
|
||||
try {
|
||||
const res = await fetch('/api/admin/ai/keys');
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
apiKeys = data.keys;
|
||||
keysLoaded = true;
|
||||
}
|
||||
} catch { /* stille */ }
|
||||
}
|
||||
loadKeys();
|
||||
|
||||
// Modellkatalog
|
||||
async function loadCatalog() {
|
||||
catalogLoading = true;
|
||||
errorMsg = '';
|
||||
try {
|
||||
const res = await fetch('/api/admin/ai/models');
|
||||
if (!res.ok) throw new Error('Kunne ikke hente modellkatalog');
|
||||
catalogModels = await res.json();
|
||||
catalogLoaded = true;
|
||||
} catch (e: any) {
|
||||
errorMsg = e.message;
|
||||
} finally {
|
||||
catalogLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
let groupedByProvider = $derived.by(() => {
|
||||
const search = catalogSearch.toLowerCase();
|
||||
const filtered = search
|
||||
? catalogModels.filter(
|
||||
(m) =>
|
||||
m.name.toLowerCase().includes(search) ||
|
||||
m.id.toLowerCase().includes(search) ||
|
||||
m.provider.toLowerCase().includes(search)
|
||||
)
|
||||
: catalogModels;
|
||||
|
||||
const map = new Map<string, CatalogModel[]>();
|
||||
for (const m of filtered) {
|
||||
const list = map.get(m.provider) ?? [];
|
||||
list.push(m);
|
||||
map.set(m.provider, list);
|
||||
}
|
||||
|
||||
// Sorter modeller synkende etter pris innen hver provider
|
||||
for (const [, models] of map) {
|
||||
models.sort((a, b) => b.completion_price_per_m - a.completion_price_per_m);
|
||||
}
|
||||
|
||||
return [...map.entries()].sort(([a], [b]) => a.localeCompare(b));
|
||||
});
|
||||
|
||||
let catalogPickerFiltered = $derived.by(() => {
|
||||
const search = catalogPickerSearch.toLowerCase();
|
||||
if (!search) return catalogModels.slice(0, 20);
|
||||
return catalogModels
|
||||
.filter(
|
||||
(m) =>
|
||||
m.name.toLowerCase().includes(search) ||
|
||||
m.id.toLowerCase().includes(search)
|
||||
)
|
||||
.slice(0, 20);
|
||||
});
|
||||
|
||||
function toggleCatalogProvider(provider: string) {
|
||||
if (expandedProviders.has(provider)) {
|
||||
expandedProviders.delete(provider);
|
||||
} else {
|
||||
expandedProviders.add(provider);
|
||||
}
|
||||
expandedProviders = new Set(expandedProviders);
|
||||
}
|
||||
|
||||
async function addFromCatalog(model: CatalogModel, aliasId: string) {
|
||||
errorMsg = '';
|
||||
const maxPri = Math.max(0, ...providersForAlias(aliasId).map((p) => p.priority));
|
||||
try {
|
||||
const res = await fetch('/api/admin/ai/providers', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
alias_id: aliasId,
|
||||
priority: maxPri + 1,
|
||||
litellm_model: `openrouter/${model.id}`,
|
||||
api_key_env: 'OPENROUTER_API_KEY'
|
||||
})
|
||||
});
|
||||
if (!res.ok) throw new Error('Feil ved opprettelse');
|
||||
const row = await res.json();
|
||||
providers = [...providers, row];
|
||||
addingFromCatalog = null;
|
||||
catalogAddAlias = '';
|
||||
} catch {
|
||||
errorMsg = 'Kunne ikke legge til provider fra katalog';
|
||||
}
|
||||
}
|
||||
|
||||
function selectFromPicker(model: CatalogModel) {
|
||||
newProvider.litellm_model = `openrouter/${model.id}`;
|
||||
newProvider.api_key_env = 'OPENROUTER_API_KEY';
|
||||
showCatalogPicker = false;
|
||||
catalogPickerSearch = '';
|
||||
}
|
||||
|
||||
async function toggleAlias(alias: Alias) {
|
||||
saving = alias.id;
|
||||
errorMsg = '';
|
||||
|
|
@ -266,11 +417,104 @@
|
|||
<span class="header-stats">{aliases.length} aliaser / {providers.length} leverandører / {totalTokens.toLocaleString('nb-NO')} tokens (30d)</span>
|
||||
</div>
|
||||
|
||||
<!-- API-nøkkel-pills -->
|
||||
{#if keysLoaded}
|
||||
<div class="key-pills">
|
||||
{#each apiKeys as key}
|
||||
<span class="key-pill" class:key-pill--ok={key.configured} class:key-pill--missing={!key.configured}>
|
||||
{key.name.replace('_API_KEY', '')}
|
||||
{key.configured ? '\u2713' : '\u2717'}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if errorMsg}
|
||||
<div class="error-msg">{errorMsg}</div>
|
||||
{/if}
|
||||
|
||||
<!-- Seksjon 1: Modellaliaser -->
|
||||
<!-- Seksjon 1: Modellkatalog -->
|
||||
<section>
|
||||
<div class="catalog-header">
|
||||
<h3>Modellkatalog (OpenRouter)</h3>
|
||||
<div class="catalog-actions">
|
||||
{#if catalogLoaded}
|
||||
<input
|
||||
type="text"
|
||||
class="catalog-search"
|
||||
placeholder="Søk modeller..."
|
||||
bind:value={catalogSearch}
|
||||
/>
|
||||
{/if}
|
||||
<button
|
||||
class="toggle-btn"
|
||||
onclick={loadCatalog}
|
||||
disabled={catalogLoading}
|
||||
>
|
||||
{catalogLoading ? 'Laster...' : catalogLoaded ? 'Oppdater' : 'Last inn katalog'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if catalogLoaded}
|
||||
{#if groupedByProvider.length === 0}
|
||||
<p class="hint">Ingen modeller matcher søket.</p>
|
||||
{:else}
|
||||
{#each groupedByProvider as [providerName, models] (providerName)}
|
||||
<div class="provider-group">
|
||||
<button class="provider-header" onclick={() => toggleCatalogProvider(providerName)}>
|
||||
<span class="provider-chevron">{expandedProviders.has(providerName) ? '\u25BC' : '\u25B6'}</span>
|
||||
<span class="provider-name">{providerName}</span>
|
||||
<span class="provider-stats">{models.length} modeller</span>
|
||||
</button>
|
||||
|
||||
{#if expandedProviders.has(providerName)}
|
||||
<div class="catalog-list">
|
||||
<div class="catalog-row catalog-row--header">
|
||||
<span class="cat-col-name">Modell</span>
|
||||
<span class="cat-col-ctx">Kontekst</span>
|
||||
<span class="cat-col-price">Prompt/M</span>
|
||||
<span class="cat-col-price">Kompl./M</span>
|
||||
<span class="cat-col-add"></span>
|
||||
</div>
|
||||
|
||||
{#each models as model (model.id)}
|
||||
<div class="catalog-row">
|
||||
<span class="cat-col-name" title={model.id}>{model.name}</span>
|
||||
<span class="cat-col-ctx">{formatCtx(model.context_length)}</span>
|
||||
<span class="cat-col-price">{formatPrice(model.prompt_price_per_m)}</span>
|
||||
<span class="cat-col-price">{formatPrice(model.completion_price_per_m)}</span>
|
||||
<span class="cat-col-add">
|
||||
{#if addingFromCatalog === model.id}
|
||||
<select
|
||||
bind:value={catalogAddAlias}
|
||||
onchange={() => {
|
||||
if (catalogAddAlias) addFromCatalog(model, catalogAddAlias);
|
||||
}}
|
||||
>
|
||||
<option value="">Velg alias...</option>
|
||||
{#each aliases as a}
|
||||
<option value={a.id}>{a.alias}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{:else}
|
||||
<button
|
||||
class="toggle-btn"
|
||||
onclick={() => { addingFromCatalog = model.id; catalogAddAlias = ''; }}
|
||||
>Legg til →</button>
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- Seksjon 2: Modellaliaser -->
|
||||
<section>
|
||||
<h3>Modellaliaser</h3>
|
||||
<div class="table-list">
|
||||
|
|
@ -291,7 +535,7 @@
|
|||
>
|
||||
{alias.alias}
|
||||
</span>
|
||||
<span class="col-desc">{alias.description ?? '—'}</span>
|
||||
<span class="col-desc">{alias.description ?? '\u2014'}</span>
|
||||
<span class="col-providers">{ap.length}</span>
|
||||
<span class="col-active">
|
||||
<button class="toggle-btn" onclick={() => toggleAlias(alias)}>
|
||||
|
|
@ -308,9 +552,9 @@
|
|||
</div>
|
||||
|
||||
{#if expandedAlias === alias.id}
|
||||
<div class="provider-list">
|
||||
<div class="provider-list-alias">
|
||||
{#each ap as provider (provider.id)}
|
||||
<div class="provider-row" class:provider-row--inactive={!provider.is_active}>
|
||||
<div class="provider-row-alias" class:provider-row--inactive={!provider.is_active}>
|
||||
<span class="col-pri">#{provider.priority}</span>
|
||||
<span class="col-model">{provider.litellm_model}</span>
|
||||
<span class="col-key">{provider.api_key_env}</span>
|
||||
|
|
@ -330,19 +574,51 @@
|
|||
</div>
|
||||
{/each}
|
||||
|
||||
<div class="provider-row provider-row--add">
|
||||
<div class="provider-row-alias provider-row--add">
|
||||
<span class="col-pri"></span>
|
||||
<div class="add-model-input">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="gemini/modell-navn"
|
||||
bind:value={newProvider.litellm_model}
|
||||
/>
|
||||
{#if catalogLoaded}
|
||||
<button
|
||||
class="toggle-btn catalog-pick-btn"
|
||||
onclick={() => { showCatalogPicker = !showCatalogPicker; catalogPickerSearch = ''; }}
|
||||
>Katalog</button>
|
||||
{/if}
|
||||
</div>
|
||||
<select bind:value={newProvider.api_key_env}>
|
||||
<option value="GEMINI_API_KEY">GEMINI_API_KEY</option>
|
||||
<option value="OPENROUTER_API_KEY">OPENROUTER_API_KEY</option>
|
||||
<option value="ANTHROPIC_API_KEY">ANTHROPIC_API_KEY</option>
|
||||
<option value="XAI_API_KEY">XAI_API_KEY</option>
|
||||
</select>
|
||||
<button class="add-btn" onclick={() => addProvider(alias.id)}>Legg til</button>
|
||||
</div>
|
||||
|
||||
{#if showCatalogPicker && catalogLoaded}
|
||||
<div class="catalog-picker">
|
||||
<input
|
||||
type="text"
|
||||
class="catalog-picker-search"
|
||||
placeholder="Søk i katalog..."
|
||||
bind:value={catalogPickerSearch}
|
||||
/>
|
||||
<div class="catalog-picker-list">
|
||||
{#each catalogPickerFiltered as model (model.id)}
|
||||
<button
|
||||
class="catalog-picker-item"
|
||||
onclick={() => selectFromPicker(model)}
|
||||
>
|
||||
<span class="picker-name">{model.name}</span>
|
||||
<span class="picker-meta">{formatCtx(model.context_length)} / {formatPrice(model.completion_price_per_m)}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
|
|
@ -355,7 +631,7 @@
|
|||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Seksjon 2: Jobbruting -->
|
||||
<!-- Seksjon 3: Jobbruting -->
|
||||
<section>
|
||||
<h3>Jobbruting</h3>
|
||||
<div class="table-list">
|
||||
|
|
@ -380,7 +656,7 @@
|
|||
{/each}
|
||||
</select>
|
||||
</span>
|
||||
<span class="col-desc">{route.description ?? '—'}</span>
|
||||
<span class="col-desc">{route.description ?? '\u2014'}</span>
|
||||
<span class="col-status">
|
||||
{#if saving === route.job_type}
|
||||
<span class="status-saving">...</span>
|
||||
|
|
@ -405,7 +681,7 @@
|
|||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Seksjon 3: System-prompts -->
|
||||
<!-- Seksjon 4: System-prompts -->
|
||||
<section>
|
||||
<h3>System-prompts</h3>
|
||||
<div class="table-list">
|
||||
|
|
@ -420,7 +696,7 @@
|
|||
{#each prompts as prompt (prompt.action)}
|
||||
<div class="table-row prompt-row">
|
||||
<span class="col-action">{prompt.action}</span>
|
||||
<span class="col-desc">{prompt.description ?? '—'}</span>
|
||||
<span class="col-desc">{prompt.description ?? '\u2014'}</span>
|
||||
<span class="col-chars">{prompt.system_prompt.length}</span>
|
||||
<span class="col-updated">{new Date(prompt.updated_at).toLocaleDateString('nb-NO')}</span>
|
||||
<span class="col-edit">
|
||||
|
|
@ -453,7 +729,7 @@
|
|||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Seksjon 4: Tokenforbruk -->
|
||||
<!-- Seksjon 5: Tokenforbruk -->
|
||||
<section>
|
||||
<h3>Tokenforbruk (siste 30 dager)</h3>
|
||||
{#if usage.length === 0}
|
||||
|
|
@ -481,7 +757,7 @@
|
|||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- Seksjon 5: Konfigurasjon -->
|
||||
<!-- Seksjon 6: Konfigurasjon -->
|
||||
<section>
|
||||
<h3>Konfigurasjon</h3>
|
||||
<div class="config-box">
|
||||
|
|
@ -503,7 +779,7 @@
|
|||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
h2 {
|
||||
|
|
@ -520,6 +796,34 @@
|
|||
color: #8b92a5;
|
||||
}
|
||||
|
||||
/* API-nøkkel-pills */
|
||||
.key-pills {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.key-pill {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
padding: 0.2rem 0.6rem;
|
||||
border-radius: 9999px;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.key-pill--ok {
|
||||
background: #0d3320;
|
||||
border: 1px solid #166534;
|
||||
color: #4ade80;
|
||||
}
|
||||
|
||||
.key-pill--missing {
|
||||
background: #3b1219;
|
||||
border: 1px solid #6b2028;
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
section {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
|
@ -534,6 +838,138 @@
|
|||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* Modellkatalog */
|
||||
.catalog-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.catalog-header h3 {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.catalog-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.catalog-search {
|
||||
background: #0f1117;
|
||||
border: 1px solid #2d3148;
|
||||
border-radius: 4px;
|
||||
color: #e1e4e8;
|
||||
padding: 0.3rem 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.catalog-search:focus {
|
||||
outline: none;
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
|
||||
.provider-group {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.provider-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: #1a1d2e;
|
||||
border: 1px solid #2d3148;
|
||||
border-radius: 6px;
|
||||
color: #e1e4e8;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.provider-header:hover {
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
|
||||
.provider-chevron {
|
||||
font-size: 0.6rem;
|
||||
color: #8b92a5;
|
||||
width: 1em;
|
||||
}
|
||||
|
||||
.provider-name {
|
||||
font-weight: 600;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.provider-stats {
|
||||
font-size: 0.75rem;
|
||||
color: #8b92a5;
|
||||
}
|
||||
|
||||
.catalog-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
background: #2d3148;
|
||||
border: 1px solid #2d3148;
|
||||
border-top: none;
|
||||
border-radius: 0 0 6px 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.catalog-row {
|
||||
display: grid;
|
||||
grid-template-columns: 3fr 70px 80px 80px 100px;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.4rem 0.75rem;
|
||||
background: #161822;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.catalog-row--header {
|
||||
background: #1a1d2e;
|
||||
font-weight: 600;
|
||||
color: #8b92a5;
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.cat-col-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cat-col-ctx {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: #8b92a5;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.cat-col-price {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.cat-col-add {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.cat-col-add select {
|
||||
font-size: 0.7rem;
|
||||
padding: 0.15rem 0.25rem;
|
||||
}
|
||||
|
||||
/* Alias table */
|
||||
.table-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
@ -616,14 +1052,14 @@
|
|||
color: #8b92a5;
|
||||
}
|
||||
|
||||
/* Provider sub-list */
|
||||
.provider-list {
|
||||
/* Provider sub-list (alias section) */
|
||||
.provider-list-alias {
|
||||
background: #0f1117;
|
||||
padding: 0.5rem 0.75rem 0.5rem 2rem;
|
||||
border-bottom: 1px solid #2d3148;
|
||||
}
|
||||
|
||||
.provider-row {
|
||||
.provider-row-alias {
|
||||
display: grid;
|
||||
grid-template-columns: 30px 2fr 1.5fr 60px 60px 40px;
|
||||
align-items: center;
|
||||
|
|
@ -642,6 +1078,87 @@
|
|||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.add-model-input {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.add-model-input input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.catalog-pick-btn {
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Catalog picker dropdown */
|
||||
.catalog-picker {
|
||||
background: #161822;
|
||||
border: 1px solid #3b82f6;
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.catalog-picker-search {
|
||||
width: 100%;
|
||||
background: #0f1117;
|
||||
border: 1px solid #2d3148;
|
||||
border-radius: 4px;
|
||||
color: #e1e4e8;
|
||||
padding: 0.3rem 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.catalog-picker-search:focus {
|
||||
outline: none;
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
|
||||
.catalog-picker-list {
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.catalog-picker-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.3rem 0.5rem;
|
||||
background: #0f1117;
|
||||
border: none;
|
||||
color: #e1e4e8;
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.catalog-picker-item:hover {
|
||||
background: #1a1d2e;
|
||||
}
|
||||
|
||||
.picker-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.picker-meta {
|
||||
color: #8b92a5;
|
||||
font-size: 0.7rem;
|
||||
flex-shrink: 0;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.toggle-btn {
|
||||
background: #1a1d2e;
|
||||
|
|
@ -657,6 +1174,11 @@
|
|||
border-color: #3b82f6;
|
||||
}
|
||||
|
||||
.toggle-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.delete-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
|
|
@ -705,8 +1227,7 @@
|
|||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
input[type='text'],
|
||||
input[type='number'] {
|
||||
input[type='text'] {
|
||||
background: #0f1117;
|
||||
border: 1px solid #2d3148;
|
||||
border-radius: 4px;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue