2218 lines
54 KiB
Svelte
2218 lines
54 KiB
Svelte
<script lang="ts">
|
|
import type { PageData } from './$types';
|
|
|
|
let { data } = $props<{ data: PageData }>();
|
|
|
|
interface Alias {
|
|
id: string;
|
|
alias: string;
|
|
description: string | null;
|
|
is_active: boolean;
|
|
}
|
|
|
|
interface Provider {
|
|
id: string;
|
|
alias_id: string;
|
|
priority: number;
|
|
litellm_model: string;
|
|
api_key_env: string;
|
|
is_active: boolean;
|
|
extra_params: Record<string, unknown> | null;
|
|
}
|
|
|
|
interface Route {
|
|
job_type: string;
|
|
alias_id: string;
|
|
alias: string;
|
|
description: string | null;
|
|
}
|
|
|
|
interface Prompt {
|
|
action: string;
|
|
system_prompt: string;
|
|
description: string | null;
|
|
label: string | null;
|
|
icon: string | null;
|
|
sort_order: number;
|
|
updated_at: string;
|
|
}
|
|
|
|
interface UsageRow {
|
|
model_alias: string;
|
|
model_actual: string | null;
|
|
action: string | null;
|
|
call_count: number;
|
|
prompt_tokens: number;
|
|
completion_tokens: number;
|
|
total_tokens: number;
|
|
}
|
|
|
|
interface CatalogModel {
|
|
id: string;
|
|
name: string;
|
|
provider: string;
|
|
litellm_prefix: string;
|
|
api_key_env: string;
|
|
context_length: number;
|
|
prompt_price_per_m: number;
|
|
completion_price_per_m: number;
|
|
modality: string;
|
|
max_completion: number | null;
|
|
}
|
|
|
|
interface ApiKey {
|
|
name: string;
|
|
label: string;
|
|
configured: boolean;
|
|
has_db_value: boolean;
|
|
is_enabled: 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[]);
|
|
let prompts = $state<Prompt[]>(data.prompts as Prompt[]);
|
|
let usage = $state<UsageRow[]>(data.usage as UsageRow[]);
|
|
|
|
let saving = $state<string | null>(null);
|
|
let saved = $state<string | null>(null);
|
|
let errorMsg = $state('');
|
|
let configMsg = $state('');
|
|
let editingPrompt = $state<string | null>(null);
|
|
let editPromptText = $state('');
|
|
let editPromptLabel = $state('');
|
|
let editPromptIcon = $state('');
|
|
let expandedAlias = $state<string | null>(null);
|
|
|
|
// Alias-redigering
|
|
let editingAlias = $state<string | null>(null);
|
|
let editAliasName = $state('');
|
|
let editAliasDesc = $state('');
|
|
|
|
// API-nøkler
|
|
let apiKeys = $state<ApiKey[]>([]);
|
|
let keysLoaded = $state(false);
|
|
let expandedKey = $state<string | null>(null);
|
|
let keyValueInput = $state('');
|
|
let newKey = $state({ env_name: '', label: '', key_value: '' });
|
|
|
|
// 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('');
|
|
|
|
// Extra params redigering
|
|
let editingExtraParams = $state<string | null>(null);
|
|
let editExtraParamsText = $state('');
|
|
|
|
// Ny provider-form
|
|
let newProvider = $state<{ alias_id: string; litellm_model: string; api_key_env: string }>({
|
|
alias_id: '',
|
|
litellm_model: '',
|
|
api_key_env: 'OPENROUTER_API_KEY'
|
|
});
|
|
|
|
// Ny alias-form
|
|
let newAlias = $state({ alias: '', description: '' });
|
|
|
|
// Ny ruting-form
|
|
let newRoute = $state({ job_type: '', alias_id: '', description: '' });
|
|
|
|
function providersForAlias(aliasId: string): Provider[] {
|
|
return providers.filter((p) => p.alias_id === aliasId).sort((a, b) => a.priority - b.priority);
|
|
}
|
|
|
|
function markSaved(id: string) {
|
|
saved = id;
|
|
setTimeout(() => {
|
|
if (saved === id) saved = null;
|
|
}, 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 | null | undefined): string {
|
|
if (n == null || n < 0) return '\u2014';
|
|
if (n === 0) return '\u2014';
|
|
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();
|
|
|
|
async function toggleKey(key: ApiKey) {
|
|
errorMsg = '';
|
|
try {
|
|
const res = await fetch('/api/admin/ai/keys', {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ env_name: key.name, is_enabled: !key.is_enabled })
|
|
});
|
|
if (!res.ok) throw new Error('Feil ved lagring');
|
|
const updated = await res.json();
|
|
Object.assign(key, { is_enabled: updated.is_enabled, configured: updated.configured, has_db_value: updated.has_db_value });
|
|
apiKeys = [...apiKeys];
|
|
} catch {
|
|
errorMsg = 'Kunne ikke oppdatere nøkkel-status';
|
|
}
|
|
}
|
|
|
|
async function saveKeyValue(key: ApiKey) {
|
|
errorMsg = '';
|
|
try {
|
|
const res = await fetch('/api/admin/ai/keys', {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ env_name: key.name, key_value: keyValueInput })
|
|
});
|
|
if (!res.ok) throw new Error('Feil ved lagring');
|
|
const updated = await res.json();
|
|
Object.assign(key, { configured: updated.configured, has_db_value: updated.has_db_value, is_enabled: updated.is_enabled });
|
|
apiKeys = [...apiKeys];
|
|
keyValueInput = '';
|
|
expandedKey = null;
|
|
markSaved(key.name);
|
|
} catch {
|
|
errorMsg = 'Kunne ikke lagre nøkkelverdi';
|
|
}
|
|
}
|
|
|
|
async function clearKeyValue(key: ApiKey) {
|
|
errorMsg = '';
|
|
try {
|
|
const res = await fetch('/api/admin/ai/keys', {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ env_name: key.name, key_value: '' })
|
|
});
|
|
if (!res.ok) throw new Error('Feil');
|
|
const updated = await res.json();
|
|
Object.assign(key, { configured: updated.configured, has_db_value: updated.has_db_value });
|
|
apiKeys = [...apiKeys];
|
|
markSaved(key.name);
|
|
} catch {
|
|
errorMsg = 'Kunne ikke fjerne nøkkelverdi';
|
|
}
|
|
}
|
|
|
|
async function addKey() {
|
|
errorMsg = '';
|
|
if (!newKey.env_name || !newKey.label) return;
|
|
try {
|
|
const res = await fetch('/api/admin/ai/keys', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(newKey)
|
|
});
|
|
if (!res.ok) {
|
|
const data = await res.json();
|
|
throw new Error(data.message || 'Feil');
|
|
}
|
|
const row = await res.json();
|
|
apiKeys = [...apiKeys, row].sort((a, b) => a.label.localeCompare(b.label));
|
|
newKey = { env_name: '', label: '', key_value: '' };
|
|
} catch (e: any) {
|
|
errorMsg = e.message || 'Kunne ikke legge til nøkkel';
|
|
}
|
|
}
|
|
|
|
async function deleteKey(key: ApiKey) {
|
|
errorMsg = '';
|
|
try {
|
|
const res = await fetch('/api/admin/ai/keys', {
|
|
method: 'DELETE',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ env_name: key.name })
|
|
});
|
|
if (!res.ok) {
|
|
const data = await res.json();
|
|
throw new Error(data.message || 'Feil');
|
|
}
|
|
apiKeys = apiKeys.filter(k => k.name !== key.name);
|
|
} catch (e: any) {
|
|
errorMsg = e.message || 'Kunne ikke slette nøkkel';
|
|
}
|
|
}
|
|
|
|
// Modellkatalog
|
|
async function loadCatalog() {
|
|
catalogLoading = true;
|
|
errorMsg = '';
|
|
try {
|
|
const res = await fetch('/api/admin/ai/models?refresh=1');
|
|
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;
|
|
}
|
|
}
|
|
|
|
// Grupper etter api_key_env + provider (f.eks. "google via GEMINI_API_KEY" vs "google via OPENROUTER_API_KEY")
|
|
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) ||
|
|
m.api_key_env.toLowerCase().includes(search)
|
|
)
|
|
: catalogModels;
|
|
|
|
const map = new Map<string, CatalogModel[]>();
|
|
for (const m of filtered) {
|
|
// Grupper per API-nøkkel, med provider som undergruppe
|
|
const groupKey = m.api_key_env === 'OPENROUTER_API_KEY'
|
|
? `${m.provider} (OpenRouter)`
|
|
: m.provider;
|
|
const list = map.get(groupKey) ?? [];
|
|
list.push(m);
|
|
map.set(groupKey, list);
|
|
}
|
|
|
|
// Sorter: pris synkende først (ukjent/-1 sist), deretter navn synkende
|
|
for (const [, models] of map) {
|
|
models.sort((a, b) => {
|
|
const aPrice = a.completion_price_per_m;
|
|
const bPrice = b.completion_price_per_m;
|
|
const aHasPrice = aPrice > 0;
|
|
const bHasPrice = bPrice > 0;
|
|
if (aHasPrice !== bHasPrice) return aHasPrice ? -1 : 1;
|
|
if (aPrice !== bPrice) return bPrice - aPrice;
|
|
return b.name.localeCompare(a.name);
|
|
});
|
|
}
|
|
|
|
// Direkte API-nøkler først, deretter OpenRouter-grupper
|
|
return [...map.entries()].sort(([a], [b]) => {
|
|
const aOr = a.includes('OpenRouter');
|
|
const bOr = b.includes('OpenRouter');
|
|
if (aOr !== bOr) return aOr ? 1 : -1;
|
|
return 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);
|
|
}
|
|
|
|
function litellmModelId(model: CatalogModel): string {
|
|
return `${model.litellm_prefix}${model.id}`;
|
|
}
|
|
|
|
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: litellmModelId(model),
|
|
api_key_env: model.api_key_env
|
|
})
|
|
});
|
|
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 = litellmModelId(model);
|
|
newProvider.api_key_env = model.api_key_env;
|
|
showCatalogPicker = false;
|
|
catalogPickerSearch = '';
|
|
}
|
|
|
|
async function toggleAlias(alias: Alias) {
|
|
saving = alias.id;
|
|
errorMsg = '';
|
|
try {
|
|
const res = await fetch(`/api/admin/ai/aliases/${alias.id}`, {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ is_active: !alias.is_active })
|
|
});
|
|
if (!res.ok) throw new Error('Feil ved lagring');
|
|
const updated = await res.json();
|
|
alias.is_active = updated.is_active;
|
|
markSaved(alias.id);
|
|
} catch {
|
|
errorMsg = 'Kunne ikke oppdatere alias';
|
|
} finally {
|
|
saving = null;
|
|
}
|
|
}
|
|
|
|
function startEditAlias(alias: Alias) {
|
|
editingAlias = alias.id;
|
|
editAliasName = alias.alias;
|
|
editAliasDesc = alias.description ?? '';
|
|
}
|
|
|
|
function cancelEditAlias() {
|
|
editingAlias = null;
|
|
editAliasName = '';
|
|
editAliasDesc = '';
|
|
}
|
|
|
|
async function saveAlias(alias: Alias) {
|
|
saving = alias.id;
|
|
errorMsg = '';
|
|
try {
|
|
const res = await fetch(`/api/admin/ai/aliases/${alias.id}`, {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ alias: editAliasName, description: editAliasDesc || null })
|
|
});
|
|
if (!res.ok) throw new Error('Feil ved lagring');
|
|
const updated = await res.json();
|
|
alias.alias = updated.alias;
|
|
alias.description = updated.description;
|
|
editingAlias = null;
|
|
markSaved(alias.id);
|
|
} catch {
|
|
errorMsg = 'Kunne ikke oppdatere alias';
|
|
} finally {
|
|
saving = null;
|
|
}
|
|
}
|
|
|
|
function estimateCost(row: UsageRow): number | null {
|
|
if (!catalogLoaded || !row.model_actual) return null;
|
|
// model_actual kan være "xai/grok-..." eller "google/gemma-..." — match mot id eller litellm_prefix+id
|
|
const actual = row.model_actual;
|
|
const model = catalogModels.find((m) =>
|
|
m.id === actual || `${m.litellm_prefix}${m.id}` === actual || `${m.provider}/${m.id}` === actual
|
|
);
|
|
if (!model || model.prompt_price_per_m < 0 || model.completion_price_per_m < 0) return null;
|
|
return (
|
|
(row.prompt_tokens / 1_000_000) * model.prompt_price_per_m +
|
|
(row.completion_tokens / 1_000_000) * model.completion_price_per_m
|
|
);
|
|
}
|
|
|
|
let totalEstimatedCost = $derived.by(() => {
|
|
if (!catalogLoaded) return null;
|
|
let total = 0;
|
|
let hasAny = false;
|
|
for (const row of usage) {
|
|
const cost = estimateCost(row);
|
|
if (cost !== null) {
|
|
total += cost;
|
|
hasAny = true;
|
|
}
|
|
}
|
|
return hasAny ? total : null;
|
|
});
|
|
|
|
function hasWebPlugin(provider: Provider): boolean {
|
|
const plugins = provider.extra_params?.plugins;
|
|
return Array.isArray(plugins) && plugins.some((p: any) => p.id === 'web');
|
|
}
|
|
|
|
async function toggleWebPlugin(provider: Provider) {
|
|
const has = hasWebPlugin(provider);
|
|
let newParams: Record<string, unknown> | null;
|
|
if (has) {
|
|
const { plugins, ...rest } = provider.extra_params ?? {};
|
|
newParams = Object.keys(rest).length > 0 ? rest : null;
|
|
} else {
|
|
newParams = { ...(provider.extra_params ?? {}), plugins: [{ id: 'web' }] };
|
|
}
|
|
saving = provider.id;
|
|
errorMsg = '';
|
|
try {
|
|
const res = await fetch(`/api/admin/ai/providers/${provider.id}`, {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ extra_params: newParams })
|
|
});
|
|
if (!res.ok) throw new Error('Feil');
|
|
const updated = await res.json();
|
|
provider.extra_params = updated.extra_params;
|
|
markSaved(provider.id);
|
|
} catch {
|
|
errorMsg = 'Kunne ikke oppdatere web-plugin';
|
|
} finally {
|
|
saving = null;
|
|
}
|
|
}
|
|
|
|
function startEditExtraParams(provider: Provider) {
|
|
editingExtraParams = provider.id;
|
|
editExtraParamsText = provider.extra_params ? JSON.stringify(provider.extra_params, null, 2) : '';
|
|
}
|
|
|
|
async function saveExtraParams(provider: Provider) {
|
|
saving = provider.id;
|
|
errorMsg = '';
|
|
try {
|
|
const parsed = editExtraParamsText.trim() ? JSON.parse(editExtraParamsText) : null;
|
|
const res = await fetch(`/api/admin/ai/providers/${provider.id}`, {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ extra_params: parsed })
|
|
});
|
|
if (!res.ok) throw new Error('Feil');
|
|
const updated = await res.json();
|
|
provider.extra_params = updated.extra_params;
|
|
editingExtraParams = null;
|
|
markSaved(provider.id);
|
|
} catch (e: any) {
|
|
errorMsg = e.message === 'Feil' ? 'Kunne ikke lagre params' : `Ugyldig JSON: ${e.message}`;
|
|
} finally {
|
|
saving = null;
|
|
}
|
|
}
|
|
|
|
async function swapProviders(providerA: Provider, providerB: Provider) {
|
|
saving = providerA.id;
|
|
errorMsg = '';
|
|
try {
|
|
const res = await fetch('/api/admin/ai/providers/swap', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ id_a: providerA.id, id_b: providerB.id })
|
|
});
|
|
if (!res.ok) throw new Error('Feil ved bytte');
|
|
const updated: Provider[] = await res.json();
|
|
// Oppdater providers-listen med nye prioriteter
|
|
for (const u of updated) {
|
|
const existing = providers.find(p => p.id === u.id);
|
|
if (existing) existing.priority = u.priority;
|
|
}
|
|
providers = [...providers];
|
|
markSaved(providerA.id);
|
|
} catch {
|
|
errorMsg = 'Kunne ikke bytte rekkefølge';
|
|
} finally {
|
|
saving = null;
|
|
}
|
|
}
|
|
|
|
async function toggleProvider(provider: Provider) {
|
|
saving = provider.id;
|
|
errorMsg = '';
|
|
try {
|
|
const res = await fetch(`/api/admin/ai/providers/${provider.id}`, {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ is_active: !provider.is_active })
|
|
});
|
|
if (!res.ok) throw new Error('Feil ved lagring');
|
|
const updated = await res.json();
|
|
provider.is_active = updated.is_active;
|
|
markSaved(provider.id);
|
|
} catch {
|
|
errorMsg = 'Kunne ikke oppdatere provider';
|
|
} finally {
|
|
saving = null;
|
|
}
|
|
}
|
|
|
|
async function addProvider(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: newProvider.litellm_model,
|
|
api_key_env: newProvider.api_key_env
|
|
})
|
|
});
|
|
if (!res.ok) throw new Error('Feil ved opprettelse');
|
|
const row = await res.json();
|
|
providers = [...providers, row];
|
|
newProvider = { alias_id: '', litellm_model: '', api_key_env: 'OPENROUTER_API_KEY' };
|
|
} catch {
|
|
errorMsg = 'Kunne ikke legge til provider';
|
|
}
|
|
}
|
|
|
|
async function deleteProvider(provider: Provider) {
|
|
errorMsg = '';
|
|
try {
|
|
const res = await fetch(`/api/admin/ai/providers/${provider.id}`, { method: 'DELETE' });
|
|
if (!res.ok) throw new Error('Feil');
|
|
providers = providers.filter((p) => p.id !== provider.id);
|
|
// Renummerer prioriteter for gjenværende providers under samme alias
|
|
await renumberPriorities(provider.alias_id);
|
|
} catch {
|
|
errorMsg = 'Kunne ikke slette provider';
|
|
}
|
|
}
|
|
|
|
async function renumberPriorities(aliasId: string) {
|
|
const ap = providersForAlias(aliasId);
|
|
let changed = false;
|
|
for (let i = 0; i < ap.length; i++) {
|
|
if (ap[i].priority !== i + 1) {
|
|
ap[i].priority = i + 1;
|
|
changed = true;
|
|
}
|
|
}
|
|
if (!changed) return;
|
|
try {
|
|
await fetch('/api/admin/ai/providers/renumber', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(ap.map(p => ({ id: p.id, priority: p.priority })))
|
|
});
|
|
providers = [...providers];
|
|
} catch { /* stille */ }
|
|
}
|
|
|
|
async function addAlias() {
|
|
errorMsg = '';
|
|
if (!newAlias.alias) return;
|
|
try {
|
|
const res = await fetch('/api/admin/ai/aliases', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(newAlias)
|
|
});
|
|
if (!res.ok) throw new Error('Feil');
|
|
const row = await res.json();
|
|
aliases = [...aliases, row];
|
|
newAlias = { alias: '', description: '' };
|
|
} catch {
|
|
errorMsg = 'Kunne ikke opprette alias';
|
|
}
|
|
}
|
|
|
|
async function updateRouting(route: Route, aliasId: string) {
|
|
saving = route.job_type;
|
|
errorMsg = '';
|
|
try {
|
|
const res = await fetch(`/api/admin/ai/routing/${route.job_type}`, {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ alias_id: aliasId })
|
|
});
|
|
if (!res.ok) throw new Error('Feil');
|
|
const updated = await res.json();
|
|
route.alias_id = updated.alias_id;
|
|
route.alias = aliases.find((a) => a.id === updated.alias_id)?.alias ?? route.alias;
|
|
markSaved(route.job_type);
|
|
} catch {
|
|
errorMsg = 'Kunne ikke oppdatere ruting';
|
|
} finally {
|
|
saving = null;
|
|
}
|
|
}
|
|
|
|
async function addRoute() {
|
|
errorMsg = '';
|
|
if (!newRoute.job_type || !newRoute.alias_id) return;
|
|
try {
|
|
const res = await fetch('/api/admin/ai/routing', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(newRoute)
|
|
});
|
|
if (!res.ok) throw new Error('Feil');
|
|
const row = await res.json();
|
|
row.alias = aliases.find((a: Alias) => a.id === row.alias_id)?.alias ?? '';
|
|
routing = [...routing, row];
|
|
newRoute = { job_type: '', alias_id: '', description: '' };
|
|
} catch {
|
|
errorMsg = 'Kunne ikke legge til ruting';
|
|
}
|
|
}
|
|
|
|
function startEditPrompt(prompt: Prompt) {
|
|
editingPrompt = prompt.action;
|
|
editPromptText = prompt.system_prompt;
|
|
editPromptLabel = prompt.label ?? '';
|
|
editPromptIcon = prompt.icon ?? '';
|
|
}
|
|
|
|
function cancelEditPrompt() {
|
|
editingPrompt = null;
|
|
editPromptText = '';
|
|
editPromptLabel = '';
|
|
editPromptIcon = '';
|
|
}
|
|
|
|
async function savePrompt(prompt: Prompt) {
|
|
saving = prompt.action;
|
|
errorMsg = '';
|
|
try {
|
|
const res = await fetch(`/api/admin/ai/prompts/${prompt.action}`, {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
system_prompt: editPromptText,
|
|
label: editPromptLabel || null,
|
|
icon: editPromptIcon || null
|
|
})
|
|
});
|
|
if (!res.ok) throw new Error('Feil ved lagring');
|
|
const updated = await res.json();
|
|
prompt.system_prompt = updated.system_prompt;
|
|
prompt.label = updated.label;
|
|
prompt.icon = updated.icon;
|
|
prompt.updated_at = updated.updated_at;
|
|
editingPrompt = null;
|
|
editPromptText = '';
|
|
editPromptLabel = '';
|
|
editPromptIcon = '';
|
|
markSaved(prompt.action);
|
|
} catch {
|
|
errorMsg = 'Kunne ikke lagre prompt';
|
|
} finally {
|
|
saving = null;
|
|
}
|
|
}
|
|
|
|
let configLoading = $state(false);
|
|
|
|
async function generateConfig(restart = false) {
|
|
configMsg = '';
|
|
errorMsg = '';
|
|
configLoading = true;
|
|
try {
|
|
const url = restart
|
|
? '/api/admin/ai/generate-config?restart=true'
|
|
: '/api/admin/ai/generate-config';
|
|
const res = await fetch(url, { method: 'POST' });
|
|
const data = await res.json();
|
|
if (!res.ok) throw new Error(data.message ?? 'Feil');
|
|
configMsg = `${data.message} (${data.model_count} modeller)`;
|
|
} catch (e: any) {
|
|
errorMsg = e.message || 'Feil ved config-generering';
|
|
} finally {
|
|
configLoading = false;
|
|
}
|
|
}
|
|
|
|
let enabledKeys = $derived(new Set(apiKeys.filter(k => k.is_enabled).map(k => k.name)));
|
|
|
|
function isKeyEnabled(envName: string): boolean {
|
|
return enabledKeys.has(envName);
|
|
}
|
|
|
|
let sortedAliases = $derived([...aliases].sort((a, b) => a.alias.localeCompare(b.alias, 'nb')));
|
|
let sortedUsage = $derived([...usage].sort((a, b) => b.total_tokens - a.total_tokens));
|
|
let totalTokens = $derived(usage.reduce((s, u) => s + u.total_tokens, 0));
|
|
</script>
|
|
|
|
<div class="admin-ai">
|
|
<div class="header">
|
|
<h2>AI-administrasjon</h2>
|
|
<span class="header-stats">{aliases.length} aliaser / {providers.length} leverandører / {totalTokens.toLocaleString('nb-NO')} tokens (30d)</span>
|
|
</div>
|
|
|
|
<!-- API-nøkler -->
|
|
{#if keysLoaded}
|
|
<div class="key-pills">
|
|
{#each apiKeys as key}
|
|
<button
|
|
class="key-pill"
|
|
class:key-pill--on={key.is_enabled && key.configured}
|
|
class:key-pill--off={!key.is_enabled && key.configured}
|
|
class:key-pill--missing={!key.configured}
|
|
onclick={() => toggleKey(key)}
|
|
title={key.configured
|
|
? (key.is_enabled ? `${key.label} aktiv — klikk for å deaktivere` : `${key.label} deaktivert — klikk for å aktivere`)
|
|
: `${key.label} — mangler nøkkelverdi`}
|
|
>
|
|
{key.label}
|
|
{#if !key.configured}
|
|
✗
|
|
{:else if key.is_enabled}
|
|
✓
|
|
{:else}
|
|
○
|
|
{/if}
|
|
</button>
|
|
{/each}
|
|
<button
|
|
class="key-pill key-pill--add"
|
|
onclick={() => { expandedKey = expandedKey === '__new__' ? null : '__new__'; }}
|
|
>+ Ny nøkkel</button>
|
|
</div>
|
|
|
|
<!-- Utvidet nøkkeladministrasjon -->
|
|
{#if expandedKey === '__new__'}
|
|
<div class="key-manage-row">
|
|
<input type="text" placeholder="ENV_NAVN (f.eks. DEEPSEEK_API_KEY)" bind:value={newKey.env_name} class="key-input" />
|
|
<input type="text" placeholder="Visningsnavn" bind:value={newKey.label} class="key-input key-input--short" />
|
|
<input type="password" placeholder="API-nøkkel (valgfritt)" bind:value={newKey.key_value} class="key-input" />
|
|
<button class="add-btn" onclick={addKey}>Legg til</button>
|
|
<button class="toggle-btn" onclick={() => { expandedKey = null; }}>Avbryt</button>
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Nøkkeldetaljer -->
|
|
<div class="key-details">
|
|
{#each apiKeys as key}
|
|
<div class="key-detail-row">
|
|
<button
|
|
class="key-detail-toggle"
|
|
onclick={() => { expandedKey = expandedKey === key.name ? null : key.name; keyValueInput = ''; }}
|
|
>
|
|
<span class="key-detail-label">{key.label}</span>
|
|
<span class="key-detail-env">{key.name}</span>
|
|
<span class="key-detail-source">
|
|
{#if key.has_db_value}
|
|
nøkkel i DB
|
|
{:else if key.configured}
|
|
nøkkel fra env
|
|
{:else}
|
|
ikke konfigurert
|
|
{/if}
|
|
</span>
|
|
</button>
|
|
{#if expandedKey === key.name}
|
|
<div class="key-detail-edit">
|
|
<input
|
|
type="password"
|
|
placeholder="Lim inn ny nøkkelverdi..."
|
|
bind:value={keyValueInput}
|
|
class="key-input"
|
|
/>
|
|
<div class="key-detail-actions">
|
|
<button class="add-btn" onclick={() => saveKeyValue(key)}>Lagre nøkkel</button>
|
|
{#if key.has_db_value}
|
|
<button class="toggle-btn" onclick={() => clearKeyValue(key)}>Fjern DB-verdi</button>
|
|
{/if}
|
|
<button class="delete-btn" onclick={() => deleteKey(key)}>Slett</button>
|
|
</div>
|
|
{#if saved === key.name}
|
|
<span class="status-saved">OK</span>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
|
|
{#if errorMsg}
|
|
<div class="error-msg">{errorMsg}</div>
|
|
{/if}
|
|
|
|
<!-- Seksjon 1: Modellkatalog -->
|
|
<section>
|
|
<div class="catalog-header">
|
|
<h3>Modellkatalog</h3>
|
|
<div class="catalog-actions">
|
|
{#if catalogLoaded}
|
|
<input
|
|
type="text"
|
|
class="catalog-search"
|
|
placeholder="Søk modeller..."
|
|
bind:value={catalogSearch}
|
|
/>
|
|
<button class="toggle-btn" onclick={() => { catalogLoaded = false; catalogModels = []; }}>Skjul</button>
|
|
{/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">
|
|
<button
|
|
class="toggle-btn"
|
|
onclick={() => { addingFromCatalog = addingFromCatalog === model.id ? null : model.id; catalogAddAlias = ''; }}
|
|
>{addingFromCatalog === model.id ? '✗' : 'Legg til →'}</button>
|
|
</span>
|
|
</div>
|
|
{#if addingFromCatalog === model.id}
|
|
<div class="catalog-add-row">
|
|
<span class="catalog-add-label">Legg til <strong>{model.name}</strong> via {model.api_key_env}:</span>
|
|
<select bind:value={catalogAddAlias}>
|
|
<option value="">Velg alias...</option>
|
|
{#each aliases as a}
|
|
<option value={a.id}>{a.alias}</option>
|
|
{/each}
|
|
</select>
|
|
<button
|
|
class="add-btn"
|
|
disabled={!catalogAddAlias}
|
|
onclick={() => addFromCatalog(model, catalogAddAlias)}
|
|
>Legg til</button>
|
|
</div>
|
|
{/if}
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{/each}
|
|
{/if}
|
|
{/if}
|
|
</section>
|
|
|
|
<!-- Seksjon 2: Modellaliaser -->
|
|
<section>
|
|
<h3>Modellaliaser</h3>
|
|
<div class="table-list">
|
|
<div class="table-row table-row--header">
|
|
<span class="col-alias">Alias</span>
|
|
<span class="col-desc">Beskrivelse</span>
|
|
<span class="col-providers">Leverandører</span>
|
|
<span class="col-active">Aktiv</span>
|
|
<span class="col-status"></span>
|
|
</div>
|
|
|
|
{#each sortedAliases as alias (alias.id)}
|
|
{@const ap = providersForAlias(alias.id)}
|
|
{@const primaryModel = ap.find(p => p.is_active && isKeyEnabled(p.api_key_env))?.litellm_model}
|
|
{#if editingAlias === alias.id}
|
|
<div class="table-row alias-edit-row">
|
|
<input type="text" class="alias-edit-input" bind:value={editAliasName} placeholder="Alias-navn" />
|
|
<input type="text" class="alias-edit-input" bind:value={editAliasDesc} placeholder="Beskrivelse" />
|
|
<span></span>
|
|
<span class="alias-edit-actions">
|
|
<button class="add-btn" onclick={() => saveAlias(alias)}>Lagre</button>
|
|
<button class="toggle-btn" onclick={cancelEditAlias}>Avbryt</button>
|
|
</span>
|
|
<span class="col-status">
|
|
{#if saving === alias.id}
|
|
<span class="status-saving">...</span>
|
|
{/if}
|
|
</span>
|
|
</div>
|
|
{:else}
|
|
<div class="table-row" class:table-row--inactive={!alias.is_active}>
|
|
<span
|
|
class="col-alias clickable"
|
|
onclick={() => (expandedAlias = expandedAlias === alias.id ? null : alias.id)}
|
|
>
|
|
{alias.alias}
|
|
{#if primaryModel}
|
|
<span class="alias-model">{primaryModel.replace('openrouter/', '')}</span>
|
|
{/if}
|
|
</span>
|
|
<span class="col-desc">{alias.description ?? '\u2014'}</span>
|
|
<span class="col-providers">{ap.length}</span>
|
|
<span class="col-active alias-action-group">
|
|
<button class="toggle-btn" onclick={() => startEditAlias(alias)}>Rediger</button>
|
|
<button class="toggle-btn" onclick={() => toggleAlias(alias)}>
|
|
{alias.is_active ? 'På' : 'Av'}
|
|
</button>
|
|
</span>
|
|
<span class="col-status">
|
|
{#if saving === alias.id}
|
|
<span class="status-saving">...</span>
|
|
{:else if saved === alias.id}
|
|
<span class="status-saved">OK</span>
|
|
{/if}
|
|
</span>
|
|
</div>
|
|
{/if}
|
|
|
|
{#if expandedAlias === alias.id}
|
|
<div class="provider-list-alias">
|
|
{#each ap as provider, idx (provider.id)}
|
|
{@const keyDisabled = !isKeyEnabled(provider.api_key_env)}
|
|
<div class="provider-row-alias" class:provider-row--inactive={!provider.is_active} class:provider-row--key-off={keyDisabled}>
|
|
<span class="col-pri">
|
|
<span class="pri-arrows">
|
|
<button
|
|
class="pri-btn"
|
|
disabled={idx === 0}
|
|
onclick={() => swapProviders(provider, ap[idx - 1])}
|
|
title="Flytt opp"
|
|
>▲</button>
|
|
<button
|
|
class="pri-btn"
|
|
disabled={idx === ap.length - 1}
|
|
onclick={() => swapProviders(provider, ap[idx + 1])}
|
|
title="Flytt ned"
|
|
>▼</button>
|
|
</span>
|
|
#{provider.priority}
|
|
</span>
|
|
<span class="col-model">{provider.litellm_model}</span>
|
|
<span class="col-key" class:col-key--disabled={keyDisabled}>{provider.api_key_env}{#if keyDisabled} (av){/if}</span>
|
|
<span class="col-extra-pills">
|
|
<button
|
|
class="extra-pill"
|
|
class:extra-pill--on={hasWebPlugin(provider)}
|
|
onclick={() => toggleWebPlugin(provider)}
|
|
title="Web-søk (OpenRouter plugin)"
|
|
>web {hasWebPlugin(provider) ? '\u2713' : '\u2717'}</button>
|
|
{#if provider.extra_params && Object.keys(provider.extra_params).length > (hasWebPlugin(provider) ? 1 : 0)}
|
|
<span class="extra-pill extra-pill--custom" title={JSON.stringify(provider.extra_params)}>+params</span>
|
|
{/if}
|
|
<button class="extra-edit-btn" onclick={() => startEditExtraParams(provider)} title="Rediger extra_params JSON">…</button>
|
|
</span>
|
|
<span class="col-active">
|
|
<button class="toggle-btn" onclick={() => toggleProvider(provider)}>
|
|
{provider.is_active ? 'På' : 'Av'}
|
|
</button>
|
|
</span>
|
|
<button class="delete-btn" onclick={() => deleteProvider(provider)}>Slett</button>
|
|
<span class="col-status">
|
|
{#if saving === provider.id}
|
|
<span class="status-saving">...</span>
|
|
{:else if saved === provider.id}
|
|
<span class="status-saved">OK</span>
|
|
{/if}
|
|
</span>
|
|
</div>
|
|
{#if editingExtraParams === provider.id}
|
|
<div class="extra-params-editor">
|
|
<textarea
|
|
bind:value={editExtraParamsText}
|
|
rows="4"
|
|
placeholder={'{"plugins": [{"id": "web"}]}'}
|
|
></textarea>
|
|
<div class="extra-params-actions">
|
|
<button class="add-btn" onclick={() => saveExtraParams(provider)}>Lagre</button>
|
|
<button class="toggle-btn" onclick={() => { editingExtraParams = null; }}>Avbryt</button>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
{/each}
|
|
|
|
<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}>
|
|
{#each apiKeys as k}
|
|
<option value={k.name}>{k.label} ({k.name})</option>
|
|
{/each}
|
|
</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}
|
|
</div>
|
|
|
|
<div class="add-form">
|
|
<input type="text" placeholder="sidelinja/nytt-alias" bind:value={newAlias.alias} />
|
|
<input type="text" placeholder="Beskrivelse" bind:value={newAlias.description} />
|
|
<button class="add-btn" onclick={addAlias}>Nytt alias</button>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- Seksjon 3: Jobbruting -->
|
|
<section>
|
|
<h3>Jobbruting</h3>
|
|
<div class="table-list">
|
|
<div class="table-row table-row--header">
|
|
<span class="col-jobtype">Jobbtype</span>
|
|
<span class="col-alias">Modellalias</span>
|
|
<span class="col-desc">Beskrivelse</span>
|
|
<span class="col-status"></span>
|
|
</div>
|
|
|
|
{#each routing as route (route.job_type)}
|
|
<div class="table-row">
|
|
<span class="col-jobtype">{route.job_type}</span>
|
|
<span class="col-alias">
|
|
<select
|
|
value={route.alias_id}
|
|
onchange={(e) =>
|
|
updateRouting(route, (e.target as HTMLSelectElement).value)}
|
|
>
|
|
{#each aliases as a}
|
|
<option value={a.id}>{a.alias}</option>
|
|
{/each}
|
|
</select>
|
|
</span>
|
|
<span class="col-desc">{route.description ?? '\u2014'}</span>
|
|
<span class="col-status">
|
|
{#if saving === route.job_type}
|
|
<span class="status-saving">...</span>
|
|
{:else if saved === route.job_type}
|
|
<span class="status-saved">OK</span>
|
|
{/if}
|
|
</span>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
|
|
<div class="add-form">
|
|
<input type="text" placeholder="jobbtype" bind:value={newRoute.job_type} />
|
|
<select bind:value={newRoute.alias_id}>
|
|
<option value="">Velg alias...</option>
|
|
{#each aliases as a}
|
|
<option value={a.id}>{a.alias}</option>
|
|
{/each}
|
|
</select>
|
|
<input type="text" placeholder="Beskrivelse" bind:value={newRoute.description} />
|
|
<button class="add-btn" onclick={addRoute}>Legg til</button>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- Seksjon 4: System-prompts -->
|
|
<section>
|
|
<h3>System-prompts</h3>
|
|
<div class="table-list">
|
|
<div class="table-row table-row--header prompt-row">
|
|
<span class="col-action">Action</span>
|
|
<span class="col-label">Visningsnavn</span>
|
|
<span class="col-desc">Beskrivelse</span>
|
|
<span class="col-chars">Tegn</span>
|
|
<span class="col-updated">Oppdatert</span>
|
|
<span class="col-edit"></span>
|
|
</div>
|
|
|
|
{#each prompts as prompt (prompt.action)}
|
|
<div class="table-row prompt-row">
|
|
<span class="col-action">{prompt.action}</span>
|
|
<span class="col-label">{prompt.icon ?? ''} {prompt.label ?? '\u2014'}</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">
|
|
{#if saving === prompt.action}
|
|
<span class="status-saving">...</span>
|
|
{:else if saved === prompt.action}
|
|
<span class="status-saved">OK</span>
|
|
{:else}
|
|
<button class="toggle-btn" onclick={() => startEditPrompt(prompt)}>Rediger</button>
|
|
{/if}
|
|
</span>
|
|
</div>
|
|
|
|
{#if editingPrompt === prompt.action}
|
|
<div class="prompt-editor">
|
|
<div class="prompt-editor-meta">
|
|
<label>
|
|
<span class="prompt-meta-label">Ikon</span>
|
|
<input type="text" bind:value={editPromptIcon} placeholder="🧹" class="prompt-meta-input prompt-meta-input--icon" />
|
|
</label>
|
|
<label>
|
|
<span class="prompt-meta-label">Visningsnavn</span>
|
|
<input type="text" bind:value={editPromptLabel} placeholder="Vask tekst" class="prompt-meta-input" />
|
|
</label>
|
|
</div>
|
|
<textarea
|
|
bind:value={editPromptText}
|
|
rows="12"
|
|
></textarea>
|
|
<div class="prompt-editor-footer">
|
|
<span class="prompt-char-count">{editPromptText.length} tegn</span>
|
|
<div class="prompt-editor-actions">
|
|
<button class="toggle-btn" onclick={cancelEditPrompt}>Avbryt</button>
|
|
<button class="add-btn" onclick={() => savePrompt(prompt)}>Lagre</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
{/each}
|
|
</div>
|
|
</section>
|
|
|
|
<!-- Seksjon 5: Tokenforbruk -->
|
|
<section>
|
|
<div class="catalog-header">
|
|
<h3>Tokenforbruk (siste 30 dager)</h3>
|
|
{#if totalEstimatedCost !== null}
|
|
<span class="usage-total-cost">Estimert totalkostnad: ${totalEstimatedCost.toFixed(2)}</span>
|
|
{/if}
|
|
</div>
|
|
{#if usage.length === 0}
|
|
<p class="hint">Ingen AI-kall registrert ennå.</p>
|
|
{:else}
|
|
<div class="table-list">
|
|
<div class="table-row table-row--header usage-row">
|
|
<span>Alias</span>
|
|
<span>Modell</span>
|
|
<span>Prompt</span>
|
|
<span class="col-num">Kall</span>
|
|
<span class="col-num">Prompt-tok.</span>
|
|
<span class="col-num">Kompl.</span>
|
|
<span class="col-num">Totalt</span>
|
|
<span class="col-num">Est. $</span>
|
|
</div>
|
|
|
|
{#each sortedUsage as row}
|
|
{@const cost = estimateCost(row)}
|
|
<div class="table-row usage-row">
|
|
<span class="col-alias">{row.model_alias}</span>
|
|
<span class="col-model-actual">{row.model_actual ?? '\u2014'}</span>
|
|
<span class="col-action-usage">{row.action ?? '\u2014'}</span>
|
|
<span class="col-num">{row.call_count}</span>
|
|
<span class="col-num">{row.prompt_tokens.toLocaleString('nb-NO')}</span>
|
|
<span class="col-num">{row.completion_tokens.toLocaleString('nb-NO')}</span>
|
|
<span class="col-num">{row.total_tokens.toLocaleString('nb-NO')}</span>
|
|
<span class="col-num">{cost !== null ? `$${cost.toFixed(2)}` : '\u2014'}</span>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
{#if !catalogLoaded}
|
|
<p class="hint">Last inn modellkatalogen for å se estimerte kostnader.</p>
|
|
{/if}
|
|
{/if}
|
|
</section>
|
|
|
|
<!-- Seksjon 6: Konfigurasjon -->
|
|
<section>
|
|
<h3>Konfigurasjon</h3>
|
|
<div class="config-box">
|
|
<button class="generate-btn" onclick={() => generateConfig(false)} disabled={configLoading}>
|
|
Generer config.yaml
|
|
</button>
|
|
<button class="generate-btn generate-btn--restart" onclick={() => generateConfig(true)} disabled={configLoading}>
|
|
{configLoading ? 'Jobber...' : 'Generer + restart gateway'}
|
|
</button>
|
|
{#if configMsg}
|
|
<span class="config-msg">{configMsg}</span>
|
|
{/if}
|
|
<p class="hint">Genererer LiteLLM config.yaml fra databasen. «Generer + restart» aktiverer endringene umiddelbart.</p>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
|
|
<style>
|
|
.admin-ai {
|
|
max-width: 960px;
|
|
}
|
|
|
|
.header {
|
|
display: flex;
|
|
align-items: baseline;
|
|
gap: 1rem;
|
|
margin-bottom: 0.75rem;
|
|
}
|
|
|
|
h2 {
|
|
font-size: 1.4rem;
|
|
}
|
|
|
|
h3 {
|
|
font-size: 1.1rem;
|
|
margin-bottom: 0.75rem;
|
|
}
|
|
|
|
.header-stats {
|
|
font-size: 0.8rem;
|
|
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;
|
|
cursor: pointer;
|
|
transition: opacity 0.15s;
|
|
}
|
|
|
|
.key-pill:hover {
|
|
opacity: 0.8;
|
|
}
|
|
|
|
.key-pill--on {
|
|
background: #0d3320;
|
|
border: 1px solid #166534;
|
|
color: #4ade80;
|
|
}
|
|
|
|
.key-pill--off {
|
|
background: #1e1e2e;
|
|
border: 1px solid #3b3b52;
|
|
color: #8b92a5;
|
|
}
|
|
|
|
.key-pill--missing {
|
|
background: #3b1219;
|
|
border: 1px solid #6b2028;
|
|
color: #f87171;
|
|
}
|
|
|
|
.key-pill--add {
|
|
background: transparent;
|
|
border: 1px dashed #3b3b52;
|
|
color: #8b92a5;
|
|
}
|
|
|
|
.key-pill--add:hover {
|
|
border-color: #8b92a5;
|
|
color: #cdd6f4;
|
|
}
|
|
|
|
.key-manage-row {
|
|
display: flex;
|
|
gap: 0.5rem;
|
|
margin-bottom: 1rem;
|
|
align-items: center;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.key-input {
|
|
background: #181825;
|
|
border: 1px solid #2d3148;
|
|
color: #cdd6f4;
|
|
padding: 0.3rem 0.5rem;
|
|
border-radius: 4px;
|
|
font-size: 0.8rem;
|
|
flex: 1;
|
|
min-width: 150px;
|
|
}
|
|
|
|
.key-input--short {
|
|
flex: 0.5;
|
|
min-width: 100px;
|
|
}
|
|
|
|
.key-details {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 1px;
|
|
margin-bottom: 1.5rem;
|
|
}
|
|
|
|
.key-detail-row {
|
|
background: #1e1e2e;
|
|
border-radius: 4px;
|
|
}
|
|
|
|
.key-detail-toggle {
|
|
display: flex;
|
|
gap: 1rem;
|
|
align-items: center;
|
|
width: 100%;
|
|
padding: 0.4rem 0.6rem;
|
|
background: none;
|
|
border: none;
|
|
color: #cdd6f4;
|
|
font-size: 0.8rem;
|
|
cursor: pointer;
|
|
text-align: left;
|
|
}
|
|
|
|
.key-detail-toggle:hover {
|
|
background: #262640;
|
|
}
|
|
|
|
.key-detail-label {
|
|
font-weight: 600;
|
|
min-width: 80px;
|
|
}
|
|
|
|
.key-detail-env {
|
|
color: #8b92a5;
|
|
font-family: monospace;
|
|
font-size: 0.75rem;
|
|
flex: 1;
|
|
}
|
|
|
|
.key-detail-source {
|
|
font-size: 0.7rem;
|
|
color: #6c7086;
|
|
}
|
|
|
|
.key-detail-edit {
|
|
padding: 0.5rem 0.6rem;
|
|
border-top: 1px solid #2d3148;
|
|
}
|
|
|
|
.key-detail-actions {
|
|
display: flex;
|
|
gap: 0.5rem;
|
|
margin-top: 0.4rem;
|
|
align-items: center;
|
|
}
|
|
|
|
section {
|
|
margin-bottom: 2rem;
|
|
}
|
|
|
|
.error-msg {
|
|
background: #3b1219;
|
|
border: 1px solid #6b2028;
|
|
color: #f87171;
|
|
padding: 0.5rem 0.75rem;
|
|
border-radius: 6px;
|
|
margin-bottom: 1rem;
|
|
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;
|
|
}
|
|
|
|
.catalog-add-row {
|
|
display: flex;
|
|
gap: 0.5rem;
|
|
align-items: center;
|
|
padding: 0.5rem 0.75rem;
|
|
background: #1a1d2e;
|
|
border-left: 3px solid #4ade80;
|
|
font-size: 0.8rem;
|
|
}
|
|
|
|
.catalog-add-row select {
|
|
font-size: 0.75rem;
|
|
padding: 0.2rem 0.3rem;
|
|
}
|
|
|
|
.catalog-add-label {
|
|
color: #8b92a5;
|
|
white-space: nowrap;
|
|
font-size: 0.75rem;
|
|
}
|
|
|
|
/* Alias table */
|
|
.table-list {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 1px;
|
|
background: #2d3148;
|
|
border: 1px solid #2d3148;
|
|
border-radius: 6px;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.table-row {
|
|
display: grid;
|
|
grid-template-columns: 2fr 2fr 1fr 60px 40px;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
padding: 0.5rem 0.75rem;
|
|
background: #161822;
|
|
font-size: 0.8rem;
|
|
}
|
|
|
|
.table-row--header {
|
|
background: #1a1d2e;
|
|
font-weight: 600;
|
|
color: #8b92a5;
|
|
font-size: 0.7rem;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.05em;
|
|
}
|
|
|
|
.table-row--inactive {
|
|
opacity: 0.5;
|
|
}
|
|
|
|
.col-alias {
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.alias-model {
|
|
display: block;
|
|
font-size: 0.75em;
|
|
color: #8b92a5;
|
|
font-weight: normal;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
|
|
.clickable {
|
|
cursor: pointer;
|
|
text-decoration: underline;
|
|
text-decoration-style: dotted;
|
|
text-underline-offset: 3px;
|
|
}
|
|
|
|
.col-desc {
|
|
color: #8b92a5;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.col-providers, .col-active, .col-status {
|
|
text-align: center;
|
|
}
|
|
|
|
/* Alias edit */
|
|
.alias-edit-row {
|
|
grid-template-columns: 2fr 2fr 1fr auto 40px;
|
|
}
|
|
|
|
.alias-edit-input {
|
|
width: 100%;
|
|
}
|
|
|
|
.alias-edit-actions {
|
|
display: flex;
|
|
gap: 0.35rem;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.alias-action-group {
|
|
display: flex;
|
|
gap: 0.25rem;
|
|
justify-content: center;
|
|
}
|
|
|
|
.col-num {
|
|
text-align: right;
|
|
font-variant-numeric: tabular-nums;
|
|
}
|
|
|
|
.col-jobtype {
|
|
font-family: monospace;
|
|
}
|
|
|
|
.col-pri {
|
|
color: #8b92a5;
|
|
font-size: 0.75rem;
|
|
width: 60px;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.25rem;
|
|
}
|
|
|
|
.pri-arrows {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0;
|
|
}
|
|
|
|
.pri-btn {
|
|
background: none;
|
|
border: none;
|
|
color: #6c7086;
|
|
font-size: 0.55rem;
|
|
cursor: pointer;
|
|
padding: 0;
|
|
line-height: 1;
|
|
}
|
|
|
|
.pri-btn:hover:not(:disabled) {
|
|
color: #cdd6f4;
|
|
}
|
|
|
|
.pri-btn:disabled {
|
|
opacity: 0.2;
|
|
cursor: default;
|
|
}
|
|
|
|
.col-model {
|
|
font-family: monospace;
|
|
font-size: 0.75rem;
|
|
}
|
|
|
|
.col-key {
|
|
font-size: 0.75rem;
|
|
color: #8b92a5;
|
|
}
|
|
|
|
/* 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-alias {
|
|
display: grid;
|
|
grid-template-columns: 30px 2fr 1fr auto 50px 50px 40px;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
padding: 0.35rem 0;
|
|
font-size: 0.8rem;
|
|
}
|
|
|
|
.provider-row--inactive {
|
|
opacity: 0.5;
|
|
}
|
|
|
|
.provider-row--key-off {
|
|
opacity: 0.4;
|
|
border-left: 2px solid #6b2028;
|
|
padding-left: 0.5rem;
|
|
}
|
|
|
|
.col-key--disabled {
|
|
color: #f87171;
|
|
font-style: italic;
|
|
}
|
|
|
|
.provider-row--add {
|
|
padding-top: 0.5rem;
|
|
border-top: 1px solid #2d3148;
|
|
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;
|
|
}
|
|
|
|
/* Extra params */
|
|
.col-extra-pills {
|
|
display: flex;
|
|
gap: 0.25rem;
|
|
align-items: center;
|
|
}
|
|
|
|
.extra-pill {
|
|
font-size: 0.6rem;
|
|
padding: 0.1rem 0.4rem;
|
|
border-radius: 9999px;
|
|
border: 1px solid #2d3148;
|
|
background: #1a1d2e;
|
|
color: #8b92a5;
|
|
cursor: pointer;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.extra-pill:hover {
|
|
border-color: #3b82f6;
|
|
}
|
|
|
|
.extra-pill--on {
|
|
background: #0d3320;
|
|
border-color: #166534;
|
|
color: #4ade80;
|
|
}
|
|
|
|
.extra-pill--custom {
|
|
background: #2d1f4e;
|
|
border-color: #6d4aaa;
|
|
color: #c4b5fd;
|
|
cursor: default;
|
|
}
|
|
|
|
.extra-edit-btn {
|
|
background: none;
|
|
border: none;
|
|
color: #8b92a5;
|
|
font-size: 0.75rem;
|
|
cursor: pointer;
|
|
padding: 0 0.2rem;
|
|
}
|
|
|
|
.extra-edit-btn:hover {
|
|
color: #e1e4e8;
|
|
}
|
|
|
|
.extra-params-editor {
|
|
background: #161822;
|
|
border: 1px solid #3b82f6;
|
|
border-radius: 6px;
|
|
padding: 0.5rem;
|
|
margin: 0.35rem 0;
|
|
}
|
|
|
|
.extra-params-editor textarea {
|
|
width: 100%;
|
|
background: #0f1117;
|
|
border: 1px solid #2d3148;
|
|
border-radius: 4px;
|
|
color: #e1e4e8;
|
|
padding: 0.4rem;
|
|
font-size: 0.75rem;
|
|
font-family: monospace;
|
|
resize: vertical;
|
|
}
|
|
|
|
.extra-params-editor textarea:focus {
|
|
outline: none;
|
|
border-color: #3b82f6;
|
|
}
|
|
|
|
.extra-params-actions {
|
|
display: flex;
|
|
gap: 0.35rem;
|
|
margin-top: 0.35rem;
|
|
}
|
|
|
|
/* Buttons */
|
|
.toggle-btn {
|
|
background: #1a1d2e;
|
|
border: 1px solid #2d3148;
|
|
border-radius: 4px;
|
|
color: #e1e4e8;
|
|
padding: 0.15rem 0.5rem;
|
|
font-size: 0.7rem;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.toggle-btn:hover {
|
|
border-color: #3b82f6;
|
|
}
|
|
|
|
.toggle-btn:disabled {
|
|
opacity: 0.5;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.delete-btn {
|
|
background: none;
|
|
border: none;
|
|
color: #6b2028;
|
|
font-size: 0.7rem;
|
|
cursor: pointer;
|
|
padding: 0.15rem 0.35rem;
|
|
}
|
|
|
|
.delete-btn:hover {
|
|
color: #f87171;
|
|
}
|
|
|
|
.add-btn {
|
|
background: #1a1d2e;
|
|
border: 1px solid #2d3148;
|
|
border-radius: 4px;
|
|
color: #e1e4e8;
|
|
padding: 0.3rem 0.75rem;
|
|
font-size: 0.75rem;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.add-btn:hover {
|
|
border-color: #3b82f6;
|
|
}
|
|
|
|
.generate-btn {
|
|
background: #1e3a5f;
|
|
border: 1px solid #3b82f6;
|
|
border-radius: 6px;
|
|
color: #e1e4e8;
|
|
padding: 0.5rem 1rem;
|
|
font-size: 0.85rem;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.generate-btn:hover:not(:disabled) {
|
|
background: #264b7a;
|
|
}
|
|
|
|
.generate-btn:disabled {
|
|
opacity: 0.5;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.generate-btn--restart {
|
|
background: #1e4f3a;
|
|
border-color: #22c55e;
|
|
}
|
|
|
|
.generate-btn--restart:hover:not(:disabled) {
|
|
background: #276b4f;
|
|
}
|
|
|
|
/* Forms */
|
|
.add-form {
|
|
display: flex;
|
|
gap: 0.5rem;
|
|
margin-top: 0.75rem;
|
|
}
|
|
|
|
input[type='text'] {
|
|
background: #0f1117;
|
|
border: 1px solid #2d3148;
|
|
border-radius: 4px;
|
|
color: #e1e4e8;
|
|
padding: 0.3rem 0.5rem;
|
|
font-size: 0.8rem;
|
|
flex: 1;
|
|
}
|
|
|
|
select {
|
|
background: #0f1117;
|
|
border: 1px solid #2d3148;
|
|
border-radius: 4px;
|
|
color: #e1e4e8;
|
|
padding: 0.3rem 0.5rem;
|
|
font-size: 0.8rem;
|
|
}
|
|
|
|
input:focus,
|
|
select:focus {
|
|
outline: none;
|
|
border-color: #3b82f6;
|
|
}
|
|
|
|
.status-saving {
|
|
color: #8b92a5;
|
|
}
|
|
|
|
.status-saved {
|
|
color: #4ade80;
|
|
}
|
|
|
|
.config-box {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 1rem;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.config-msg {
|
|
color: #4ade80;
|
|
font-size: 0.85rem;
|
|
}
|
|
|
|
.hint {
|
|
color: #8b92a5;
|
|
font-size: 0.8rem;
|
|
font-style: italic;
|
|
}
|
|
|
|
/* Usage/tokenforbruk */
|
|
.usage-row {
|
|
grid-template-columns: 1fr 2fr 1fr 60px 80px 80px 80px 60px;
|
|
}
|
|
|
|
.col-action-usage {
|
|
font-size: 0.75rem;
|
|
color: #8b92a5;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.col-model-actual {
|
|
font-family: monospace;
|
|
font-size: 0.7rem;
|
|
color: #8b92a5;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.usage-total-cost {
|
|
font-size: 0.85rem;
|
|
color: #4ade80;
|
|
font-weight: 600;
|
|
}
|
|
|
|
/* Prompt-seksjon */
|
|
.prompt-row {
|
|
grid-template-columns: 1.2fr 1.2fr 2fr 60px 80px 70px;
|
|
}
|
|
|
|
.col-label {
|
|
font-size: 0.85rem;
|
|
}
|
|
|
|
.col-action {
|
|
font-family: monospace;
|
|
}
|
|
|
|
.col-chars {
|
|
text-align: right;
|
|
font-variant-numeric: tabular-nums;
|
|
color: #8b92a5;
|
|
font-size: 0.75rem;
|
|
}
|
|
|
|
.col-updated {
|
|
color: #8b92a5;
|
|
font-size: 0.75rem;
|
|
}
|
|
|
|
.col-edit {
|
|
text-align: center;
|
|
}
|
|
|
|
.prompt-editor {
|
|
background: #0f1117;
|
|
padding: 0.75rem;
|
|
border-bottom: 1px solid #2d3148;
|
|
}
|
|
|
|
.prompt-editor-meta {
|
|
display: flex;
|
|
gap: 0.75rem;
|
|
margin-bottom: 0.5rem;
|
|
}
|
|
|
|
.prompt-editor-meta label {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.35rem;
|
|
}
|
|
|
|
.prompt-meta-label {
|
|
font-size: 0.75rem;
|
|
color: #8b92a5;
|
|
}
|
|
|
|
.prompt-meta-input {
|
|
background: #161822;
|
|
border: 1px solid #2d3148;
|
|
border-radius: 4px;
|
|
color: #e1e4e8;
|
|
padding: 0.25rem 0.4rem;
|
|
font-size: 0.8rem;
|
|
width: 160px;
|
|
}
|
|
|
|
.prompt-meta-input--icon {
|
|
width: 50px;
|
|
text-align: center;
|
|
}
|
|
|
|
.prompt-meta-input:focus {
|
|
outline: none;
|
|
border-color: #3b82f6;
|
|
}
|
|
|
|
.prompt-editor textarea {
|
|
width: 100%;
|
|
background: #161822;
|
|
border: 1px solid #2d3148;
|
|
border-radius: 4px;
|
|
color: #e1e4e8;
|
|
padding: 0.5rem;
|
|
font-size: 0.8rem;
|
|
font-family: monospace;
|
|
line-height: 1.5;
|
|
resize: vertical;
|
|
}
|
|
|
|
.prompt-editor textarea:focus {
|
|
outline: none;
|
|
border-color: #3b82f6;
|
|
}
|
|
|
|
.prompt-editor-footer {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-top: 0.5rem;
|
|
}
|
|
|
|
.prompt-char-count {
|
|
color: #8b92a5;
|
|
font-size: 0.75rem;
|
|
}
|
|
|
|
.prompt-editor-actions {
|
|
display: flex;
|
|
gap: 0.5rem;
|
|
}
|
|
</style>
|