@ -7,9 +7,10 @@ import {
For ,
createEffect ,
} from 'solid-js' ;
import { Settings as SettingsIcon , Save , Plus , Trash2 , Info } from 'lucide-solid' ;
import { Settings as SettingsIcon , Save , Plus , Trash2 , Info , Download , Upload } from 'lucide-solid' ;
import { settingsApi } from '~/api/settings' ;
import { configApi } from '~/api/config' ;
import { apiKeysApi } from '~/api/apiKeys' ;
import { useI18n } from '~/i18n' ;
import { DEFAULT_SETTINGS , isApiError } from '~/types' ;
import type { UserSettings , ProviderConfig } from '~/types' ;
@ -29,10 +30,13 @@ const Settings: Component = () => {
type : 'success' | 'error' ;
text : string ;
} | null > ( null ) ;
const [ includeApiKeys , setIncludeApiKeys ] = createSignal ( false ) ;
const [ providers ] = createResource ( ( ) = > configApi . listProviders ( ) ) ;
const [ providerWarning , setProviderWarning ] = createSignal ( false ) ;
let fileInputRef : HTMLInputElement | undefined ;
onMount ( async ( ) = > {
try {
const data = await settingsApi . get ( ) ;
@ -153,6 +157,80 @@ const Settings: Component = () => {
} ) ) ;
} ;
const handleExport = async ( ) = > {
try {
const exportData : Record < string , unknown > = { . . . settings ( ) } ;
if ( includeApiKeys ( ) ) {
const keys = await apiKeysApi . exportKeys ( ) ;
exportData . api_keys = keys ;
}
const blob = new Blob ( [ JSON . stringify ( exportData , null , 2 ) ] , {
type : 'application/json' ,
} ) ;
const url = URL . createObjectURL ( blob ) ;
const a = document . createElement ( 'a' ) ;
a . href = url ;
a . download = 'settings.json' ;
document . body . appendChild ( a ) ;
a . click ( ) ;
document . body . removeChild ( a ) ;
URL . revokeObjectURL ( url ) ;
} catch ( err ) {
if ( isApiError ( err ) ) {
setMessage ( { type : 'error' , text : err.message } ) ;
} else {
setMessage ( { type : 'error' , text : t ( 'settings.saveError' ) } ) ;
}
}
} ;
const handleImport = async ( file : File ) = > {
try {
const text = await file . text ( ) ;
const data = JSON . parse ( text ) ;
// Merge over DEFAULT_SETTINGS for missing fields
const merged : UserSettings = {
. . . DEFAULT_SETTINGS ,
theme : data.theme ? ? DEFAULT_SETTINGS . theme ,
max_age_days : data.max_age_days ? ? DEFAULT_SETTINGS . max_age_days ,
max_items_per_category : data.max_items_per_category ? ? DEFAULT_SETTINGS . max_items_per_category ,
search_agent_behavior : data.search_agent_behavior ? ? DEFAULT_SETTINGS . search_agent_behavior ,
ai_model : data.ai_model ? ? DEFAULT_SETTINGS . ai_model ,
ai_model_writing : data.ai_model_writing ? ? DEFAULT_SETTINGS . ai_model_writing ,
ai_provider : data.ai_provider ? ? DEFAULT_SETTINGS . ai_provider ,
rate_limit_max_requests : data.rate_limit_max_requests ? ? DEFAULT_SETTINGS . rate_limit_max_requests ,
rate_limit_time_window_seconds : data.rate_limit_time_window_seconds ? ? DEFAULT_SETTINGS . rate_limit_time_window_seconds ,
categories : Array.isArray ( data . categories ) ? data.categories : DEFAULT_SETTINGS.categories ,
} ;
setSettings ( merged ) ;
// Import API keys if present
if ( Array . isArray ( data . api_keys ) ) {
for ( const key of data . api_keys ) {
if ( key . provider_name && key . api_key ) {
await apiKeysApi . create ( {
provider_name : key.provider_name ,
api_key : key.api_key ,
} ) ;
}
}
}
setMessage ( { type : 'success' , text : t ( 'settings.importSuccess' ) } ) ;
} catch {
setMessage ( { type : 'error' , text : t ( 'settings.importError' ) } ) ;
}
// Reset file input
if ( fileInputRef ) {
fileInputRef . value = '' ;
}
} ;
return (
< Show when = { ! loading ( ) } fallback = { < LoadingSpinner / > } >
< div class = "max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-8" >
@ -163,6 +241,56 @@ const Settings: Component = () => {
{ t ( 'settings.title' ) }
< / h1 >
< / div >
< div class = "flex items-center gap-2" >
{ /* Export button */ }
< button
type = "button"
onClick = { handleExport }
class = "inline-flex items-center px-3 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
title = { t ( 'settings.export' ) }
>
< Download class = "h-4 w-4 mr-1" / >
{ t ( 'settings.export' ) }
< / button >
{ /* Import button */ }
< button
type = "button"
onClick = { ( ) = > fileInputRef ? . click ( ) }
class = "inline-flex items-center px-3 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
title = { t ( 'settings.import' ) }
>
< Upload class = "h-4 w-4 mr-1" / >
{ t ( 'settings.import' ) }
< / button >
< input
ref = { fileInputRef }
type = "file"
accept = ".json"
class = "hidden"
onChange = { ( e ) = > {
const file = e . currentTarget . files ? . [ 0 ] ;
if ( file ) handleImport ( file ) ;
} }
/ >
< / div >
< / div >
{ /* Export: Include API keys checkbox */ }
< div class = "mb-6" >
< label class = "inline-flex items-center gap-2 cursor-pointer" >
< input
type = "checkbox"
checked = { includeApiKeys ( ) }
onChange = { ( e ) = > setIncludeApiKeys ( e . currentTarget . checked ) }
class = "rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
/ >
< span class = "text-sm text-gray-700" > { t ( 'settings.exportIncludeKeys' ) } < / span >
< / label >
< Show when = { includeApiKeys ( ) } >
< p class = "mt-1 text-sm text-amber-600" >
{ t ( 'settings.exportKeysWarning' ) }
< / p >
< / Show >
< / div >
< Show when = { message ( ) } >
@ -342,13 +470,13 @@ const Settings: Component = () => {
< / div >
< / Show >
{ /* M odel dropdown */}
{ /* Research m odel dropdown */}
< div >
< label
for = "aiModel"
class = "block text-sm font-medium text-gray-700"
>
{ t ( 'settings.model ') }
{ t ( 'settings.model Research ') }
< / label >
< div class = "mt-1" >
< select
@ -378,7 +506,43 @@ const Settings: Component = () => {
< / select >
< / div >
< p class = "mt-2 text-sm text-gray-500" >
{ t ( 'settings.modelHelp' ) }
{ t ( 'settings.modelResearchHelp' ) }
< / p >
< / div >
{ /* Writing model dropdown */ }
< div >
< label
for = "aiModelWriting"
class = "block text-sm font-medium text-gray-700"
>
{ t ( 'settings.modelWriting' ) }
< / label >
< div class = "mt-1" >
< select
id = "aiModelWriting"
class = "mt-1 block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md border"
value = { settings ( ) . ai_model_writing }
onChange = { ( e ) = >
setSettings ( ( prev ) = > ( {
. . . prev ,
ai_model_writing : e.currentTarget.value ,
} ) )
}
disabled = { ! selectedProvider ( ) }
>
< option value = "" > { t ( 'settings.modelPlaceholder' ) } < / option >
< For each = { selectedProvider ( ) ? . models ? ? [ ] } >
{ ( model ) = > (
< option value = { model . model_id } >
{ model . display_name }
< / option >
) }
< / For >
< / select >
< / div >
< p class = "mt-2 text-sm text-gray-500" >
{ t ( 'settings.modelWritingHelp' ) }
< / p >
< / div >
@ -454,6 +618,92 @@ const Settings: Component = () => {
< / For >
< / div >
< / div >
{ /* Rate Limit Section */ }
< hr class = "border-gray-200" / >
< div >
< h3 class = "text-sm font-medium text-gray-700 mb-4" >
{ t ( 'settings.rateLimitSection' ) }
< / h3 >
< div class = "grid grid-cols-1 gap-y-6 gap-x-4 sm:grid-cols-2" >
< div >
< label
for = "rateLimitMaxRequests"
class = "block text-sm font-medium text-gray-700"
>
{ t ( 'settings.rateLimitMaxRequests' ) }
< / label >
< div class = "mt-1" >
< input
type = "number"
id = "rateLimitMaxRequests"
min = "1"
class = "shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 rounded-md py-2 px-3 border"
value = { settings ( ) . rate_limit_max_requests ? ? '' }
onInput = { ( e ) = > {
const val = e . currentTarget . value ;
setSettings ( ( prev ) = > ( {
. . . prev ,
rate_limit_max_requests : val === '' ? null : ( parseInt ( val ) || null ) ,
} ) ) ;
} }
placeholder = ""
/ >
< / div >
< / div >
< div >
< label
for = "rateLimitTimeWindow"
class = "block text-sm font-medium text-gray-700"
>
{ t ( 'settings.rateLimitTimeWindow' ) }
< / label >
< div class = "mt-1" >
< input
type = "number"
id = "rateLimitTimeWindow"
min = "1"
class = "shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 rounded-md py-2 px-3 border"
value = { settings ( ) . rate_limit_time_window_seconds ? ? '' }
onInput = { ( e ) = > {
const val = e . currentTarget . value ;
setSettings ( ( prev ) = > ( {
. . . prev ,
rate_limit_time_window_seconds : val === '' ? null : ( parseInt ( val ) || null ) ,
} ) ) ;
} }
placeholder = ""
/ >
< / div >
< / div >
< / div >
< p class = "mt-2 text-sm text-gray-500" >
{ t ( 'settings.rateLimitHelp' ) }
< / p >
< Show when = { settings ( ) . rate_limit_max_requests !== null && settings ( ) . rate_limit_time_window_seconds !== null } >
< p class = "mt-2 text-sm text-indigo-600 font-medium" >
{ t ( 'settings.rateLimitEffective' )
. replace ( '{max}' , String ( settings ( ) . rate_limit_max_requests ) )
. replace ( '{window}' , String ( settings ( ) . rate_limit_time_window_seconds ) ) }
< / p >
< / Show >
< Show when = { settings ( ) . rate_limit_max_requests !== null || settings ( ) . rate_limit_time_window_seconds !== null } >
< button
type = "button"
onClick = { ( ) = >
setSettings ( ( prev ) = > ( {
. . . prev ,
rate_limit_max_requests : null ,
rate_limit_time_window_seconds : null ,
} ) )
}
class = "mt-2 text-sm text-indigo-600 hover:text-indigo-800 underline"
>
{ t ( 'settings.rateLimitReset' ) }
< / button >
< / Show >
< / div >
< / div >
{ /* Save button */ }