LlmSetting.tsx raw

   1  import { Input } from '@/components/ui/input'
   2  import { Label } from '@/components/ui/label'
   3  import {
   4    Select,
   5    SelectContent,
   6    SelectItem,
   7    SelectTrigger,
   8    SelectValue
   9  } from '@/components/ui/select'
  10  import { Switch } from '@/components/ui/switch'
  11  import { Textarea } from '@/components/ui/textarea'
  12  import { useNostr } from '@/providers/NostrProvider'
  13  import { DEFAULT_MODEL, fetchModels, TAnthropicModel } from '@/services/llm.service'
  14  import storage, { dispatchSettingsChanged } from '@/services/local-storage.service'
  15  import { TLlmConfig } from '@/types'
  16  import { LoaderCircle } from 'lucide-react'
  17  import { useCallback, useEffect, useState } from 'react'
  18  import { useTranslation } from 'react-i18next'
  19  
  20  const DEFAULT_CONFIG: TLlmConfig = {
  21    apiKey: '',
  22    model: '',
  23    systemPrompt: '',
  24    autoRewrite: false
  25  }
  26  
  27  export default function LlmSetting() {
  28    const { t } = useTranslation()
  29    const { pubkey } = useNostr()
  30    const [config, setConfig] = useState<TLlmConfig>(DEFAULT_CONFIG)
  31    const [models, setModels] = useState<TAnthropicModel[]>([])
  32    const [loadingModels, setLoadingModels] = useState(false)
  33  
  34    useEffect(() => {
  35      if (pubkey) {
  36        setConfig(storage.getLlmConfig(pubkey) ?? DEFAULT_CONFIG)
  37      }
  38    }, [pubkey])
  39  
  40    // Fetch models when API key changes
  41    useEffect(() => {
  42      if (!config.apiKey || config.apiKey.length < 10) {
  43        setModels([])
  44        return
  45      }
  46  
  47      let cancelled = false
  48      const timer = setTimeout(async () => {
  49        setLoadingModels(true)
  50        try {
  51          const result = await fetchModels(config.apiKey)
  52          if (!cancelled) setModels(result)
  53        } catch (err) {
  54          console.error('[LLM] Failed to fetch models:', err)
  55          if (!cancelled) setModels([])
  56        } finally {
  57          if (!cancelled) setLoadingModels(false)
  58        }
  59      }, 500) // debounce while typing the key
  60  
  61      return () => {
  62        cancelled = true
  63        clearTimeout(timer)
  64      }
  65    }, [config.apiKey])
  66  
  67    const save = useCallback(
  68      (updated: TLlmConfig) => {
  69        setConfig(updated)
  70        if (pubkey) {
  71          storage.setLlmConfig(pubkey, updated)
  72          dispatchSettingsChanged()
  73        }
  74      },
  75      [pubkey]
  76    )
  77  
  78    const selectedModel = config.model || DEFAULT_MODEL
  79  
  80    return (
  81      <div className="space-y-2">
  82        <Label className="text-base font-semibold">{t('LLM Settings')}</Label>
  83  
  84        <div className="space-y-1">
  85          <Label htmlFor="llm-api-key">{t('Anthropic API key')}</Label>
  86          <Input
  87            id="llm-api-key"
  88            type="password"
  89            placeholder="sk-ant-..."
  90            value={config.apiKey}
  91            onChange={(e) => save({ ...config, apiKey: e.target.value })}
  92          />
  93        </div>
  94  
  95        <div className="space-y-1">
  96          <Label>{t('Model')}</Label>
  97          {loadingModels ? (
  98            <div className="flex items-center gap-2 h-10 px-3 text-sm text-muted-foreground">
  99              <LoaderCircle className="size-4 animate-spin" />
 100              {t('Loading models...')}
 101            </div>
 102          ) : models.length > 0 ? (
 103            <Select value={selectedModel} onValueChange={(v) => save({ ...config, model: v })}>
 104              <SelectTrigger>
 105                <SelectValue />
 106              </SelectTrigger>
 107              <SelectContent>
 108                {models.map((m) => (
 109                  <SelectItem key={m.id} value={m.id}>
 110                    {m.display_name}
 111                  </SelectItem>
 112                ))}
 113              </SelectContent>
 114            </Select>
 115          ) : (
 116            <div className="flex items-center h-10 px-3 text-sm text-muted-foreground rounded-lg border border-input">
 117              {config.apiKey
 118                ? t('Enter a valid API key to load models')
 119                : t('Enter API key first')}
 120            </div>
 121          )}
 122        </div>
 123  
 124        <div className="space-y-1">
 125          <Label htmlFor="llm-system-prompt">{t('Rewrite instructions')}</Label>
 126          <Textarea
 127            id="llm-system-prompt"
 128            rows={6}
 129            placeholder={t(
 130              'e.g. Rewrite the following note to be concise and clear. Preserve the original meaning and tone.'
 131            )}
 132            value={config.systemPrompt}
 133            onChange={(e) => save({ ...config, systemPrompt: e.target.value })}
 134          />
 135        </div>
 136  
 137        <div className="flex items-center justify-between min-h-9">
 138          <Label htmlFor="llm-auto-rewrite" className="text-base font-normal">
 139            {t('Auto-rewrite on publish')}
 140          </Label>
 141          <Switch
 142            id="llm-auto-rewrite"
 143            checked={config.autoRewrite}
 144            onCheckedChange={(checked) => save({ ...config, autoRewrite: checked })}
 145          />
 146        </div>
 147  
 148        <p className="text-xs text-muted-foreground">
 149          {t('Only Anthropic Claude is supported.')}{' '}
 150          <a
 151            href="https://git.mleku.dev/mleku/smesh"
 152            target="_blank"
 153            rel="noopener noreferrer"
 154            className="underline"
 155          >
 156            {t('Create an issue or PR for other APIs')}
 157          </a>
 158        </p>
 159      </div>
 160    )
 161  }
 162