mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-05-19 16:12:30 +00:00
Polish WebUI streaming and provider settings
This commit is contained in:
parent
9340567f2d
commit
4b5de66c58
@ -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()
|
||||||
|
|||||||
@ -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,
|
||||||
),
|
),
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -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 }) {
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
82
webui/src/tests/markdown-text.test.tsx
Normal file
82
webui/src/tests/markdown-text.test.tsx
Normal 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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user