``. */
+ if (lone != null && isValidElement(lone) && lone.type === CodeBlock) {
+ return <>{markdownChildren}>;
+ }
+ return (
+
+ );
+ },
+ a({ href, children: markdownChildren, ...props }) {
+ return (
+
;
- }
- 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 (
-
- {kids}
-
- );
- }
- return (
-
- {kids}
-
- );
- },
- pre({ children: markdownChildren }) {
- const kids = Children.toArray(markdownChildren);
- const lone = kids.length === 1 ? kids[0] : null;
- /** Highlighted fences render ``CodeBlock`` (block shell); skip invalid ``
``. */
- if (lone != null && isValidElement(lone) && lone.type === CodeBlock) {
- return <>{markdownChildren}>;
- }
- return (
-
- {markdownChildren}
-
- );
- },
- a({ href, children: markdownChildren, ...props }) {
- return (
-
- {markdownChildren}
-
- );
- },
- }}
+ remarkPlugins={remarkPlugins}
+ rehypePlugins={rehypePlugins}
+ components={components}
>
{children}
diff --git a/webui/src/components/MessageBubble.tsx b/webui/src/components/MessageBubble.tsx
index d5427ec42..98ab0c941 100644
--- a/webui/src/components/MessageBubble.tsx
+++ b/webui/src/components/MessageBubble.tsx
@@ -1,6 +1,5 @@
import {
useCallback,
- useDeferredValue,
useEffect,
useRef,
useState,
@@ -120,7 +119,7 @@ export function MessageBubble({
) : empty && message.isStreaming ? null : (
<>
-
{message.content}
+
{message.content}
{media.length > 0 ?
: null}
{showAssistantFooterRow ? (
@@ -480,8 +479,6 @@ export function ReasoningBubble({
embeddedInCluster = false,
}: ReasoningBubbleProps) {
const { t } = useTranslation();
- const deferredText = useDeferredValue(text);
- const markdownSource = streaming ? deferredText : text;
const [userToggled, setUserToggled] = useState(false);
const [openLocal, setOpenLocal] = useState(true);
const open = userToggled ? openLocal : streaming;
@@ -537,6 +534,7 @@ export function ReasoningBubble({
)}
>
- {markdownSource}
+ {text}
)}
diff --git a/webui/src/components/settings/SettingsView.tsx b/webui/src/components/settings/SettingsView.tsx
index 96188e60e..116b67d62 100644
--- a/webui/src/components/settings/SettingsView.tsx
+++ b/webui/src/components/settings/SettingsView.tsx
@@ -52,6 +52,13 @@ import type { SettingsPayload, WebSearchSettingsUpdate } from "@/lib/types";
type SettingsSectionKey = "general" | "byok";
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 {
theme: "light" | "dark";
onToggleTheme: () => void;
@@ -176,7 +183,8 @@ export function SettingsView({
if (!provider) return;
const providerForm = providerForms[providerName] ?? { apiKey: "", apiBase: "" };
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"));
return;
}
@@ -917,7 +925,10 @@ function ByokSettings({
const [activePane, setActivePane] = useState
("llm");
const [showAllUnconfigured, setShowAllUnconfigured] = useState(false);
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 visibleUnconfiguredProviders = showAllUnconfigured
? unconfiguredProviders
@@ -935,6 +946,12 @@ function ByokSettings({
const saving = providerSaving === provider.name;
const keyVisible = !!visibleProviderKeys[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 (
onSaveProvider(provider.name)}
- disabled={saving || (!provider.configured && !form.apiKey.trim())}
+ disabled={saving || missingRequiredApiKey || missingOptionalCredential}
className="rounded-full"
>
{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
= {
custom: Hexagon,
openrouter: Sparkles,
@@ -1212,6 +1248,12 @@ const PROVIDER_ICONS: Record = {
qianfan: Database,
azure_openai: Cloud,
bedrock: Database,
+ vllm: Cpu,
+ ollama: Cpu,
+ lm_studio: Cpu,
+ atomic_chat: Cpu,
+ ovms: Cpu,
+ nvidia: Zap,
};
function ProviderIcon({ provider }: { provider: string }) {
diff --git a/webui/src/lib/types.ts b/webui/src/lib/types.ts
index 0e54544b0..59ad8566c 100644
--- a/webui/src/lib/types.ts
+++ b/webui/src/lib/types.ts
@@ -110,6 +110,7 @@ export interface SettingsPayload {
name: string;
label: string;
configured: boolean;
+ api_key_required?: boolean;
api_key_hint?: string | null;
api_base?: string | null;
default_api_base?: string | null;
diff --git a/webui/src/tests/app-layout.test.tsx b/webui/src/tests/app-layout.test.tsx
index 7709c1c9c..f6e3f8aec 100644
--- a/webui/src/tests/app-layout.test.tsx
+++ b/webui/src/tests/app-layout.test.tsx
@@ -198,8 +198,52 @@ describe("App layout", () => {
name: "openrouter",
label: "OpenRouter",
configured: false,
+ api_key_required: true,
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: {
provider: "brave",
@@ -254,6 +298,9 @@ describe("App layout", () => {
fireEvent.click(screen.getByText("OpenAI"));
expect(screen.getByText("open••••-key")).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" }));
expect(screen.getByText("Search provider")).toBeInTheDocument();
diff --git a/webui/src/tests/code-block.test.tsx b/webui/src/tests/code-block.test.tsx
index 2a96bf64d..b76aeb0d8 100644
--- a/webui/src/tests/code-block.test.tsx
+++ b/webui/src/tests/code-block.test.tsx
@@ -35,6 +35,18 @@ vi.mock("react-syntax-highlighter/dist/esm/styles/prism/one-light", () => ({
}));
describe("CodeBlock", () => {
+ it("renders plain code without mounting the highlighter when highlighting is disabled", () => {
+ render(
+
+
+ ,
+ );
+
+ 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 () => {
const originalMutationObserver = globalThis.MutationObserver;
const observer = vi.fn();
diff --git a/webui/src/tests/markdown-text.test.tsx b/webui/src/tests/markdown-text.test.tsx
new file mode 100644
index 000000000..c818f2f5a
--- /dev/null
+++ b/webui/src/tests/markdown-text.test.tsx
@@ -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 (
+
+ {children}
+
+ );
+ },
+}));
+
+describe("MarkdownText", () => {
+ it("throttles streaming markdown commits and flushes before final highlighting", async () => {
+ rendererSpy.mockClear();
+ vi.useFakeTimers();
+ try {
+ const { rerender } = render(
+ hello,
+ );
+
+ 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(hello world);
+ 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(hello world!!!);
+ expect(screen.getByTestId("markdown-renderer")).toHaveTextContent("hello world");
+
+ rerender(hello world!!!);
+ expect(screen.getByTestId("markdown-renderer")).toHaveTextContent("hello world!!!");
+ expect(screen.getByTestId("markdown-renderer")).toHaveAttribute(
+ "data-highlight-code",
+ "true",
+ );
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+});