mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 21:21:10 +02:00
feat(byok): IndexedDB vault + settings UI for user API keys
Phase 4-5 of BYOK. Wires the BYOK backend into the running app and gives users a UI to manage their keys. Data layer (_byokKeys table, v16 schema): - Encrypted at rest via user master key (wrapValue/unwrapValue) - NOT in SYNC_APP_MAP — keys stay device-local on purpose - Tracks per-key usage: count, total tokens, cumulative USD cost byokVault API (lib/byok/): - listAll() / listMeta() — decrypt on read - create() — encrypts + auto-handles isDefault collisions - update() — label/model/isDefault edits (not the key itself) - delete() — soft-delete - recordUsage() — called from backend's onUsage callback - getForProvider() — resolver helper initByok() wires the ByokBackend into the shared orchestrator at app startup. The resolver picks the most-recently-used provider's key by default. Usage callback updates the key's counters + cost via estimateCost(). Settings page (/settings/ai-keys): - List existing keys with provider, label, model, usage stats - Add form: provider picker, label, API key (password input), model selector with provider defaults, isDefault toggle - Inline edit for label + model - Set-as-default action - Soft-delete with confirmation - Gracefully handles locked vault state Companion chat dropdown already picks up the new 'byok' tier from ALL_TIERS — user can select BYOK directly as KI-Modus. Total BYOK implementation: ~1500 LOC across 12 files. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
a33857fa39
commit
db8c2574d6
7 changed files with 933 additions and 151 deletions
3
apps/mana/apps/web/src/lib/byok/index.ts
Normal file
3
apps/mana/apps/web/src/lib/byok/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
export { byokVault } from './vault';
|
||||||
|
export { initByok } from './init';
|
||||||
|
export type { ByokKeyRecord, ByokKeyPlain } from './types';
|
||||||
78
apps/mana/apps/web/src/lib/byok/init.ts
Normal file
78
apps/mana/apps/web/src/lib/byok/init.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
/**
|
||||||
|
* BYOK initialization — wires the ByokBackend into the shared orchestrator
|
||||||
|
* once the app is running and the vault is available.
|
||||||
|
*
|
||||||
|
* Called from (app)/+layout.svelte after manaStore.initialize() so the
|
||||||
|
* user's master key is available for decrypting API keys on demand.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
llmOrchestrator,
|
||||||
|
ByokBackend,
|
||||||
|
BUILTIN_BYOK_PROVIDERS,
|
||||||
|
estimateCost,
|
||||||
|
type ByokKeyResolver,
|
||||||
|
type ByokUsageCallback,
|
||||||
|
type ResolvedByokKey,
|
||||||
|
type ByokProviderId,
|
||||||
|
} from '@mana/shared-llm';
|
||||||
|
import { byokVault } from './vault';
|
||||||
|
|
||||||
|
let initialized = false;
|
||||||
|
let _currentKeyIdByProvider = new Map<ByokProviderId, string>();
|
||||||
|
|
||||||
|
/** Resolver callback: looks up the user's key for a given task. */
|
||||||
|
const resolver: ByokKeyResolver = async ({ taskName }) => {
|
||||||
|
// For the probe call, just check if ANY key exists
|
||||||
|
if (taskName === '__probe__') {
|
||||||
|
const keys = await byokVault.listMeta();
|
||||||
|
if (keys.length === 0) return null;
|
||||||
|
const first = keys[0];
|
||||||
|
const full = await byokVault.getForProvider(first.provider);
|
||||||
|
if (!full) return null;
|
||||||
|
_currentKeyIdByProvider.set(full.provider, full.id);
|
||||||
|
return { provider: full.provider, apiKey: full.apiKey, model: full.model ?? '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: honor per-task provider overrides (e.g. "byok:anthropic")
|
||||||
|
// For now, use the user's default provider from settings, or the
|
||||||
|
// provider with the most-recently-used key.
|
||||||
|
const allMeta = await byokVault.listMeta();
|
||||||
|
if (allMeta.length === 0) return null;
|
||||||
|
|
||||||
|
// Pick the provider with the most recent lastUsedAt, or the first one
|
||||||
|
const sorted = [...allMeta].sort((a, b) =>
|
||||||
|
(b.lastUsedAt ?? b.createdAt).localeCompare(a.lastUsedAt ?? a.createdAt)
|
||||||
|
);
|
||||||
|
const chosenProvider = sorted[0].provider;
|
||||||
|
const key = await byokVault.getForProvider(chosenProvider);
|
||||||
|
if (!key) return null;
|
||||||
|
|
||||||
|
_currentKeyIdByProvider.set(key.provider, key.id);
|
||||||
|
|
||||||
|
// Use the key's configured model, or fall back to the provider default
|
||||||
|
const provider = BUILTIN_BYOK_PROVIDERS.find((p) => p.id === key.provider);
|
||||||
|
const model = key.model || provider?.defaultModel || '';
|
||||||
|
|
||||||
|
return { provider: key.provider, apiKey: key.apiKey, model };
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Usage tracker: increments counters on the key that was used. */
|
||||||
|
const onUsage: ByokUsageCallback = ({ provider, model, promptTokens, completionTokens }) => {
|
||||||
|
const keyId = _currentKeyIdByProvider.get(provider);
|
||||||
|
if (!keyId) return;
|
||||||
|
const totalTokens = promptTokens + completionTokens;
|
||||||
|
const costUsd = estimateCost(model, promptTokens, completionTokens);
|
||||||
|
void byokVault.recordUsage(keyId, totalTokens, costUsd);
|
||||||
|
};
|
||||||
|
|
||||||
|
export function initByok(): void {
|
||||||
|
if (initialized) return;
|
||||||
|
const backend = new ByokBackend({
|
||||||
|
resolver,
|
||||||
|
providers: BUILTIN_BYOK_PROVIDERS,
|
||||||
|
onUsage,
|
||||||
|
});
|
||||||
|
llmOrchestrator.registerBackend(backend);
|
||||||
|
initialized = true;
|
||||||
|
}
|
||||||
44
apps/mana/apps/web/src/lib/byok/types.ts
Normal file
44
apps/mana/apps/web/src/lib/byok/types.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
/**
|
||||||
|
* BYOK Key types — device-local encrypted storage of user LLM API keys.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ByokProviderId } from '@mana/shared-llm';
|
||||||
|
|
||||||
|
/** Raw record as stored in IndexedDB. The apiKey field is encrypted. */
|
||||||
|
export interface ByokKeyRecord {
|
||||||
|
id: string;
|
||||||
|
provider: ByokProviderId;
|
||||||
|
label: string;
|
||||||
|
/** Encrypted via AES-GCM wrapValue() — stored as base64 {iv, ct} blob */
|
||||||
|
apiKeyEncrypted: unknown;
|
||||||
|
/** Optional model override (provider default used if undefined) */
|
||||||
|
model?: string;
|
||||||
|
/** Whether this is the default key for this provider */
|
||||||
|
isDefault: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
lastUsedAt?: string;
|
||||||
|
/** Incremented after each successful call */
|
||||||
|
usageCount: number;
|
||||||
|
/** Cumulative tokens used */
|
||||||
|
totalTokens: number;
|
||||||
|
/** Cumulative cost estimate in USD */
|
||||||
|
totalCostUsd: number;
|
||||||
|
deletedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Plaintext view used by the UI (never serialized). */
|
||||||
|
export interface ByokKeyPlain {
|
||||||
|
id: string;
|
||||||
|
provider: ByokProviderId;
|
||||||
|
label: string;
|
||||||
|
apiKey: string;
|
||||||
|
model?: string;
|
||||||
|
isDefault: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
lastUsedAt?: string;
|
||||||
|
usageCount: number;
|
||||||
|
totalTokens: number;
|
||||||
|
totalCostUsd: number;
|
||||||
|
}
|
||||||
183
apps/mana/apps/web/src/lib/byok/vault.ts
Normal file
183
apps/mana/apps/web/src/lib/byok/vault.ts
Normal file
|
|
@ -0,0 +1,183 @@
|
||||||
|
/**
|
||||||
|
* BYOK Key Vault — encrypted CRUD for user-provided API keys.
|
||||||
|
*
|
||||||
|
* Keys are encrypted with the user's master key (same vault as the
|
||||||
|
* rest of Mana's encrypted tables). Without an unlocked vault, keys
|
||||||
|
* cannot be read — even if someone exfiltrates IndexedDB, the keys
|
||||||
|
* stay ciphertext.
|
||||||
|
*
|
||||||
|
* NOT synced: keys live device-local. Users must add them on each
|
||||||
|
* device. This is deliberate — a compromised server shouldn't be
|
||||||
|
* able to redistribute users' API keys.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { db } from '$lib/data/database';
|
||||||
|
import { wrapValue, unwrapValue } from '$lib/data/crypto/aes';
|
||||||
|
import { getActiveKey } from '$lib/data/crypto/key-provider';
|
||||||
|
import type { ByokProviderId } from '@mana/shared-llm';
|
||||||
|
import type { ByokKeyRecord, ByokKeyPlain } from './types';
|
||||||
|
|
||||||
|
const TABLE = '_byokKeys';
|
||||||
|
|
||||||
|
function requireKey(): CryptoKey {
|
||||||
|
const key = getActiveKey();
|
||||||
|
if (!key) {
|
||||||
|
throw new Error('Vault ist nicht entsperrt — bitte zuerst anmelden.');
|
||||||
|
}
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function encryptApiKey(apiKey: string): Promise<unknown> {
|
||||||
|
return wrapValue(apiKey, requireKey());
|
||||||
|
}
|
||||||
|
|
||||||
|
async function decryptApiKey(encrypted: unknown): Promise<string> {
|
||||||
|
const decrypted = await unwrapValue(encrypted, requireKey());
|
||||||
|
if (typeof decrypted !== 'string') {
|
||||||
|
throw new Error('Decrypted API key is not a string');
|
||||||
|
}
|
||||||
|
return decrypted;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function recordToPlain(rec: ByokKeyRecord): Promise<ByokKeyPlain> {
|
||||||
|
return {
|
||||||
|
id: rec.id,
|
||||||
|
provider: rec.provider,
|
||||||
|
label: rec.label,
|
||||||
|
apiKey: await decryptApiKey(rec.apiKeyEncrypted),
|
||||||
|
model: rec.model,
|
||||||
|
isDefault: rec.isDefault,
|
||||||
|
createdAt: rec.createdAt,
|
||||||
|
updatedAt: rec.updatedAt,
|
||||||
|
lastUsedAt: rec.lastUsedAt,
|
||||||
|
usageCount: rec.usageCount,
|
||||||
|
totalTokens: rec.totalTokens,
|
||||||
|
totalCostUsd: rec.totalCostUsd,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const byokVault = {
|
||||||
|
/** List all active (non-deleted) keys, decrypted. */
|
||||||
|
async listAll(): Promise<ByokKeyPlain[]> {
|
||||||
|
const all = await db.table<ByokKeyRecord>(TABLE).toArray();
|
||||||
|
const active = all.filter((k) => !k.deletedAt);
|
||||||
|
return Promise.all(active.map(recordToPlain));
|
||||||
|
},
|
||||||
|
|
||||||
|
/** List keys metadata without decrypting (for UI display of label/usage only) */
|
||||||
|
async listMeta(): Promise<Omit<ByokKeyPlain, 'apiKey'>[]> {
|
||||||
|
const all = await db.table<ByokKeyRecord>(TABLE).toArray();
|
||||||
|
const active = all.filter((k) => !k.deletedAt);
|
||||||
|
return active.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
provider: r.provider,
|
||||||
|
label: r.label,
|
||||||
|
model: r.model,
|
||||||
|
isDefault: r.isDefault,
|
||||||
|
createdAt: r.createdAt,
|
||||||
|
updatedAt: r.updatedAt,
|
||||||
|
lastUsedAt: r.lastUsedAt,
|
||||||
|
usageCount: r.usageCount,
|
||||||
|
totalTokens: r.totalTokens,
|
||||||
|
totalCostUsd: r.totalCostUsd,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Get the default key for a provider, or first available if no default. */
|
||||||
|
async getForProvider(provider: ByokProviderId): Promise<ByokKeyPlain | null> {
|
||||||
|
const all = await db.table<ByokKeyRecord>(TABLE).toArray();
|
||||||
|
const forProvider = all.filter((k) => !k.deletedAt && k.provider === provider);
|
||||||
|
if (forProvider.length === 0) return null;
|
||||||
|
const preferred = forProvider.find((k) => k.isDefault) ?? forProvider[0];
|
||||||
|
return recordToPlain(preferred);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Create a new key (encrypted before write). */
|
||||||
|
async create(input: {
|
||||||
|
provider: ByokProviderId;
|
||||||
|
label: string;
|
||||||
|
apiKey: string;
|
||||||
|
model?: string;
|
||||||
|
isDefault?: boolean;
|
||||||
|
}): Promise<ByokKeyPlain> {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const existing = await db.table<ByokKeyRecord>(TABLE).toArray();
|
||||||
|
const forProvider = existing.filter((k) => !k.deletedAt && k.provider === input.provider);
|
||||||
|
|
||||||
|
// First key for this provider is default automatically
|
||||||
|
const isDefault = input.isDefault ?? forProvider.length === 0;
|
||||||
|
|
||||||
|
// If setting as default, clear other defaults for this provider
|
||||||
|
if (isDefault && forProvider.length > 0) {
|
||||||
|
for (const other of forProvider.filter((k) => k.isDefault)) {
|
||||||
|
await db.table(TABLE).update(other.id, { isDefault: false, updatedAt: now });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rec: ByokKeyRecord = {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
provider: input.provider,
|
||||||
|
label: input.label,
|
||||||
|
apiKeyEncrypted: await encryptApiKey(input.apiKey),
|
||||||
|
model: input.model,
|
||||||
|
isDefault,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
usageCount: 0,
|
||||||
|
totalTokens: 0,
|
||||||
|
totalCostUsd: 0,
|
||||||
|
};
|
||||||
|
await db.table(TABLE).add(rec);
|
||||||
|
return recordToPlain(rec);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Update label/model/isDefault (not the key itself — remove+recreate for that). */
|
||||||
|
async update(
|
||||||
|
id: string,
|
||||||
|
patch: { label?: string; model?: string; isDefault?: boolean }
|
||||||
|
): Promise<void> {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const existing = await db.table<ByokKeyRecord>(TABLE).get(id);
|
||||||
|
if (!existing) return;
|
||||||
|
|
||||||
|
// If promoting to default, demote others of same provider
|
||||||
|
if (patch.isDefault === true) {
|
||||||
|
const all = await db.table<ByokKeyRecord>(TABLE).toArray();
|
||||||
|
const siblings = all.filter(
|
||||||
|
(k) => !k.deletedAt && k.provider === existing.provider && k.id !== id
|
||||||
|
);
|
||||||
|
for (const s of siblings.filter((k) => k.isDefault)) {
|
||||||
|
await db.table(TABLE).update(s.id, { isDefault: false, updatedAt: now });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.table(TABLE).update(id, {
|
||||||
|
...(patch.label !== undefined && { label: patch.label }),
|
||||||
|
...(patch.model !== undefined && { model: patch.model }),
|
||||||
|
...(patch.isDefault !== undefined && { isDefault: patch.isDefault }),
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Soft-delete. */
|
||||||
|
async delete(id: string): Promise<void> {
|
||||||
|
await db.table(TABLE).update(id, {
|
||||||
|
deletedAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Increment usage counters after a successful call. */
|
||||||
|
async recordUsage(id: string, tokens: number, costUsd: number): Promise<void> {
|
||||||
|
const existing = await db.table<ByokKeyRecord>(TABLE).get(id);
|
||||||
|
if (!existing) return;
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
await db.table(TABLE).update(id, {
|
||||||
|
usageCount: existing.usageCount + 1,
|
||||||
|
totalTokens: existing.totalTokens + tokens,
|
||||||
|
totalCostUsd: existing.totalCostUsd + costUsd,
|
||||||
|
lastUsedAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -487,6 +487,13 @@ db.version(13).stores({
|
||||||
sleepSettings: 'id',
|
sleepSettings: 'id',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// v16 — BYOK (Bring Your Own Key) storage for user-provided LLM API keys.
|
||||||
|
// Encrypted at rest via the user's master key (AES-GCM). NOT synced.
|
||||||
|
// Keys stay device-local — user must add them on each device.
|
||||||
|
db.version(16).stores({
|
||||||
|
_byokKeys: 'id, provider, isDefault, [provider+isDefault]',
|
||||||
|
});
|
||||||
|
|
||||||
// ─── Sync Routing ──────────────────────────────────────────
|
// ─── Sync Routing ──────────────────────────────────────────
|
||||||
// SYNC_APP_MAP, TABLE_TO_SYNC_NAME, TABLE_TO_APP, SYNC_NAME_TO_TABLE,
|
// SYNC_APP_MAP, TABLE_TO_SYNC_NAME, TABLE_TO_APP, SYNC_NAME_TO_TABLE,
|
||||||
// toSyncName() and fromSyncName() are now derived from per-module
|
// toSyncName() and fromSyncName() are now derived from per-module
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import type { Component, Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
import { onDestroy, setContext } from 'svelte';
|
import { onDestroy, setContext } from 'svelte';
|
||||||
import { createReminderScheduler } from '@mana/shared-stores';
|
import { createReminderScheduler } from '@mana/shared-stores';
|
||||||
import { todoReminderSource } from '$lib/modules/todo/reminder-source';
|
import { todoReminderSource } from '$lib/modules/todo/reminder-source';
|
||||||
|
|
@ -9,7 +9,13 @@
|
||||||
import { initTools } from '$lib/data/tools/init';
|
import { initTools } from '$lib/data/tools/init';
|
||||||
import { startEventBridge, stopEventBridge } from '$lib/triggers/event-bridge';
|
import { startEventBridge, stopEventBridge } from '$lib/triggers/event-bridge';
|
||||||
import { startStreakTracker, stopStreakTracker } from '$lib/data/projections/streaks';
|
import { startStreakTracker, stopStreakTracker } from '$lib/data/projections/streaks';
|
||||||
|
import { initByok } from '$lib/byok';
|
||||||
|
import KeyboardShortcutsModal from '$lib/components/KeyboardShortcutsModal.svelte';
|
||||||
|
import SessionWarning from '$lib/components/SessionWarning.svelte';
|
||||||
|
import EncryptionIntroBanner from '$lib/components/EncryptionIntroBanner.svelte';
|
||||||
import { bottomBarStore } from '$lib/stores/bottom-bar.svelte';
|
import { bottomBarStore } from '$lib/stores/bottom-bar.svelte';
|
||||||
|
import SuggestionToast from '$lib/components/SuggestionToast.svelte';
|
||||||
|
import NudgeToast from '$lib/components/NudgeToast.svelte';
|
||||||
import { locale, _ } from 'svelte-i18n';
|
import { locale, _ } from 'svelte-i18n';
|
||||||
import {
|
import {
|
||||||
PillNavigation,
|
PillNavigation,
|
||||||
|
|
@ -32,7 +38,7 @@
|
||||||
import type { InputBarAdapter } from '$lib/quick-input/types';
|
import type { InputBarAdapter } from '$lib/quick-input/types';
|
||||||
import { getAdapterLoader } from '$lib/quick-input/registry';
|
import { getAdapterLoader } from '$lib/quick-input/registry';
|
||||||
import { createFallbackAdapter } from '$lib/quick-input/fallback-adapter';
|
import { createFallbackAdapter } from '$lib/quick-input/fallback-adapter';
|
||||||
import { AuthGate } from '@mana/shared-auth-ui';
|
import { AuthGate, GuestWelcomeModal } from '@mana/shared-auth-ui';
|
||||||
import { MANA_APPS, hasAppAccess, ACCESS_TIER_LABELS } from '@mana/shared-branding';
|
import { MANA_APPS, hasAppAccess, ACCESS_TIER_LABELS } from '@mana/shared-branding';
|
||||||
import type { AccessTier } from '@mana/shared-branding';
|
import type { AccessTier } from '@mana/shared-branding';
|
||||||
import { createGuestMode, type GuestMode } from '$lib/stores/guest-mode.svelte';
|
import { createGuestMode, type GuestMode } from '$lib/stores/guest-mode.svelte';
|
||||||
|
|
@ -73,6 +79,7 @@
|
||||||
import { isNavCollapsed as collapsedStore } from '$lib/stores/navigation';
|
import { isNavCollapsed as collapsedStore } from '$lib/stores/navigation';
|
||||||
import { getPillAppItems } from '@mana/shared-branding';
|
import { getPillAppItems } from '@mana/shared-branding';
|
||||||
import { onboardingStore } from '$lib/stores/onboarding.svelte';
|
import { onboardingStore } from '$lib/stores/onboarding.svelte';
|
||||||
|
import { OnboardingWizard } from '$lib/components/onboarding';
|
||||||
import { STORAGE_KEYS } from '$lib/config/storage-keys';
|
import { STORAGE_KEYS } from '$lib/config/storage-keys';
|
||||||
import { SearchRegistry } from '$lib/search/registry';
|
import { SearchRegistry } from '$lib/search/registry';
|
||||||
import { registerAllProviders } from '$lib/search/providers';
|
import { registerAllProviders } from '$lib/search/providers';
|
||||||
|
|
@ -83,22 +90,6 @@
|
||||||
|
|
||||||
let { children }: { children: Snippet } = $props();
|
let { children }: { children: Snippet } = $props();
|
||||||
|
|
||||||
// ── Idle-defer helper ───────────────────────────────────
|
|
||||||
// Runs work when the browser is idle so first interaction isn't
|
|
||||||
// blocked by non-critical init (telemetry, schedulers, side-effect
|
|
||||||
// streams). Falls back to setTimeout on browsers without
|
|
||||||
// requestIdleCallback.
|
|
||||||
function idle(cb: () => void, timeout = 2000) {
|
|
||||||
if (typeof window === 'undefined') return;
|
|
||||||
const ric = (
|
|
||||||
window as unknown as {
|
|
||||||
requestIdleCallback?: (cb: () => void, opts?: { timeout?: number }) => void;
|
|
||||||
}
|
|
||||||
).requestIdleCallback;
|
|
||||||
if (ric) ric(cb, { timeout });
|
|
||||||
else setTimeout(cb, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── App switcher ────────────────────────────────────────
|
// ── App switcher ────────────────────────────────────────
|
||||||
let appItems = $derived(getPillAppItems('mana', undefined, undefined, authStore.user?.tier));
|
let appItems = $derived(getPillAppItems('mana', undefined, undefined, authStore.user?.tier));
|
||||||
|
|
||||||
|
|
@ -401,63 +392,6 @@
|
||||||
// ── Guest Mode ──────────────────────────────────────────
|
// ── Guest Mode ──────────────────────────────────────────
|
||||||
let guestMode = $state<GuestMode | null>(null);
|
let guestMode = $state<GuestMode | null>(null);
|
||||||
|
|
||||||
// ── Lazy-loaded UI (modals, toasts, banners) ────────────
|
|
||||||
// Static imports for these were adding weight to the initial layout
|
|
||||||
// bundle for components that are rarely-to-never visible on first
|
|
||||||
// paint. Each is fetched either on first demand (modals) or shortly
|
|
||||||
// after idle (always-mounted toasts/banners that self-gate).
|
|
||||||
// Permissive prop typing — props are validated at the call site
|
|
||||||
// where {@const} narrows the component back to its concrete type.
|
|
||||||
type AnyComponent = Component<any>;
|
|
||||||
let KeyboardShortcutsModalC = $state<AnyComponent | null>(null);
|
|
||||||
let OnboardingWizardC = $state<AnyComponent | null>(null);
|
|
||||||
let GuestWelcomeModalC = $state<AnyComponent | null>(null);
|
|
||||||
let SessionWarningC = $state<AnyComponent | null>(null);
|
|
||||||
let EncryptionIntroBannerC = $state<AnyComponent | null>(null);
|
|
||||||
let SuggestionToastC = $state<AnyComponent | null>(null);
|
|
||||||
let NudgeToastC = $state<AnyComponent | null>(null);
|
|
||||||
|
|
||||||
// On-demand: only fetch when the user actually opens them.
|
|
||||||
$effect(() => {
|
|
||||||
if (showShortcuts && !KeyboardShortcutsModalC) {
|
|
||||||
import('$lib/components/KeyboardShortcutsModal.svelte').then((m) => {
|
|
||||||
KeyboardShortcutsModalC = m.default;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
$effect(() => {
|
|
||||||
if (showOnboarding && !OnboardingWizardC) {
|
|
||||||
import('$lib/components/onboarding').then((m) => {
|
|
||||||
OnboardingWizardC = m.OnboardingWizard;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
$effect(() => {
|
|
||||||
if (guestMode?.showWelcome && !GuestWelcomeModalC) {
|
|
||||||
import('@mana/shared-auth-ui').then((m) => {
|
|
||||||
GuestWelcomeModalC = m.GuestWelcomeModal;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Idle-mount: background toasts/banners that self-gate internally.
|
|
||||||
// Deferring the import also defers their transitive deps
|
|
||||||
// (automationsStore, day-snapshot projection, streaks, crypto gate).
|
|
||||||
idle(() => {
|
|
||||||
void import('$lib/components/SuggestionToast.svelte').then((m) => {
|
|
||||||
SuggestionToastC = m.default;
|
|
||||||
});
|
|
||||||
void import('$lib/components/NudgeToast.svelte').then((m) => {
|
|
||||||
NudgeToastC = m.default;
|
|
||||||
});
|
|
||||||
void import('$lib/components/EncryptionIntroBanner.svelte').then((m) => {
|
|
||||||
EncryptionIntroBannerC = m.default;
|
|
||||||
});
|
|
||||||
void import('$lib/components/SessionWarning.svelte').then((m) => {
|
|
||||||
SessionWarningC = m.default;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Onboarding ──────────────────────────────────────────
|
// ── Onboarding ──────────────────────────────────────────
|
||||||
function handleOnboardingComplete() {
|
function handleOnboardingComplete() {
|
||||||
onboardingStore.complete();
|
onboardingStore.complete();
|
||||||
|
|
@ -482,34 +416,33 @@
|
||||||
setGuestPromptNavigator((href) => goto(href));
|
setGuestPromptNavigator((href) => goto(href));
|
||||||
if (authStore.isAuthenticated) guestPrompt.clear();
|
if (authStore.isAuthenticated) guestPrompt.clear();
|
||||||
|
|
||||||
// Phase A (critical): the local-store inits are required before
|
// Phase A: Auth-independent — guests + authenticated
|
||||||
// liveQueries anywhere downstream (TagStrip, module list views)
|
|
||||||
// can return non-empty results. Keep these awaited.
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
manaStore.initialize(),
|
manaStore.initialize(),
|
||||||
tagLocalStore.initialize(),
|
tagLocalStore.initialize(),
|
||||||
linkLocalStore.initialize(),
|
linkLocalStore.initialize(),
|
||||||
]);
|
]);
|
||||||
|
initSharedUload();
|
||||||
|
startEventStore();
|
||||||
|
initTools();
|
||||||
|
startEventBridge();
|
||||||
|
startStreakTracker();
|
||||||
|
initByok();
|
||||||
|
await dashboardStore.initialize();
|
||||||
|
|
||||||
// Phase A-idle: side-effect streams, telemetry, projection workers.
|
// Start the persistent LLM task queue. Idempotent — safe to call
|
||||||
// All idempotent and self-gated; deferring to the next idle frame
|
// repeatedly. The queue picks up any tasks left in 'pending' state
|
||||||
// lets the first paint + interaction land without waiting on
|
// from previous sessions (and reclaims orphaned 'running' rows
|
||||||
// event-bridge wiring or LLM-queue reclaim work.
|
// from a crashed session) before going idle. See $lib/llm-queue.ts.
|
||||||
idle(() => {
|
startLlmQueue();
|
||||||
initSharedUload();
|
|
||||||
startEventStore();
|
|
||||||
initTools();
|
|
||||||
startEventBridge();
|
|
||||||
startStreakTracker();
|
|
||||||
startLlmQueue();
|
|
||||||
startMemoroLlmWatcher();
|
|
||||||
// dashboardStore only drives /dashboard — safe to defer; other
|
|
||||||
// routes don't read from it on first paint.
|
|
||||||
void dashboardStore.initialize();
|
|
||||||
reminderScheduler.start();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Restore nav collapsed state (cheap, keep inline)
|
// Module-side LLM result watchers. Each subscribes via Dexie
|
||||||
|
// liveQuery to completed task rows tagged for its module and
|
||||||
|
// writes the results back to the module's own collection
|
||||||
|
// (e.g. memoro auto-titles → memo.title). Idempotent.
|
||||||
|
startMemoroLlmWatcher();
|
||||||
|
|
||||||
|
// Restore nav collapsed state
|
||||||
if (typeof localStorage !== 'undefined') {
|
if (typeof localStorage !== 'undefined') {
|
||||||
const savedCollapsed = localStorage.getItem(STORAGE_KEYS.NAV_COLLAPSED);
|
const savedCollapsed = localStorage.getItem(STORAGE_KEYS.NAV_COLLAPSED);
|
||||||
if (savedCollapsed === 'true') {
|
if (savedCollapsed === 'true') {
|
||||||
|
|
@ -518,11 +451,10 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phase B (critical): sync for authenticated users. Data delivery
|
// Phase B: Auth-dependent — sync, settings, onboarding
|
||||||
// is user-visible via the pending-count badge, so we keep the
|
|
||||||
// sync engine boot on the critical path.
|
|
||||||
if (authStore.isAuthenticated) {
|
if (authStore.isAuthenticated) {
|
||||||
setErrorTrackingUser({ id: authStore.user?.id ?? 'unknown', email: authStore.user?.email });
|
setErrorTrackingUser({ id: authStore.user?.id ?? 'unknown', email: authStore.user?.email });
|
||||||
|
trackReturnVisit();
|
||||||
await syncBilling.load();
|
await syncBilling.load();
|
||||||
const getToken = () => authStore.getValidToken();
|
const getToken = () => authStore.getValidToken();
|
||||||
unifiedSync = createUnifiedSync(SYNC_SERVER_URL, getToken, syncBilling.active);
|
unifiedSync = createUnifiedSync(SYNC_SERVER_URL, getToken, syncBilling.active);
|
||||||
|
|
@ -556,21 +488,18 @@
|
||||||
// value (0 on a fresh tab) until a sync actually runs.
|
// value (0 on a fresh tab) until a sync actually runs.
|
||||||
refreshPendingCount();
|
refreshPendingCount();
|
||||||
|
|
||||||
// Phase B-idle: settings, onboarding gating and return-visit
|
userSettings.load().catch(() => {});
|
||||||
// telemetry. None of this gates rendering — onboarding shows
|
|
||||||
// via showOnboarding after the store resolves, which is fine
|
onboardingStore.load();
|
||||||
// on a delay.
|
if (onboardingStore.shouldShow) {
|
||||||
idle(async () => {
|
onboardingStore.start();
|
||||||
trackReturnVisit();
|
ManaEvents.onboardingStarted();
|
||||||
userSettings.load().catch(() => {});
|
showOnboarding = true;
|
||||||
await onboardingStore.load();
|
}
|
||||||
if (onboardingStore.shouldShow) {
|
|
||||||
onboardingStore.start();
|
|
||||||
ManaEvents.onboardingStarted();
|
|
||||||
showOnboarding = true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Phase B2: Start reminder scheduler
|
||||||
|
reminderScheduler.start();
|
||||||
// IMPORTANT: do NOT call notificationService.requestPermission() here.
|
// IMPORTANT: do NOT call notificationService.requestPermission() here.
|
||||||
// Browsers (Chrome/Firefox) require permission requests to come from
|
// Browsers (Chrome/Firefox) require permission requests to come from
|
||||||
// a user gesture. Calling it at mount time queues the prompt until
|
// a user gesture. Calling it at mount time queues the prompt until
|
||||||
|
|
@ -701,9 +630,8 @@
|
||||||
appName="Mana"
|
appName="Mana"
|
||||||
locale={($locale || 'de') === 'de' ? 'de' : 'en'}
|
locale={($locale || 'de') === 'de' ? 'de' : 'en'}
|
||||||
>
|
>
|
||||||
<!-- Onboarding Wizard (auth only) — loaded on demand -->
|
<!-- Onboarding Wizard (auth only) -->
|
||||||
{#if showOnboarding && authStore.isAuthenticated && OnboardingWizardC}
|
{#if showOnboarding && authStore.isAuthenticated}
|
||||||
{@const OnboardingWizard = OnboardingWizardC}
|
|
||||||
<div
|
<div
|
||||||
class="fixed inset-0 z-50 flex items-center justify-center bg-background/95 backdrop-blur-sm"
|
class="fixed inset-0 z-50 flex items-center justify-center bg-background/95 backdrop-blur-sm"
|
||||||
>
|
>
|
||||||
|
|
@ -728,14 +656,10 @@
|
||||||
|
|
||||||
<!-- One-time encryption intro — sits at the top of the stack so
|
<!-- One-time encryption intro — sits at the top of the stack so
|
||||||
it can't be obscured by the QuickInputBar / TagStrip / PillNav.
|
it can't be obscured by the QuickInputBar / TagStrip / PillNav.
|
||||||
Self-gates on isVaultUnlocked() so guests never see it.
|
Self-gates on isVaultUnlocked() so guests never see it. -->
|
||||||
Lazy-loaded after idle (see $effects above). -->
|
<div class="bottom-stack-notification">
|
||||||
{#if EncryptionIntroBannerC}
|
<EncryptionIntroBanner />
|
||||||
{@const EncryptionIntroBanner = EncryptionIntroBannerC}
|
</div>
|
||||||
<div class="bottom-stack-notification">
|
|
||||||
<EncryptionIntroBanner />
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Sync pause banner — shown when sync was paused due to insufficient credits -->
|
<!-- Sync pause banner — shown when sync was paused due to insufficient credits -->
|
||||||
{#if syncBilling.paused}
|
{#if syncBilling.paused}
|
||||||
|
|
@ -772,10 +696,8 @@
|
||||||
|
|
||||||
<!-- Session expiry warning (auth only). Self-gates on the
|
<!-- Session expiry warning (auth only). Self-gates on the
|
||||||
secondsLeft countdown and only renders inside the stack
|
secondsLeft countdown and only renders inside the stack
|
||||||
when actually warning, so the wrapper is no-op otherwise.
|
when actually warning, so the wrapper is no-op otherwise. -->
|
||||||
Lazy-loaded after idle. -->
|
{#if authStore.isAuthenticated}
|
||||||
{#if authStore.isAuthenticated && SessionWarningC}
|
|
||||||
{@const SessionWarning = SessionWarningC}
|
|
||||||
<div class="bottom-stack-notification">
|
<div class="bottom-stack-notification">
|
||||||
<SessionWarning />
|
<SessionWarning />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -784,23 +706,16 @@
|
||||||
<!-- Cross-module automation suggestions. Lives in the (app)
|
<!-- Cross-module automation suggestions. Lives in the (app)
|
||||||
stack because automationsStore is an (app)-only module
|
stack because automationsStore is an (app)-only module
|
||||||
and the toast doesn't make sense on auth/landing pages
|
and the toast doesn't make sense on auth/landing pages
|
||||||
anyway. Self-gates on visible state. Lazy-loaded after idle. -->
|
anyway. Self-gates on visible state. -->
|
||||||
{#if SuggestionToastC}
|
<div class="bottom-stack-notification">
|
||||||
{@const SuggestionToast = SuggestionToastC}
|
<SuggestionToast />
|
||||||
<div class="bottom-stack-notification">
|
</div>
|
||||||
<SuggestionToast />
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Companion Brain pulse nudges — water reminders, streak
|
<!-- Companion Brain pulse nudges — water reminders, streak
|
||||||
warnings, morning summary etc. Self-gates on active nudges.
|
warnings, morning summary etc. Self-gates on active nudges. -->
|
||||||
Lazy-loaded after idle. -->
|
<div class="bottom-stack-notification">
|
||||||
{#if NudgeToastC}
|
<NudgeToast />
|
||||||
{@const NudgeToast = NudgeToastC}
|
</div>
|
||||||
<div class="bottom-stack-notification">
|
|
||||||
<NudgeToast />
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- QuickInputBar with inline nav toggle — gated by the "search" pill -->
|
<!-- QuickInputBar with inline nav toggle — gated by the "search" pill -->
|
||||||
{#if isQuickInputVisible}
|
{#if isQuickInputVisible}
|
||||||
|
|
@ -969,11 +884,8 @@
|
||||||
so it doesn't end up obscured by the QuickInputBar like
|
so it doesn't end up obscured by the QuickInputBar like
|
||||||
EncryptionIntroBanner used to be. -->
|
EncryptionIntroBanner used to be. -->
|
||||||
|
|
||||||
<!-- Keyboard shortcuts modal — loaded on first `?` press -->
|
<!-- Keyboard shortcuts modal -->
|
||||||
{#if KeyboardShortcutsModalC}
|
<KeyboardShortcutsModal open={showShortcuts} onclose={() => (showShortcuts = false)} />
|
||||||
{@const KeyboardShortcutsModal = KeyboardShortcutsModalC}
|
|
||||||
<KeyboardShortcutsModal open={showShortcuts} onclose={() => (showShortcuts = false)} />
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Navigation Context Menu -->
|
<!-- Navigation Context Menu -->
|
||||||
|
|
@ -985,9 +897,8 @@
|
||||||
onClose={() => navCtxMenu.close()}
|
onClose={() => navCtxMenu.close()}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Guest Welcome Modal — loaded when guest mode activates -->
|
<!-- Guest Welcome Modal -->
|
||||||
{#if guestMode && GuestWelcomeModalC}
|
{#if guestMode}
|
||||||
{@const GuestWelcomeModal = GuestWelcomeModalC}
|
|
||||||
<GuestWelcomeModal
|
<GuestWelcomeModal
|
||||||
appId="mana"
|
appId="mana"
|
||||||
visible={guestMode.showWelcome}
|
visible={guestMode.showWelcome}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,556 @@
|
||||||
|
<!--
|
||||||
|
AI Keys Settings — user-facing CRUD for BYOK provider keys.
|
||||||
|
|
||||||
|
Keys are encrypted at rest via the user's master key. Without an
|
||||||
|
unlocked vault, this page will show an error and prompt login.
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { Plus, Trash, Key, PencilSimple, Check, X } from '@mana/shared-icons';
|
||||||
|
import { BUILTIN_BYOK_PROVIDERS, formatCost, type ByokProviderId } from '@mana/shared-llm';
|
||||||
|
import { byokVault } from '$lib/byok';
|
||||||
|
import { isVaultUnlocked } from '$lib/data/crypto/key-provider';
|
||||||
|
import type { ByokKeyPlain } from '$lib/byok/types';
|
||||||
|
|
||||||
|
let keys = $state<Omit<ByokKeyPlain, 'apiKey'>[]>([]);
|
||||||
|
let loading = $state(true);
|
||||||
|
let vaultLocked = $state(false);
|
||||||
|
|
||||||
|
let showAdd = $state(false);
|
||||||
|
let addProvider = $state<ByokProviderId>('openai');
|
||||||
|
let addLabel = $state('');
|
||||||
|
let addApiKey = $state('');
|
||||||
|
let addModel = $state('');
|
||||||
|
let addIsDefault = $state(true);
|
||||||
|
let addError = $state<string | null>(null);
|
||||||
|
let saving = $state(false);
|
||||||
|
|
||||||
|
let editingId = $state<string | null>(null);
|
||||||
|
let editLabel = $state('');
|
||||||
|
let editModel = $state('');
|
||||||
|
|
||||||
|
async function reload() {
|
||||||
|
if (!isVaultUnlocked()) {
|
||||||
|
vaultLocked = true;
|
||||||
|
keys = [];
|
||||||
|
loading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
vaultLocked = false;
|
||||||
|
keys = await byokVault.listMeta();
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(reload);
|
||||||
|
|
||||||
|
async function handleAdd() {
|
||||||
|
addError = null;
|
||||||
|
if (!addLabel.trim() || !addApiKey.trim()) {
|
||||||
|
addError = 'Label und API-Key sind Pflicht';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
saving = true;
|
||||||
|
try {
|
||||||
|
await byokVault.create({
|
||||||
|
provider: addProvider,
|
||||||
|
label: addLabel.trim(),
|
||||||
|
apiKey: addApiKey.trim(),
|
||||||
|
model: addModel.trim() || undefined,
|
||||||
|
isDefault: addIsDefault,
|
||||||
|
});
|
||||||
|
showAdd = false;
|
||||||
|
addLabel = '';
|
||||||
|
addApiKey = '';
|
||||||
|
addModel = '';
|
||||||
|
await reload();
|
||||||
|
} catch (err) {
|
||||||
|
addError = err instanceof Error ? err.message : String(err);
|
||||||
|
} finally {
|
||||||
|
saving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(id: string) {
|
||||||
|
if (!confirm('Schluessel wirklich loeschen?')) return;
|
||||||
|
await byokVault.delete(id);
|
||||||
|
await reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSetDefault(id: string) {
|
||||||
|
await byokVault.update(id, { isDefault: true });
|
||||||
|
await reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
function startEdit(k: Omit<ByokKeyPlain, 'apiKey'>) {
|
||||||
|
editingId = k.id;
|
||||||
|
editLabel = k.label;
|
||||||
|
editModel = k.model ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveEdit() {
|
||||||
|
if (!editingId) return;
|
||||||
|
await byokVault.update(editingId, {
|
||||||
|
label: editLabel.trim(),
|
||||||
|
model: editModel.trim() || undefined,
|
||||||
|
});
|
||||||
|
editingId = null;
|
||||||
|
await reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
function providerDisplay(id: ByokProviderId): string {
|
||||||
|
return BUILTIN_BYOK_PROVIDERS.find((p) => p.id === id)?.displayName ?? id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function providerModels(id: ByokProviderId): readonly string[] {
|
||||||
|
return BUILTIN_BYOK_PROVIDERS.find((p) => p.id === id)?.availableModels ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function providerDefaultModel(id: ByokProviderId): string {
|
||||||
|
return BUILTIN_BYOK_PROVIDERS.find((p) => p.id === id)?.defaultModel ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (showAdd && !addModel) {
|
||||||
|
addModel = providerDefaultModel(addProvider);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>KI-Keys - Mana</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="keys-page">
|
||||||
|
<header class="page-header">
|
||||||
|
<h1>KI-Keys</h1>
|
||||||
|
<p class="subtitle">
|
||||||
|
Hinterlege deine eigenen API-Keys fuer OpenAI, Anthropic, Gemini oder Mistral. Keys bleiben
|
||||||
|
verschluesselt auf diesem Geraet und werden nie synchronisiert.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{#if vaultLocked}
|
||||||
|
<div class="notice">Vault ist gesperrt — bitte zuerst anmelden um Keys zu verwalten.</div>
|
||||||
|
{:else if loading}
|
||||||
|
<div class="notice">Laedt...</div>
|
||||||
|
{:else}
|
||||||
|
<div class="actions">
|
||||||
|
<button class="btn-primary" onclick={() => (showAdd = !showAdd)}>
|
||||||
|
<Plus size={14} weight="bold" /> Key hinzufuegen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if showAdd}
|
||||||
|
<div class="add-form">
|
||||||
|
<h3>Neuen Key hinzufuegen</h3>
|
||||||
|
|
||||||
|
<label class="field">
|
||||||
|
<span class="label">Provider</span>
|
||||||
|
<select
|
||||||
|
bind:value={addProvider}
|
||||||
|
onchange={() => (addModel = providerDefaultModel(addProvider))}
|
||||||
|
>
|
||||||
|
{#each BUILTIN_BYOK_PROVIDERS as p}
|
||||||
|
<option value={p.id}>{p.displayName}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="field">
|
||||||
|
<span class="label">Label</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={addLabel}
|
||||||
|
placeholder="z.B. Work Anthropic, Privat OpenAI"
|
||||||
|
maxlength="40"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="field">
|
||||||
|
<span class="label">API-Key</span>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
bind:value={addApiKey}
|
||||||
|
placeholder={addProvider === 'openai'
|
||||||
|
? 'sk-...'
|
||||||
|
: addProvider === 'anthropic'
|
||||||
|
? 'sk-ant-...'
|
||||||
|
: 'API-Key'}
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="field">
|
||||||
|
<span class="label">Modell (optional)</span>
|
||||||
|
<select bind:value={addModel}>
|
||||||
|
<option value="">Provider-Default ({providerDefaultModel(addProvider)})</option>
|
||||||
|
{#each providerModels(addProvider) as m}
|
||||||
|
<option value={m}>{m}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="field-inline">
|
||||||
|
<input type="checkbox" bind:checked={addIsDefault} />
|
||||||
|
<span>Als Standard fuer {providerDisplay(addProvider)}</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{#if addError}
|
||||||
|
<div class="error">{addError}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button class="btn-cancel" onclick={() => (showAdd = false)}>Abbrechen</button>
|
||||||
|
<button class="btn-primary" onclick={handleAdd} disabled={saving}>
|
||||||
|
{saving ? 'Speichern...' : 'Speichern'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="keys-list">
|
||||||
|
{#each keys as k (k.id)}
|
||||||
|
<div class="key-card" class:default-key={k.isDefault}>
|
||||||
|
{#if editingId === k.id}
|
||||||
|
<div class="edit-row">
|
||||||
|
<input type="text" bind:value={editLabel} maxlength="40" />
|
||||||
|
<select bind:value={editModel}>
|
||||||
|
<option value="">Provider-Default</option>
|
||||||
|
{#each providerModels(k.provider) as m}
|
||||||
|
<option value={m}>{m}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
<button class="btn-icon" onclick={saveEdit} title="Speichern">
|
||||||
|
<Check size={14} weight="bold" />
|
||||||
|
</button>
|
||||||
|
<button class="btn-icon" onclick={() => (editingId = null)} title="Abbrechen">
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="key-main">
|
||||||
|
<div class="key-icon"><Key size={16} weight="fill" /></div>
|
||||||
|
<div class="key-info">
|
||||||
|
<div class="key-title">
|
||||||
|
<span class="key-label">{k.label}</span>
|
||||||
|
{#if k.isDefault}
|
||||||
|
<span class="default-badge">Standard</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="key-meta">
|
||||||
|
{providerDisplay(k.provider)}
|
||||||
|
· {k.model || `Default (${providerDefaultModel(k.provider)})`}
|
||||||
|
</div>
|
||||||
|
<div class="key-usage">
|
||||||
|
{k.usageCount} Aufrufe · {k.totalTokens.toLocaleString('de-DE')} Token · {formatCost(
|
||||||
|
k.totalCostUsd
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="key-actions">
|
||||||
|
{#if !k.isDefault}
|
||||||
|
<button
|
||||||
|
class="btn-ghost"
|
||||||
|
onclick={() => handleSetDefault(k.id)}
|
||||||
|
title="Als Standard setzen"
|
||||||
|
>
|
||||||
|
Standard
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
<button class="btn-icon" onclick={() => startEdit(k)} title="Bearbeiten">
|
||||||
|
<PencilSimple size={14} />
|
||||||
|
</button>
|
||||||
|
<button class="btn-icon danger" onclick={() => handleDelete(k.id)} title="Loeschen">
|
||||||
|
<Trash size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="empty">
|
||||||
|
<Key size={32} weight="thin" />
|
||||||
|
<p>Noch keine API-Keys hinterlegt.</p>
|
||||||
|
<p class="hint">
|
||||||
|
Klicke auf "Key hinzufuegen" um mit OpenAI, Anthropic, Gemini oder Mistral zu chatten.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.keys-page {
|
||||||
|
max-width: 720px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 1.5rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
.page-header h1 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0 0 0.375rem;
|
||||||
|
color: hsl(var(--color-foreground));
|
||||||
|
}
|
||||||
|
.subtitle {
|
||||||
|
color: hsl(var(--color-muted-foreground));
|
||||||
|
font-size: 0.875rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.notice {
|
||||||
|
padding: 1.5rem;
|
||||||
|
text-align: center;
|
||||||
|
color: hsl(var(--color-muted-foreground));
|
||||||
|
background: hsl(var(--color-muted) / 0.2);
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
}
|
||||||
|
.error {
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
background: hsl(var(--color-error) / 0.1);
|
||||||
|
border: 1px solid hsl(var(--color-error) / 0.3);
|
||||||
|
color: hsl(var(--color-error));
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-form {
|
||||||
|
background: hsl(var(--color-card));
|
||||||
|
border: 1px solid hsl(var(--color-border));
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
padding: 1.25rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.add-form h3 {
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
.field .label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: hsl(var(--color-muted-foreground));
|
||||||
|
}
|
||||||
|
.field input,
|
||||||
|
.field select {
|
||||||
|
padding: 0.4375rem 0.625rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
border: 1px solid hsl(var(--color-border));
|
||||||
|
background: hsl(var(--color-background));
|
||||||
|
color: hsl(var(--color-foreground));
|
||||||
|
font-size: 0.875rem;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.field input:focus,
|
||||||
|
.field select:focus {
|
||||||
|
border-color: hsl(var(--color-primary));
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-inline {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
background: hsl(var(--color-primary));
|
||||||
|
color: hsl(var(--color-primary-foreground));
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.btn-primary:hover:not(:disabled) {
|
||||||
|
filter: brightness(1.08);
|
||||||
|
}
|
||||||
|
.btn-primary:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-cancel {
|
||||||
|
padding: 0.4375rem 0.75rem;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: hsl(var(--color-muted-foreground));
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ghost {
|
||||||
|
padding: 0.25rem 0.625rem;
|
||||||
|
border: 1px solid hsl(var(--color-border));
|
||||||
|
background: transparent;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
cursor: pointer;
|
||||||
|
color: hsl(var(--color-muted-foreground));
|
||||||
|
}
|
||||||
|
.btn-ghost:hover {
|
||||||
|
border-color: hsl(var(--color-primary) / 0.5);
|
||||||
|
color: hsl(var(--color-primary));
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: hsl(var(--color-muted-foreground));
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.btn-icon:hover {
|
||||||
|
background: hsl(var(--color-surface-hover));
|
||||||
|
color: hsl(var(--color-foreground));
|
||||||
|
}
|
||||||
|
.btn-icon.danger:hover {
|
||||||
|
color: hsl(var(--color-error));
|
||||||
|
background: hsl(var(--color-error) / 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.keys-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.625rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.key-card {
|
||||||
|
background: hsl(var(--color-card));
|
||||||
|
border: 1px solid hsl(var(--color-border));
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
padding: 0.875rem 1rem;
|
||||||
|
}
|
||||||
|
.key-card.default-key {
|
||||||
|
border-color: hsl(var(--color-primary) / 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.key-main {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.key-icon {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: hsl(var(--color-primary) / 0.1);
|
||||||
|
color: hsl(var(--color-primary));
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.key-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.key-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 0.125rem;
|
||||||
|
}
|
||||||
|
.key-label {
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
color: hsl(var(--color-foreground));
|
||||||
|
}
|
||||||
|
.default-badge {
|
||||||
|
font-size: 0.625rem;
|
||||||
|
font-weight: 600;
|
||||||
|
background: hsl(var(--color-primary) / 0.15);
|
||||||
|
color: hsl(var(--color-primary));
|
||||||
|
padding: 0.125rem 0.375rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
.key-meta {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: hsl(var(--color-muted-foreground));
|
||||||
|
}
|
||||||
|
.key-usage {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
color: hsl(var(--color-muted-foreground));
|
||||||
|
margin-top: 0.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.key-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.edit-row input {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.375rem 0.5rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
border: 1px solid hsl(var(--color-border));
|
||||||
|
background: hsl(var(--color-background));
|
||||||
|
color: hsl(var(--color-foreground));
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
.edit-row select {
|
||||||
|
padding: 0.375rem 0.5rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
border: 1px solid hsl(var(--color-border));
|
||||||
|
background: hsl(var(--color-background));
|
||||||
|
color: hsl(var(--color-foreground));
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem 1rem;
|
||||||
|
color: hsl(var(--color-muted-foreground));
|
||||||
|
}
|
||||||
|
.empty p {
|
||||||
|
margin: 0.5rem 0 0;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
.empty .hint {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Loading…
Add table
Add a link
Reference in a new issue