Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,10 @@ CACHE_DEFAULT_TTL_SECONDS=3600
# State Management
STATE_CLEANUP_INTERVAL_HOURS=1
STATE_RETENTION_DAYS=7

# Rate-limit IP trust
# Set to 'true' ONLY when the app is fronted by a proxy that overwrites
# x-forwarded-for (Vercel, Cloudflare, nginx with proxy_set_header). When the
# app is reachable directly from the public Internet, leave unset — clients
# can otherwise spoof their rate-limit identity via x-forwarded-for.
# TRUST_PROXY=true
20 changes: 15 additions & 5 deletions src/lib/client/features/chat/components/Chat.simple.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@
import { workspaceStore } from '$lib/client/stores/workspace.svelte';
import { kv } from '$lib/client/stores/kvStore.svelte';
import { modelsStore } from '$lib/client/stores/models.svelte';
import { aiSettings } from '$lib/client/stores/settings.svelte';
import { providerKeyHeaders } from '$lib/client/util/provider-keys';
import ProviderIcon from '$lib/client/features/chat/components/ProviderIcon.svelte';
import ToolSimpleChip from '$lib/client/features/chat/components/ToolSimpleChip.svelte';
import {
Expand Down Expand Up @@ -1134,7 +1136,11 @@
try {
const formData = new FormData();
formData.append('audio', blob, 'recording.webm');
const res = await fetch('/api/audio', { method: 'POST', body: formData });
const res = await fetch('/api/audio', {
method: 'POST',
body: formData,
headers: providerKeyHeaders(aiSettings.value)
});
if (res.ok) {
const data = await res.json();
if (data.text) {
Expand Down Expand Up @@ -1492,7 +1498,7 @@
try {
const res = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: { 'Content-Type': 'application/json', ...providerKeyHeaders(aiSettings.value) },
body: JSON.stringify({
currentDiagram: '',
currentMarkdown: '',
Expand Down Expand Up @@ -1542,7 +1548,7 @@
try {
const res = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: { 'Content-Type': 'application/json', ...providerKeyHeaders(aiSettings.value) },
body: JSON.stringify({
currentDiagram: '',
currentMarkdown: '',
Expand Down Expand Up @@ -1699,7 +1705,11 @@
formData.append('file', blob, file.filename || 'attachment');
formData.append('sessionId', sessionId);
formData.append('supportsImages', selectedModel?.imageSupport ? 'true' : 'false');
const res = await fetch('/api/upload', { method: 'POST', body: formData });
const res = await fetch('/api/upload', {
method: 'POST',
body: formData,
headers: providerKeyHeaders(aiSettings.value)
});
if (!res.ok) {
const message = await res.text().catch(() => '');
console.error('Upload failed:', res.status, message);
Expand Down Expand Up @@ -1896,7 +1906,7 @@
const activeEngine = getActiveWorkspaceEngine();
return fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: { 'Content-Type': 'application/json', ...providerKeyHeaders(aiSettings.value) },
body: JSON.stringify({
activeFileId: filesStore.activeId,
activeTabEngine: activeEngine,
Expand Down
154 changes: 88 additions & 66 deletions src/lib/client/features/settings/SettingsModal.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -53,16 +53,27 @@
let newMemoryKey = $state('');
let newMemoryValue = $state('');

interface MemoryEntry {
value: unknown;
savedAt?: string;
}

async function loadMemories() {
memoryLoading = true;
try {
const data = await kv.get('memories', 'all');
if (data && typeof data === 'object' && !Array.isArray(data)) {
memories = Object.entries(data as Record<string, any>).map(([k, v]: [string, any]) => ({
key: k,
value: typeof v === 'object' ? v.value || JSON.stringify(v) : String(v),
savedAt: typeof v === 'object' ? v.savedAt || '' : ''
}));
memories = Object.entries(data as Record<string, unknown>).map(([k, v]) => {
const entry = v as MemoryEntry | string;
if (entry && typeof entry === 'object' && 'value' in entry) {
return {
key: k,
value: typeof entry.value === 'string' ? entry.value : JSON.stringify(entry.value),
savedAt: entry.savedAt ?? ''
};
}
return { key: k, value: String(entry), savedAt: '' };
});
} else {
memories = [];
}
Expand All @@ -75,8 +86,8 @@
async function saveMemory(key: string, value: string) {
const current = (await kv.get('memories', 'all')) || {};
const updated = {
...(current as Record<string, any>),
[key]: { value, savedAt: new Date().toISOString() }
...(current as Record<string, unknown>),
[key]: { savedAt: new Date().toISOString(), value }
};
await kv.set('memories', 'all', updated);
await loadMemories();
Expand All @@ -88,8 +99,11 @@

async function deleteMemory(key: string) {
const current = (await kv.get('memories', 'all')) || {};
const updated = { ...(current as Record<string, any>) };
delete updated[key];
// Build a new object excluding the deleted key — avoids the
// `delete obj[dyn]` pattern that flips perf characteristics on V8.
const updated = Object.fromEntries(
Object.entries(current as Record<string, unknown>).filter(([k]) => k !== key)
);
await kv.set('memories', 'all', updated);
await loadMemories();
}
Expand Down Expand Up @@ -160,52 +174,32 @@
let openAiApiKeyInput = $state('');
let openRouterApiKeyInput = $state('');

// Per-user search-provider keys (stored via /api/user/api-keys, encrypted
// server-side). Unlike AI provider keys above, these are NOT global —
// every user (including guests) carries their own.
// Per-user search-provider keys live in localStorage on aiSettings, same
// as the AI provider keys. Earlier versions stored these server-side via
// /api/user/api-keys (encrypted at rest); the local-settings revamp
// moved them client-side too so the server holds zero secrets.
let braveSearchInput = $state('');
let tavilyInput = $state('');
let braveSearchPresent = $state(false);
let tavilyPresent = $state(false);
const braveSearchPresent = $derived(Boolean(aiSettings.value.braveSearchApiKey));
const tavilyPresent = $derived(Boolean(aiSettings.value.tavilyApiKey));
let searchKeySaving = $state(false);
let searchKeyMessage = $state<{ kind: 'error' | 'notice'; text: string } | null>(null);

async function loadUserApiKeys() {
try {
const res = await fetch('/api/user/api-keys', { credentials: 'include' });
if (!res.ok) return;
const data = await res.json();
braveSearchPresent = Boolean(data?.providers?.brave_search);
tavilyPresent = Boolean(data?.providers?.tavily);
} catch {
/* non-fatal */
}
}

async function saveUserApiKey(provider: 'brave_search' | 'tavily', key: string) {
function saveUserApiKey(provider: 'brave_search' | 'tavily', key: string) {
if (!key.trim() || key.trim().length < 8) {
searchKeyMessage = { kind: 'error', text: 'Key must be at least 8 characters.' };
return;
}
searchKeySaving = true;
searchKeyMessage = null;
try {
const res = await fetch('/api/user/api-keys', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ provider, key: key.trim() })
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data?.error || `Failed to save ${provider}`);
}
const trimmed = key.trim();
if (provider === 'brave_search') {
aiSettings.update((s) => ({ ...s, braveSearchApiKey: trimmed }));
braveSearchInput = '';
braveSearchPresent = true;
} else {
aiSettings.update((s) => ({ ...s, tavilyApiKey: trimmed }));
tavilyInput = '';
tavilyPresent = true;
}
searchKeyMessage = { kind: 'notice', text: 'Saved.' };
} catch (err) {
Expand All @@ -218,17 +212,15 @@
}
}

async function clearUserApiKey(provider: 'brave_search' | 'tavily') {
function clearUserApiKey(provider: 'brave_search' | 'tavily') {
searchKeySaving = true;
searchKeyMessage = null;
try {
const res = await fetch(`/api/user/api-keys?provider=${encodeURIComponent(provider)}`, {
method: 'DELETE',
credentials: 'include'
});
if (!res.ok) throw new Error('Failed to clear key');
if (provider === 'brave_search') braveSearchPresent = false;
else tavilyPresent = false;
if (provider === 'brave_search') {
aiSettings.update((s) => ({ ...s, braveSearchApiKey: '' }));
} else {
aiSettings.update((s) => ({ ...s, tavilyApiKey: '' }));
}
searchKeyMessage = { kind: 'notice', text: 'Cleared.' };
} catch (err) {
searchKeyMessage = {
Expand All @@ -240,10 +232,41 @@
}
}

let enabledModels = $state<Record<string, any>[]>([]);
/** Shape of rows returned by the admin enabled-models API. */
interface EnabledModelRow {
model_id: string;
model_name: string;
provider?: string;
category?: string;
description?: string;
is_enabled?: boolean;
is_free?: boolean;
gems_per_message?: number;
max_tokens?: number;
tool_support?: boolean;
metadata?: Record<string, unknown>;
sort_order?: number;
}

/** Provider catalog rows (OpenAI / Anthropic / OpenRouter all differ). */
interface ProviderModelRow {
id: string;
name?: string;
description?: string;
created?: number;
contextWindow?: number;
context_length?: number;
architecture?: { modality?: string };
provider?: string;
pricing?: { prompt?: string; completion?: string };
supportedParameters?: string[];
supported_parameters?: string[];
}

let enabledModels = $state<EnabledModelRow[]>([]);
let enabledModelsLoading = $state(false);
let modelSearchProvider = $state<ModelSearchProvider>('openrouter');
let providerModels = $state<Record<string, any>[]>([]);
let providerModels = $state<ProviderModelRow[]>([]);
let providerModelsLoading = $state(false);
let providerModelSearch = $state('');
let modelAdminError = $state('');
Expand Down Expand Up @@ -288,7 +311,7 @@
return modelId.startsWith(`${provider}/`) ? modelId : `${provider}/${modelId}`;
}

function buildProviderImportPayload(provider: ModelSearchProvider, model: Record<string, any>) {
function buildProviderImportPayload(provider: ModelSearchProvider, model: ProviderModelRow) {
const fullId = providerModelId(provider, model.id);
const isFree = model.pricing?.prompt === '0' && model.pricing?.completion === '0';

Expand Down Expand Up @@ -389,7 +412,7 @@
}
}

async function importProviderModel(model: Record<string, any>) {
async function importProviderModel(model: ProviderModelRow) {
const modelData = buildProviderImportPayload(modelSearchProvider, model);
modelAdminError = '';
modelAdminNotice = '';
Expand Down Expand Up @@ -438,12 +461,10 @@
modelAdminError = '';
modelAdminNotice = '';
try {
await adminPost({
action: 'setProviderApiKey',
apiKey: credential,
credentialType,
provider
});
// Keys live only in localStorage (per-user namespaced via the settings
// store). Earlier versions also POSTed to /api/admin setProviderApiKey
// which stored a global encrypted row in app_settings — that endpoint
// is gone (it was the original guest-leak vector).
updateProviderCredential(provider, credential, credentialType);
const successMsg = `${labelByProvider[provider]} ${credentialLabel} saved`;
modelAdminNotice = successMsg;
Expand Down Expand Up @@ -565,11 +586,13 @@
loadEnabledModels();
loadChatCompactionSettings();
loadVoiceSettings();
loadUserApiKeys();
// Search-provider keys live in aiSettings now — no server fetch needed.
// Load memories
loadMemories();

return () => {};
return () => {
// No teardown needed — every subscription is HMR-aware via the store.
};
});

function updateProviderCredential(
Expand All @@ -578,7 +601,7 @@
credentialType: ProviderCredential
) {
const keyField = credentialType === 'auth_token' ? `${provider}AuthToken` : `${provider}ApiKey`;
aiSettings.update((s: any) => ({
aiSettings.update((s) => ({
...s,
[keyField]: value
}));
Expand Down Expand Up @@ -1150,6 +1173,7 @@
<div class="divide-y divide-border">
{#each filteredProviderModels as model (model.id)}
{@const imported = isProviderModelImported(model.id)}
{@const ctx = model.contextWindow || model.context_length || 0}
<div class="flex items-center justify-between gap-3 px-3 py-3">
<div class="min-w-0 flex-1">
<div class="truncate text-[13px] font-medium">
Expand All @@ -1159,9 +1183,7 @@
{model.id}
</div>
<div class="mt-1 text-[13px] text-muted-foreground">
{model.contextWindow || model.context_length
? `${((model.contextWindow || model.context_length) / 1000).toFixed(0)}k context`
: 'Context unknown'}
{ctx ? `${(ctx / 1000).toFixed(0)}k context` : 'Context unknown'}
</div>
</div>
{#if imported}
Expand Down Expand Up @@ -1267,7 +1289,7 @@
</div>
</div>

{#each TOOL_CATEGORIES as cat}
{#each TOOL_CATEGORIES as cat (cat.id)}
{@const catTools = toolsConfig.filter((t) => t.category === cat.id)}
{#if catTools.length > 0}
<div class="overflow-hidden rounded-md border border-border">
Expand All @@ -1276,7 +1298,7 @@
{cat.label}
</div>
<div class="divide-y divide-border">
{#each catTools as t}
{#each catTools as t (t.id)}
<button
type="button"
class="flex w-full items-center justify-between gap-4 px-3 py-3 text-left hover:bg-muted/40"
Expand Down Expand Up @@ -1408,7 +1430,7 @@
<div class="rounded-md border border-border p-3">
<div class="mb-2 text-[13px] font-medium">Save behavior</div>
<div class="grid gap-2">
{#each ['conservative', 'balanced', 'aggressive'] as mode}
{#each ['conservative', 'balanced', 'aggressive'] as mode (mode)}
<button
type="button"
class="rounded-md border px-3 py-2 text-left text-[13px] capitalize transition-colors {personalization.memorySaveMode ===
Expand Down
Loading
Loading