Polish WebUI streaming and provider settings

This commit is contained in:
Xubin Ren 2026-05-17 17:41:33 +08:00
parent 9340567f2d
commit 4b5de66c58
12 changed files with 456 additions and 97 deletions

View File

@ -230,6 +230,25 @@ def _mask_secret_hint(secret: str | None) -> str | None:
return f"{secret[:4]}••••{secret[-4:]}" return f"{secret[:4]}••••{secret[-4:]}"
def _provider_requires_api_key(spec: Any) -> bool:
if spec.backend == "azure_openai":
return True
if spec.is_local or spec.is_direct:
return False
return True
def _provider_configured_for_settings(spec: Any, provider_config: Any) -> bool:
if _provider_requires_api_key(spec):
return bool(provider_config.api_key)
return bool(
provider_config.api_key
or provider_config.api_base
or getattr(provider_config, "region", None)
or getattr(provider_config, "profile", None)
)
_WEB_SEARCH_PROVIDER_OPTIONS: tuple[dict[str, str], ...] = ( _WEB_SEARCH_PROVIDER_OPTIONS: tuple[dict[str, str], ...] = (
{"name": "duckduckgo", "label": "DuckDuckGo", "credential": "none"}, {"name": "duckduckgo", "label": "DuckDuckGo", "credential": "none"},
{"name": "brave", "label": "Brave Search", "credential": "api_key"}, {"name": "brave", "label": "Brave Search", "credential": "api_key"},
@ -786,13 +805,14 @@ class WebSocketChannel(BaseChannel):
providers = [] providers = []
for spec in PROVIDERS: for spec in PROVIDERS:
provider_config = getattr(config.providers, spec.name, None) provider_config = getattr(config.providers, spec.name, None)
if provider_config is None or spec.is_oauth or spec.is_local: if provider_config is None or spec.is_oauth:
continue continue
providers.append( providers.append(
{ {
"name": spec.name, "name": spec.name,
"label": spec.label, "label": spec.label,
"configured": bool(provider_config.api_key), "configured": _provider_configured_for_settings(spec, provider_config),
"api_key_required": _provider_requires_api_key(spec),
"api_key_hint": _mask_secret_hint(provider_config.api_key), "api_key_hint": _mask_secret_hint(provider_config.api_key),
"api_base": provider_config.api_base, "api_base": provider_config.api_base,
"default_api_base": spec.default_api_base or None, "default_api_base": spec.default_api_base or None,
@ -862,7 +882,12 @@ class WebSocketChannel(BaseChannel):
if find_by_name(provider) is None: if find_by_name(provider) is None:
return _http_error(400, "unknown provider") return _http_error(400, "unknown provider")
provider_config = getattr(config.providers, provider, None) provider_config = getattr(config.providers, provider, None)
if provider_config is None or not provider_config.api_key: spec = find_by_name(provider)
if (
provider_config is None
or spec is None
or not _provider_configured_for_settings(spec, provider_config)
):
return _http_error(400, "provider is not configured") return _http_error(400, "provider is not configured")
if defaults.provider != provider: if defaults.provider != provider:
defaults.provider = provider defaults.provider = provider
@ -885,7 +910,7 @@ class WebSocketChannel(BaseChannel):
if not provider_name: if not provider_name:
return _http_error(400, "provider is required") return _http_error(400, "provider is required")
spec = find_by_name(provider_name) spec = find_by_name(provider_name)
if spec is None or spec.is_oauth or spec.is_local: if spec is None or spec.is_oauth:
return _http_error(400, "unknown provider") return _http_error(400, "unknown provider")
config = load_config() config = load_config()

View File

@ -396,7 +396,7 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
name="vllm", name="vllm",
keywords=("vllm",), keywords=("vllm",),
env_key="HOSTED_VLLM_API_KEY", env_key="HOSTED_VLLM_API_KEY",
display_name="vLLM/Local", display_name="vLLM",
backend="openai_compat", backend="openai_compat",
is_local=True, is_local=True,
), ),

View File

@ -946,7 +946,12 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
providers = {provider["name"]: provider for provider in body["providers"]} providers = {provider["name"]: provider for provider in body["providers"]}
assert providers["openai"]["configured"] is True assert providers["openai"]["configured"] is True
assert providers["openai"]["api_key_hint"] == "secr••••-key" assert providers["openai"]["api_key_hint"] == "secr••••-key"
assert providers["azure_openai"]["api_key_required"] is True
assert providers["openrouter"]["configured"] is False assert providers["openrouter"]["configured"] is False
assert providers["openrouter"]["api_key_required"] is True
assert providers["atomic_chat"]["configured"] is False
assert providers["atomic_chat"]["api_key_required"] is False
assert providers["atomic_chat"]["default_api_base"] == "http://localhost:1337/v1"
assert body["agent"]["has_api_key"] is True assert body["agent"]["has_api_key"] is True
assert body["web_search"]["provider"] == "brave" assert body["web_search"]["provider"] == "brave"
assert body["web_search"]["api_key_hint"] == "brav••••cret" assert body["web_search"]["api_key_hint"] == "brav••••cret"
@ -969,10 +974,24 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
assert provider_rows["openrouter"]["configured"] is True assert provider_rows["openrouter"]["configured"] is True
assert "sk-or-test" not in provider_updated.text assert "sk-or-test" not in provider_updated.text
local_provider_updated = await _http_get(
"http://127.0.0.1:"
f"{port}/api/settings/provider/update?provider=atomic_chat"
"&api_base=http%3A%2F%2Flocalhost%3A1337%2Fv1",
headers={"Authorization": "Bearer tok"},
)
assert local_provider_updated.status_code == 200
local_provider_body = local_provider_updated.json()
local_provider_rows = {
provider["name"]: provider for provider in local_provider_body["providers"]
}
assert local_provider_rows["atomic_chat"]["configured"] is True
assert "localhost:1337" in local_provider_updated.text
updated = await _http_get( updated = await _http_get(
"http://127.0.0.1:" "http://127.0.0.1:"
f"{port}/api/settings/update?model=openrouter/test" f"{port}/api/settings/update?model=atomic_chat/test"
"&provider=openrouter", "&provider=atomic_chat",
headers={"Authorization": "Bearer tok"}, headers={"Authorization": "Bearer tok"},
) )
assert updated.status_code == 200 assert updated.status_code == 200
@ -992,10 +1011,11 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
assert search_body["web_search"]["base_url"] == "https://search.example.com" assert search_body["web_search"]["base_url"] == "https://search.example.com"
saved = load_config(config_path) saved = load_config(config_path)
assert saved.agents.defaults.model == "openrouter/test" assert saved.agents.defaults.model == "atomic_chat/test"
assert saved.agents.defaults.provider == "openrouter" assert saved.agents.defaults.provider == "atomic_chat"
assert saved.providers.openrouter.api_key == "sk-or-test" assert saved.providers.openrouter.api_key == "sk-or-test"
assert saved.providers.openrouter.api_base == "https://openrouter.ai/api/v1" assert saved.providers.openrouter.api_base == "https://openrouter.ai/api/v1"
assert saved.providers.atomic_chat.api_base == "http://localhost:1337/v1"
assert saved.tools.web.search.provider == "searxng" assert saved.tools.web.search.provider == "searxng"
assert saved.tools.web.search.api_key == "" assert saved.tools.web.search.api_key == ""
assert saved.tools.web.search.base_url == "https://search.example.com" assert saved.tools.web.search.base_url == "https://search.example.com"

View File

@ -9,6 +9,7 @@ interface CodeBlockProps {
language?: string; language?: string;
code: string; code: string;
className?: string; className?: string;
highlight?: boolean;
} }
interface HighlightedCodeProps { interface HighlightedCodeProps {
@ -60,7 +61,12 @@ function PlainCodeFallback({ code }: { code: string }) {
); );
} }
export function CodeBlock({ language, code, className }: CodeBlockProps) { export function CodeBlock({
language,
code,
className,
highlight = true,
}: CodeBlockProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const isDark = useThemeValue() === "dark"; const isDark = useThemeValue() === "dark";
@ -111,9 +117,13 @@ export function CodeBlock({ language, code, className }: CodeBlockProps) {
<span>{copied ? t("code.copied") : t("code.copy")}</span> <span>{copied ? t("code.copied") : t("code.copy")}</span>
</button> </button>
</div> </div>
<Suspense fallback={<PlainCodeFallback code={code} />}> {highlight ? (
<LazyHighlightedCode language={language} code={code} isDark={isDark} /> <Suspense fallback={<PlainCodeFallback code={code} />}>
</Suspense> <LazyHighlightedCode language={language} code={code} isDark={isDark} />
</Suspense>
) : (
<PlainCodeFallback code={code} />
)}
</div> </div>
); );
} }

View File

@ -1,15 +1,46 @@
import { Suspense, lazy } from "react"; import {
Suspense,
lazy,
memo,
startTransition,
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
interface MarkdownTextProps { interface MarkdownTextProps {
children: string; children: string;
className?: string; className?: string;
streaming?: boolean;
} }
const loadMarkdownRenderer = () => import("@/components/MarkdownTextRenderer"); const loadMarkdownRenderer = () => import("@/components/MarkdownTextRenderer");
const LazyMarkdownRenderer = lazy(loadMarkdownRenderer); const LazyMarkdownRenderer = lazy(loadMarkdownRenderer);
const MemoizedMarkdownRenderer = memo(function MemoizedMarkdownRenderer({
source,
className,
highlightCode,
}: {
source: string;
className?: string;
highlightCode: boolean;
}) {
return (
<LazyMarkdownRenderer className={className} highlightCode={highlightCode}>
{source}
</LazyMarkdownRenderer>
);
});
const SHORT_STREAM_COMMIT_MS = 80;
const MEDIUM_STREAM_COMMIT_MS = 140;
const LONG_STREAM_COMMIT_MS = 220;
export function preloadMarkdownText(): void { export function preloadMarkdownText(): void {
void loadMarkdownRenderer(); void loadMarkdownRenderer();
} }
@ -19,7 +50,18 @@ export function preloadMarkdownText(): void {
* ``remark-math`` / ``rehype-katex``, and fenced code blocks delegated to * ``remark-math`` / ``rehype-katex``, and fenced code blocks delegated to
* ``CodeBlock`` for copy-to-clipboard and syntax highlighting. * ``CodeBlock`` for copy-to-clipboard and syntax highlighting.
*/ */
export function MarkdownText({ children, className }: MarkdownTextProps) { export function MarkdownText({
children,
className,
streaming = false,
}: MarkdownTextProps) {
const renderedSource = useStreamingMarkdownSource(children, streaming);
const highlightCode = !streaming && renderedSource === children;
useEffect(() => {
if (streaming) preloadMarkdownText();
}, [streaming]);
return ( return (
<Suspense <Suspense
fallback={ fallback={
@ -29,11 +71,73 @@ export function MarkdownText({ children, className }: MarkdownTextProps) {
className, className,
)} )}
> >
{children} {renderedSource}
</div> </div>
} }
> >
<LazyMarkdownRenderer className={className}>{children}</LazyMarkdownRenderer> <MemoizedMarkdownRenderer
source={renderedSource}
className={className}
highlightCode={highlightCode}
/>
</Suspense> </Suspense>
); );
} }
function useStreamingMarkdownSource(source: string, streaming: boolean): string {
const [renderedSource, setRenderedSource] = useState(source);
const latestSourceRef = useRef(source);
const renderedSourceRef = useRef(source);
const timerRef = useRef<number | null>(null);
const clearPendingCommit = useCallback(() => {
if (timerRef.current !== null) {
window.clearTimeout(timerRef.current);
timerRef.current = null;
}
}, []);
const commitSource = useCallback((next: string, urgent: boolean) => {
if (renderedSourceRef.current === next) return;
renderedSourceRef.current = next;
if (urgent) {
setRenderedSource(next);
return;
}
startTransition(() => setRenderedSource(next));
}, []);
const scheduleCommit = useCallback(() => {
if (timerRef.current !== null) return;
timerRef.current = window.setTimeout(() => {
timerRef.current = null;
commitSource(latestSourceRef.current, false);
}, streamingCommitDelay(latestSourceRef.current.length));
}, [commitSource]);
latestSourceRef.current = source;
useLayoutEffect(() => {
latestSourceRef.current = source;
if (!streaming) {
clearPendingCommit();
commitSource(source, true);
}
}, [clearPendingCommit, commitSource, source, streaming]);
useEffect(() => {
latestSourceRef.current = source;
if (!streaming) return;
scheduleCommit();
}, [scheduleCommit, source, streaming]);
useEffect(() => clearPendingCommit, [clearPendingCommit]);
return renderedSource;
}
function streamingCommitDelay(length: number): number {
if (length > 24_000) return LONG_STREAM_COMMIT_MS;
if (length > 8_000) return MEDIUM_STREAM_COMMIT_MS;
return SHORT_STREAM_COMMIT_MS;
}

View File

@ -1,4 +1,5 @@
import { Children, isValidElement } from "react"; import { Children, isValidElement, useMemo } from "react";
import type { Components } from "react-markdown";
import ReactMarkdown from "react-markdown"; import ReactMarkdown from "react-markdown";
import rehypeKatex from "rehype-katex"; import rehypeKatex from "rehype-katex";
import remarkGfm from "remark-gfm"; import remarkGfm from "remark-gfm";
@ -12,8 +13,12 @@ import "katex/dist/katex.min.css";
interface MarkdownTextRendererProps { interface MarkdownTextRendererProps {
children: string; children: string;
className?: string; className?: string;
highlightCode?: boolean;
} }
const remarkPlugins = [remarkGfm, remarkMath];
const rehypePlugins = [rehypeKatex];
/** /**
* Heavy markdown stack (GFM, math, KaTeX, syntax highlighting) kept in a * Heavy markdown stack (GFM, math, KaTeX, syntax highlighting) kept in a
* separate chunk so the app shell can paint sooner on refresh. * separate chunk so the app shell can paint sooner on refresh.
@ -21,7 +26,88 @@ interface MarkdownTextRendererProps {
export default function MarkdownTextRenderer({ export default function MarkdownTextRenderer({
children, children,
className, className,
highlightCode = true,
}: MarkdownTextRendererProps) { }: MarkdownTextRendererProps) {
const components = useMemo<Components>(
() => ({
code({ className: cls, children: kids, ...props }) {
const match = /language-(\w+)/.exec(cls || "");
if (match) {
const code = String(kids).replace(/\n$/, "");
return (
<CodeBlock
language={match[1]}
code={code}
className="my-3"
highlight={highlightCode}
/>
);
}
const raw = String(kids).replace(/\n$/, "");
/** Plain fenced ``` blocks (no language) & wide one-liners: block monospace, not inline pill. */
const widePlainBlock = raw.includes("\n") || raw.length > 120;
if (widePlainBlock) {
return (
<code
className={cn(
"block min-w-0 whitespace-pre bg-transparent p-0 font-mono text-[0.8125rem]",
"leading-snug text-inherit",
cls,
)}
{...props}
>
{kids}
</code>
);
}
return (
<code
className={cn(
"rounded bg-muted px-1 py-0.5 font-mono text-[0.85em]",
cls,
)}
{...props}
>
{kids}
</code>
);
},
pre({ children: markdownChildren }) {
const kids = Children.toArray(markdownChildren);
const lone = kids.length === 1 ? kids[0] : null;
/** Highlighted fences render ``CodeBlock`` (block shell); skip invalid ``<pre><div>``. */
if (lone != null && isValidElement(lone) && lone.type === CodeBlock) {
return <>{markdownChildren}</>;
}
return (
<pre
className={cn(
"my-3 overflow-x-auto rounded-lg border border-border/60 bg-muted/35",
"p-3 font-mono text-[0.8125rem] leading-snug text-foreground/90",
"whitespace-pre [overflow-wrap:normal]",
)}
>
{markdownChildren}
</pre>
);
},
a({ href, children: markdownChildren, ...props }) {
return (
<a
href={href}
target="_blank"
rel="noreferrer noopener"
className="text-primary underline underline-offset-2 hover:opacity-80"
{...props}
>
{markdownChildren}
</a>
);
},
}),
[highlightCode],
);
return ( return (
<div <div
className={cn( className={cn(
@ -42,77 +128,9 @@ export default function MarkdownTextRenderer({
style={{ lineHeight: "var(--cjk-line-height)" }} style={{ lineHeight: "var(--cjk-line-height)" }}
> >
<ReactMarkdown <ReactMarkdown
remarkPlugins={[remarkGfm, remarkMath]} remarkPlugins={remarkPlugins}
rehypePlugins={[rehypeKatex]} rehypePlugins={rehypePlugins}
components={{ components={components}
code({ className: cls, children: kids, ...props }) {
const match = /language-(\w+)/.exec(cls || "");
if (match) {
const code = String(kids).replace(/\n$/, "");
return <CodeBlock language={match[1]} code={code} className="my-3" />;
}
const raw = String(kids).replace(/\n$/, "");
/** Plain fenced ``` blocks (no language) & wide one-liners: block monospace, not inline pill. */
const widePlainBlock = raw.includes("\n") || raw.length > 120;
if (widePlainBlock) {
return (
<code
className={cn(
"block min-w-0 whitespace-pre bg-transparent p-0 font-mono text-[0.8125rem]",
"leading-snug text-inherit",
cls,
)}
{...props}
>
{kids}
</code>
);
}
return (
<code
className={cn(
"rounded bg-muted px-1 py-0.5 font-mono text-[0.85em]",
cls,
)}
{...props}
>
{kids}
</code>
);
},
pre({ children: markdownChildren }) {
const kids = Children.toArray(markdownChildren);
const lone = kids.length === 1 ? kids[0] : null;
/** Highlighted fences render ``CodeBlock`` (block shell); skip invalid ``<pre><div>``. */
if (lone != null && isValidElement(lone) && lone.type === CodeBlock) {
return <>{markdownChildren}</>;
}
return (
<pre
className={cn(
"my-3 overflow-x-auto rounded-lg border border-border/60 bg-muted/35",
"p-3 font-mono text-[0.8125rem] leading-snug text-foreground/90",
"whitespace-pre [overflow-wrap:normal]",
)}
>
{markdownChildren}
</pre>
);
},
a({ href, children: markdownChildren, ...props }) {
return (
<a
href={href}
target="_blank"
rel="noreferrer noopener"
className="text-primary underline underline-offset-2 hover:opacity-80"
{...props}
>
{markdownChildren}
</a>
);
},
}}
> >
{children} {children}
</ReactMarkdown> </ReactMarkdown>

View File

@ -1,6 +1,5 @@
import { import {
useCallback, useCallback,
useDeferredValue,
useEffect, useEffect,
useRef, useRef,
useState, useState,
@ -120,7 +119,7 @@ export function MessageBubble({
<TypingDots /> <TypingDots />
) : empty && message.isStreaming ? null : ( ) : empty && message.isStreaming ? null : (
<> <>
<MarkdownText>{message.content}</MarkdownText> <MarkdownText streaming={!!message.isStreaming}>{message.content}</MarkdownText>
{media.length > 0 ? <MessageMedia media={media} align="left" /> : null} {media.length > 0 ? <MessageMedia media={media} align="left" /> : null}
{showAssistantFooterRow ? ( {showAssistantFooterRow ? (
<div className="mt-2 flex min-h-8 flex-wrap items-center gap-x-2 gap-y-1 text-muted-foreground"> <div className="mt-2 flex min-h-8 flex-wrap items-center gap-x-2 gap-y-1 text-muted-foreground">
@ -480,8 +479,6 @@ export function ReasoningBubble({
embeddedInCluster = false, embeddedInCluster = false,
}: ReasoningBubbleProps) { }: ReasoningBubbleProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const deferredText = useDeferredValue(text);
const markdownSource = streaming ? deferredText : text;
const [userToggled, setUserToggled] = useState(false); const [userToggled, setUserToggled] = useState(false);
const [openLocal, setOpenLocal] = useState(true); const [openLocal, setOpenLocal] = useState(true);
const open = userToggled ? openLocal : streaming; const open = userToggled ? openLocal : streaming;
@ -537,6 +534,7 @@ export function ReasoningBubble({
)} )}
> >
<MarkdownText <MarkdownText
streaming={streaming}
className={cn( className={cn(
"text-[12.5px] italic text-muted-foreground/88", "text-[12.5px] italic text-muted-foreground/88",
"prose-p:my-1.5 prose-li:my-0.5", "prose-p:my-1.5 prose-li:my-0.5",
@ -547,7 +545,7 @@ export function ReasoningBubble({
"prose-code:text-[0.92em]", "prose-code:text-[0.92em]",
)} )}
> >
{markdownSource} {text}
</MarkdownText> </MarkdownText>
</div> </div>
)} )}

View File

@ -52,6 +52,13 @@ import type { SettingsPayload, WebSearchSettingsUpdate } from "@/lib/types";
type SettingsSectionKey = "general" | "byok"; type SettingsSectionKey = "general" | "byok";
type ByokPaneKey = "llm" | "web-search"; type ByokPaneKey = "llm" | "web-search";
const LOCAL_UNCONFIGURED_PROVIDER_ORDER = new Map(
["vllm", "ollama", "lm_studio", "atomic_chat", "ovms"].map((name, index) => [
name,
index,
]),
);
interface SettingsViewProps { interface SettingsViewProps {
theme: "light" | "dark"; theme: "light" | "dark";
onToggleTheme: () => void; onToggleTheme: () => void;
@ -176,7 +183,8 @@ export function SettingsView({
if (!provider) return; if (!provider) return;
const providerForm = providerForms[providerName] ?? { apiKey: "", apiBase: "" }; const providerForm = providerForms[providerName] ?? { apiKey: "", apiBase: "" };
const apiKey = providerForm.apiKey.trim(); const apiKey = providerForm.apiKey.trim();
if (!provider.configured && !apiKey) { const apiKeyRequired = provider.api_key_required ?? true;
if (!provider.configured && apiKeyRequired && !apiKey) {
setError(t("settings.byok.apiKeyRequired")); setError(t("settings.byok.apiKeyRequired"));
return; return;
} }
@ -917,7 +925,10 @@ function ByokSettings({
const [activePane, setActivePane] = useState<ByokPaneKey>("llm"); const [activePane, setActivePane] = useState<ByokPaneKey>("llm");
const [showAllUnconfigured, setShowAllUnconfigured] = useState(false); const [showAllUnconfigured, setShowAllUnconfigured] = useState(false);
const configuredProviders = settings.providers.filter((provider) => provider.configured); const configuredProviders = settings.providers.filter((provider) => provider.configured);
const unconfiguredProviders = settings.providers.filter((provider) => !provider.configured); const unconfiguredProviders = useMemo(
() => orderUnconfiguredProviders(settings.providers.filter((provider) => !provider.configured)),
[settings.providers],
);
const initialUnconfiguredCount = 6; const initialUnconfiguredCount = 6;
const visibleUnconfiguredProviders = showAllUnconfigured const visibleUnconfiguredProviders = showAllUnconfigured
? unconfiguredProviders ? unconfiguredProviders
@ -935,6 +946,12 @@ function ByokSettings({
const saving = providerSaving === provider.name; const saving = providerSaving === provider.name;
const keyVisible = !!visibleProviderKeys[provider.name]; const keyVisible = !!visibleProviderKeys[provider.name];
const editingKey = !provider.configured || !!editingProviderKeys[provider.name]; const editingKey = !provider.configured || !!editingProviderKeys[provider.name];
const apiKeyRequired = provider.api_key_required ?? true;
const apiKey = form.apiKey.trim();
const apiBase = form.apiBase.trim();
const missingRequiredApiKey = apiKeyRequired && !provider.configured && !apiKey;
const missingOptionalCredential =
!apiKeyRequired && !provider.configured && !apiKey && !apiBase;
return ( return (
<div <div
key={provider.name} key={provider.name}
@ -1045,7 +1062,7 @@ function ByokSettings({
size="sm" size="sm"
variant="outline" variant="outline"
onClick={() => onSaveProvider(provider.name)} onClick={() => onSaveProvider(provider.name)}
disabled={saving || (!provider.configured && !form.apiKey.trim())} disabled={saving || missingRequiredApiKey || missingOptionalCredential}
className="rounded-full" className="rounded-full"
> >
{saving ? t("settings.actions.saving") : t("settings.actions.save")} {saving ? t("settings.actions.saving") : t("settings.actions.save")}
@ -1188,6 +1205,25 @@ function ByokEmptyState({ children }: { children: ReactNode }) {
); );
} }
function orderUnconfiguredProviders(
providers: SettingsPayload["providers"],
): SettingsPayload["providers"] {
return providers
.map((provider, index) => ({ provider, index }))
.sort((left, right) => {
const rank = providerVisibilityRank(left.provider) - providerVisibilityRank(right.provider);
return rank || left.index - right.index;
})
.map(({ provider }) => provider);
}
function providerVisibilityRank(provider: SettingsPayload["providers"][number]): number {
const localRank = LOCAL_UNCONFIGURED_PROVIDER_ORDER.get(provider.name);
if (localRank !== undefined) return localRank;
if ((provider.api_key_required ?? true) === false) return 100;
return 200;
}
const PROVIDER_ICONS: Record<string, LucideIcon> = { const PROVIDER_ICONS: Record<string, LucideIcon> = {
custom: Hexagon, custom: Hexagon,
openrouter: Sparkles, openrouter: Sparkles,
@ -1212,6 +1248,12 @@ const PROVIDER_ICONS: Record<string, LucideIcon> = {
qianfan: Database, qianfan: Database,
azure_openai: Cloud, azure_openai: Cloud,
bedrock: Database, bedrock: Database,
vllm: Cpu,
ollama: Cpu,
lm_studio: Cpu,
atomic_chat: Cpu,
ovms: Cpu,
nvidia: Zap,
}; };
function ProviderIcon({ provider }: { provider: string }) { function ProviderIcon({ provider }: { provider: string }) {

View File

@ -110,6 +110,7 @@ export interface SettingsPayload {
name: string; name: string;
label: string; label: string;
configured: boolean; configured: boolean;
api_key_required?: boolean;
api_key_hint?: string | null; api_key_hint?: string | null;
api_base?: string | null; api_base?: string | null;
default_api_base?: string | null; default_api_base?: string | null;

View File

@ -198,8 +198,52 @@ describe("App layout", () => {
name: "openrouter", name: "openrouter",
label: "OpenRouter", label: "OpenRouter",
configured: false, configured: false,
api_key_required: true,
default_api_base: "https://openrouter.ai/api/v1", default_api_base: "https://openrouter.ai/api/v1",
}, },
{
name: "azure_openai",
label: "Azure OpenAI",
configured: false,
api_key_required: true,
},
{
name: "huggingface",
label: "Hugging Face",
configured: false,
api_key_required: true,
},
{
name: "siliconflow",
label: "SiliconFlow",
configured: false,
api_key_required: true,
},
{
name: "volcengine",
label: "VolcEngine",
configured: false,
api_key_required: true,
},
{
name: "byteplus",
label: "BytePlus",
configured: false,
api_key_required: true,
},
{
name: "qianfan",
label: "Qianfan",
configured: false,
api_key_required: true,
},
{
name: "atomic_chat",
label: "Atomic Chat",
configured: false,
api_key_required: false,
default_api_base: "http://localhost:1337/v1",
},
], ],
web_search: { web_search: {
provider: "brave", provider: "brave",
@ -254,6 +298,9 @@ describe("App layout", () => {
fireEvent.click(screen.getByText("OpenAI")); fireEvent.click(screen.getByText("OpenAI"));
expect(screen.getByText("open••••-key")).toBeInTheDocument(); expect(screen.getByText("open••••-key")).toBeInTheDocument();
expect(screen.queryByDisplayValue("unsaved-openai-key")).not.toBeInTheDocument(); expect(screen.queryByDisplayValue("unsaved-openai-key")).not.toBeInTheDocument();
fireEvent.click(screen.getByText("Atomic Chat"));
expect(screen.getByDisplayValue("http://localhost:1337/v1")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Save" })).toBeEnabled();
fireEvent.click(screen.getByRole("tab", { name: "Web Search" })); fireEvent.click(screen.getByRole("tab", { name: "Web Search" }));
expect(screen.getByText("Search provider")).toBeInTheDocument(); expect(screen.getByText("Search provider")).toBeInTheDocument();

View File

@ -35,6 +35,18 @@ vi.mock("react-syntax-highlighter/dist/esm/styles/prism/one-light", () => ({
})); }));
describe("CodeBlock", () => { describe("CodeBlock", () => {
it("renders plain code without mounting the highlighter when highlighting is disabled", () => {
render(
<ThemeProvider theme="dark">
<CodeBlock language="ts" code="const value = 1;" highlight={false} />
</ThemeProvider>,
);
expect(screen.queryByTestId("highlighted-code")).not.toBeInTheDocument();
expect(screen.getByText("const value = 1;")).toBeInTheDocument();
expect(screen.getByText("ts")).toBeInTheDocument();
});
it("reads theme from context without creating per-block observers", async () => { it("reads theme from context without creating per-block observers", async () => {
const originalMutationObserver = globalThis.MutationObserver; const originalMutationObserver = globalThis.MutationObserver;
const observer = vi.fn(); const observer = vi.fn();

View File

@ -0,0 +1,82 @@
import { act, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { MarkdownText } from "@/components/MarkdownText";
const rendererSpy = vi.hoisted(() => vi.fn());
vi.mock("@/components/MarkdownTextRenderer", () => ({
default: ({
children,
highlightCode,
}: {
children: string;
highlightCode?: boolean;
}) => {
rendererSpy({ children, highlightCode });
return (
<div
data-testid="markdown-renderer"
data-highlight-code={String(highlightCode)}
>
{children}
</div>
);
},
}));
describe("MarkdownText", () => {
it("throttles streaming markdown commits and flushes before final highlighting", async () => {
rendererSpy.mockClear();
vi.useFakeTimers();
try {
const { rerender } = render(
<MarkdownText streaming>hello</MarkdownText>,
);
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(screen.getByTestId("markdown-renderer")).toHaveTextContent("hello");
expect(screen.getByTestId("markdown-renderer")).toHaveAttribute(
"data-highlight-code",
"false",
);
expect(rendererSpy).toHaveBeenCalledTimes(1);
rerender(<MarkdownText streaming>hello world</MarkdownText>);
expect(screen.getByTestId("markdown-renderer")).toHaveTextContent("hello");
expect(rendererSpy).toHaveBeenCalledTimes(1);
act(() => {
vi.advanceTimersByTime(79);
});
expect(screen.getByTestId("markdown-renderer")).toHaveTextContent("hello");
expect(rendererSpy).toHaveBeenCalledTimes(1);
act(() => {
vi.advanceTimersByTime(1);
});
await act(async () => {
await Promise.resolve();
});
expect(screen.getByTestId("markdown-renderer")).toHaveTextContent("hello world");
expect(rendererSpy).toHaveBeenCalledTimes(2);
rerender(<MarkdownText streaming>hello world!!!</MarkdownText>);
expect(screen.getByTestId("markdown-renderer")).toHaveTextContent("hello world");
rerender(<MarkdownText>hello world!!!</MarkdownText>);
expect(screen.getByTestId("markdown-renderer")).toHaveTextContent("hello world!!!");
expect(screen.getByTestId("markdown-renderer")).toHaveAttribute(
"data-highlight-code",
"true",
);
} finally {
vi.useRealTimers();
}
});
});