Add DeepSeek/xAI/OpenAI-compatible sentiment providers; custom dark dropdown
Deploy / lint (push) Successful in 5s
Deploy / test (push) Successful in 32s
Deploy / deploy (push) Successful in 22s

Providers (admin-switchable, no redeploy):
- DeepSeek and any OpenAI-compatible endpoint (OpenRouter, Together,
  Groq, local Ollama) via a generic Chat Completions adapter + base_url
- xAI Grok with Live Search (search_parameters web+X, citations) —
  grounded tier alongside OpenAI and Gemini
- DeepSeek / generic compatible endpoints are ungrounded (no web
  search); UI shows an amber warning and labels each provider's grounding
- Optional env fallbacks DEEPSEEK_API_KEY / XAI_API_KEY

UI: replace native <select> (unstyleable white popup on Windows) with a
custom dark Dropdown component everywhere — sentiment provider, scanner
filters, market sort, indicators, admin universe, user role.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-13 12:42:04 +02:00
parent d53ed972d1
commit 126c3b3c17
16 changed files with 521 additions and 98 deletions
@@ -4,7 +4,7 @@ import {
useUpdateSentimentSettings,
useTestSentimentProvider,
} from '../../hooks/useAdmin';
import { Select } from '../ui/Field';
import { Dropdown, type DropdownOption } from '../ui/Dropdown';
import { SkeletonTable } from '../ui/Skeleton';
import type { SentimentTestResult } from '../../lib/types';
@@ -14,6 +14,18 @@ const SOURCE_LABEL: Record<string, string> = {
none: 'not configured',
};
const PROVIDER_LABELS: Record<string, string> = {
openai: 'OpenAI — web search',
gemini: 'Google Gemini — web search',
deepseek: 'DeepSeek — cheap, no web search',
xai: 'xAI Grok — Live Search',
openai_compatible: 'OpenAI-compatible — custom URL',
};
function providerLabel(p: string): string {
return PROVIDER_LABELS[p] ?? p;
}
export function SentimentProviderSettings() {
const { data, isLoading, isError, error } = useSentimentSettings();
const update = useUpdateSentimentSettings();
@@ -22,12 +34,14 @@ export function SentimentProviderSettings() {
const [provider, setProvider] = useState('openai');
const [model, setModel] = useState('');
const [apiKey, setApiKey] = useState('');
const [baseUrl, setBaseUrl] = useState('');
const [testResult, setTestResult] = useState<SentimentTestResult | null>(null);
useEffect(() => {
if (data) {
setProvider(data.provider);
setModel(data.model);
setBaseUrl(data.base_url ?? '');
}
}, [data]);
@@ -35,10 +49,17 @@ export function SentimentProviderSettings() {
if (isError) return <p className="text-sm text-red-400">{(error as Error)?.message || 'Failed to load sentiment settings'}</p>;
if (!data) return null;
const grounded = data.web_search_providers ?? ['openai', 'gemini'];
const needsBaseUrl = (data.custom_base_url_providers ?? ['openai_compatible']).includes(provider);
const isGrounded = grounded.includes(provider);
const providerOptions: DropdownOption[] = data.valid_providers.map((p) => ({
value: p,
label: providerLabel(p),
}));
const onProviderChange = (next: string) => {
setProvider(next);
// Auto-fill the model with the new provider's default unless the user has a
// custom value that isn't the previous provider's default.
const defaults = data.default_models;
if (!model || Object.values(defaults).includes(model)) {
setModel(defaults[next] ?? '');
@@ -50,6 +71,7 @@ export function SentimentProviderSettings() {
update.mutate({
provider,
model,
...(needsBaseUrl ? { base_url: baseUrl } : {}),
...(apiKey ? { api_key: apiKey } : {}),
});
setApiKey('');
@@ -74,11 +96,7 @@ export function SentimentProviderSettings() {
<div className="grid gap-4 md:grid-cols-2">
<label className="block space-y-1">
<span className="text-xs text-gray-400">Provider</span>
<Select value={provider} onChange={(e) => onProviderChange(e.target.value)} className="w-full !py-2">
{data.valid_providers.map((p) => (
<option key={p} value={p}>{p}</option>
))}
</Select>
<Dropdown value={provider} onChange={onProviderChange} options={providerOptions} ariaLabel="Sentiment provider" />
</label>
<label className="block space-y-1">
@@ -87,12 +105,33 @@ export function SentimentProviderSettings() {
type="text"
value={model}
onChange={(e) => setModel(e.target.value)}
placeholder={data.default_models[provider] ?? ''}
placeholder={data.default_models[provider] || 'model id'}
className="w-full input-glass px-3 py-2 text-sm"
/>
</label>
</div>
{needsBaseUrl && (
<label className="block space-y-1">
<span className="text-xs text-gray-400">Base URL</span>
<input
type="text"
value={baseUrl}
onChange={(e) => setBaseUrl(e.target.value)}
placeholder="https://openrouter.ai/api/v1"
className="w-full input-glass px-3 py-2 text-sm"
/>
<span className="text-[11px] text-gray-600">OpenAI-compatible Chat Completions endpoint (OpenRouter, Together, Groq, local Ollama).</span>
</label>
)}
{!isGrounded && (
<div className="rounded-lg border border-amber-500/20 bg-amber-500/10 px-4 py-2.5 text-xs text-amber-300">
No live web search. This provider scores sentiment from the model's training knowledge,
not current news — cheaper, but not real-time. OpenAI and Gemini are grounded in live search.
</div>
)}
<label className="block space-y-1">
<span className="text-xs text-gray-400">API Key</span>
<input
@@ -5,6 +5,7 @@ import {
useUpdateTickerUniverseSetting,
} from '../../hooks/useAdmin';
import type { TickerUniverse } from '../../lib/types';
import { Dropdown } from '../ui/Dropdown';
const UNIVERSE_OPTIONS: Array<{ value: TickerUniverse; label: string }> = [
{ value: 'sp500', label: 'S&P 500' },
@@ -50,18 +51,11 @@ export function TickerUniverseBootstrap() {
<div className="grid gap-4 md:grid-cols-3">
<label className="block space-y-1 md:col-span-2">
<span className="text-xs text-gray-400">Default Universe</span>
<select
<Dropdown
value={universe}
onChange={(e) => setUniverse(e.target.value as TickerUniverse)}
className="w-full input-glass px-3 py-2 text-sm"
disabled={isLoading || updateDefault.isPending || bootstrap.isPending}
>
{UNIVERSE_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
onChange={(v) => setUniverse(v as TickerUniverse)}
options={UNIVERSE_OPTIONS.map((o) => ({ value: o.value, label: o.label }))}
/>
</label>
<label className="flex items-end gap-2 pb-2">
+10 -4
View File
@@ -1,6 +1,7 @@
import { useState } from 'react';
import { useUsers, useCreateUser, useUpdateAccess, useResetPassword } from '../../hooks/useAdmin';
import { SkeletonTable } from '../ui/Skeleton';
import { Dropdown } from '../ui/Dropdown';
import type { AdminUser } from '../../lib/types';
export function UserTable() {
@@ -53,10 +54,15 @@ export function UserTable() {
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs text-gray-400">Role</label>
<select value={newRole} onChange={(e) => setNewRole(e.target.value)} className="input-glass px-3 py-2 text-sm">
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
<Dropdown
value={newRole}
onChange={setNewRole}
className="w-32"
options={[
{ value: 'user', label: 'User' },
{ value: 'admin', label: 'Admin' },
]}
/>
</div>
<label className="flex items-center gap-2 text-sm text-gray-300 pb-1">
<input type="checkbox" checked={newAccess} onChange={(e) => setNewAccess(e.target.checked)}