feat(webui): add model settings runtime refresh

This commit is contained in:
Xubin Ren
2026-04-25 18:05:06 +00:00
parent a58d9fd357
commit b440e76d2f
14 changed files with 793 additions and 172 deletions
+35 -10
View File
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { DeleteConfirm } from "@/components/DeleteConfirm";
import { Sidebar } from "@/components/Sidebar";
import { SettingsView } from "@/components/settings/SettingsView";
import { ThreadShell } from "@/components/thread/ThreadShell";
import { Sheet, SheetContent } from "@/components/ui/sheet";
import { preloadMarkdownText } from "@/components/MarkdownText";
@@ -25,6 +26,7 @@ type BootState =
const SIDEBAR_STORAGE_KEY = "nanobot-webui.sidebar";
const SIDEBAR_WIDTH = 279;
type ShellView = "chat" | "settings";
function readSidebarOpen(): boolean {
if (typeof window === "undefined") return true;
@@ -136,22 +138,29 @@ export default function App() {
);
}
const handleModelNameChange = (modelName: string | null) => {
setState((current) =>
current.status === "ready" ? { ...current, modelName } : current,
);
};
return (
<ClientProvider
client={state.client}
token={state.token}
modelName={state.modelName}
>
<Shell />
<Shell onModelNameChange={handleModelNameChange} />
</ClientProvider>
);
}
function Shell() {
function Shell({ onModelNameChange }: { onModelNameChange: (modelName: string | null) => void }) {
const { t, i18n } = useTranslation();
const { theme, toggle } = useTheme();
const { sessions, loading, refresh, createChat, deleteChat } = useSessions();
const [activeKey, setActiveKey] = useState<string | null>(null);
const [view, setView] = useState<ShellView>("chat");
const [desktopSidebarOpen, setDesktopSidebarOpen] =
useState<boolean>(readSidebarOpen);
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
@@ -208,6 +217,7 @@ function Shell() {
try {
const chatId = await createChat();
setActiveKey(`websocket:${chatId}`);
setView("chat");
setMobileSidebarOpen(false);
return chatId;
} catch (e) {
@@ -219,6 +229,7 @@ function Shell() {
const onSelectChat = useCallback(
(key: string) => {
setActiveKey(key);
setView("chat");
setMobileSidebarOpen(false);
},
[],
@@ -266,6 +277,11 @@ function Shell() {
onRefresh: () => void refresh(),
onRequestDelete: (key: string, label: string) =>
setPendingDelete({ key, label }),
activeView: view,
onOpenSettings: () => {
setView("settings" as const);
setMobileSidebarOpen(false);
},
};
return (
@@ -303,14 +319,23 @@ function Shell() {
</Sheet>
<main className="flex h-full min-w-0 flex-1 flex-col">
<ThreadShell
session={activeSession}
title={headerTitle}
onToggleSidebar={toggleSidebar}
onGoHome={() => setActiveKey(null)}
onNewChat={onNewChat}
hideSidebarToggleOnDesktop={desktopSidebarOpen}
/>
{view === "settings" ? (
<SettingsView
theme={theme}
onToggleTheme={toggle}
onBackToChat={() => setView("chat")}
onModelNameChange={onModelNameChange}
/>
) : (
<ThreadShell
session={activeSession}
title={headerTitle}
onToggleSidebar={toggleSidebar}
onGoHome={() => setActiveKey(null)}
onNewChat={onNewChat}
hideSidebarToggleOnDesktop={desktopSidebarOpen}
/>
)}
</main>
<DeleteConfirm
+51 -33
View File
@@ -1,9 +1,8 @@
import { Moon, PanelLeftClose, Plus, RefreshCcw, Sun } from "lucide-react";
import { Moon, PanelLeftClose, RefreshCcw, Settings, SquarePen, Sun } from "lucide-react";
import { useTranslation } from "react-i18next";
import { ChatList } from "@/components/ChatList";
import { ConnectionBadge } from "@/components/ConnectionBadge";
import { LanguageSwitcher } from "@/components/LanguageSwitcher";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import type { ChatSummary } from "@/lib/types";
@@ -19,48 +18,60 @@ interface SidebarProps {
onRefresh: () => void;
onRequestDelete: (key: string, label: string) => void;
onCollapse: () => void;
activeView?: "chat" | "settings";
onOpenSettings: () => void;
}
export function Sidebar(props: SidebarProps) {
const { t } = useTranslation();
return (
<aside className="flex h-full w-full flex-col border-r border-sidebar-border/70 bg-sidebar text-sidebar-foreground">
<div className="flex items-center justify-between px-2 py-2">
<Button
variant="ghost"
size="icon"
aria-label={t("sidebar.collapse")}
onClick={props.onCollapse}
className="h-7 w-7 rounded-lg text-muted-foreground hover:bg-sidebar-accent hover:text-sidebar-foreground"
>
<PanelLeftClose className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
aria-label={t("sidebar.toggleTheme")}
onClick={props.onToggleTheme}
className="h-7 w-7 rounded-lg text-muted-foreground hover:bg-sidebar-accent hover:text-sidebar-foreground"
>
{props.theme === "dark" ? (
<Sun className="h-3.5 w-3.5" />
) : (
<Moon className="h-3.5 w-3.5" />
)}
</Button>
<div className="flex items-center justify-between px-3 pb-2 pt-3">
<picture className="block min-w-0">
<source srcSet="/brand/nanobot_logo.webp" type="image/webp" />
<img
src="/brand/nanobot_logo.png"
alt="nanobot"
className="h-7 w-auto select-none object-contain"
draggable={false}
/>
</picture>
<div className="flex items-center gap-0.5">
<Button
variant="ghost"
size="icon"
aria-label={t("sidebar.toggleTheme")}
onClick={props.onToggleTheme}
className="h-7 w-7 rounded-lg text-muted-foreground hover:bg-sidebar-accent hover:text-sidebar-foreground"
>
{props.theme === "dark" ? (
<Sun className="h-3.5 w-3.5" />
) : (
<Moon className="h-3.5 w-3.5" />
)}
</Button>
<Button
variant="ghost"
size="icon"
aria-label={t("sidebar.collapse")}
onClick={props.onCollapse}
className="h-7 w-7 rounded-lg text-muted-foreground hover:bg-sidebar-accent hover:text-sidebar-foreground"
>
<PanelLeftClose className="h-3.5 w-3.5" />
</Button>
</div>
</div>
<div className="px-2 pb-2.5">
<div className="px-2 pb-2">
<Button
onClick={props.onNewChat}
className="h-8.5 w-full justify-start gap-2 rounded-lg border border-sidebar-border/80 bg-card/25 px-3 text-[13px] font-medium text-sidebar-foreground shadow-none hover:bg-sidebar-accent/80"
variant="outline"
className="h-9 w-full justify-start gap-2 rounded-full px-3 text-[13px] font-medium text-sidebar-foreground/90 hover:bg-sidebar-accent hover:text-sidebar-foreground"
variant="ghost"
>
<Plus className="h-3.5 w-3.5" />
<SquarePen className="h-3.5 w-3.5" />
{t("sidebar.newChat")}
</Button>
</div>
<Separator className="bg-sidebar-border/70" />
<div className="flex items-center justify-between px-2.5 py-2 text-[11px] font-medium text-muted-foreground">
<div className="flex items-center justify-between px-3 pb-1.5 pt-2.5 text-[11px] font-medium text-muted-foreground">
<span>{t("sidebar.recent")}</span>
<Button
variant="ghost"
@@ -81,10 +92,17 @@ export function Sidebar(props: SidebarProps) {
onRequestDelete={props.onRequestDelete}
/>
</div>
<Separator className="bg-sidebar-border/70" />
<Separator className="bg-sidebar-border/50" />
<div className="flex items-center justify-between gap-2 px-2.5 py-2 text-xs">
<ConnectionBadge />
<LanguageSwitcher />
<Button
onClick={props.onOpenSettings}
className="h-7 gap-1.5 rounded-md px-2 text-[11px] text-muted-foreground hover:bg-sidebar-accent hover:text-sidebar-foreground"
variant={props.activeView === "settings" ? "secondary" : "ghost"}
>
<Settings className="h-3.5 w-3.5" />
Settings
</Button>
</div>
</aside>
);
@@ -0,0 +1,245 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { ChevronLeft, Loader2 } from "lucide-react";
import { LanguageSwitcher } from "@/components/LanguageSwitcher";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { fetchSettings, updateSettings } from "@/lib/api";
import { cn } from "@/lib/utils";
import { useClient } from "@/providers/ClientProvider";
import type { SettingsPayload } from "@/lib/types";
interface SettingsViewProps {
theme: "light" | "dark";
onToggleTheme: () => void;
onBackToChat: () => void;
onModelNameChange: (modelName: string | null) => void;
}
export function SettingsView({
onBackToChat,
onModelNameChange,
}: SettingsViewProps) {
const { token } = useClient();
const [settings, setSettings] = useState<SettingsPayload | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [form, setForm] = useState({
model: "",
provider: "auto",
});
const applyPayload = useCallback((payload: SettingsPayload) => {
setSettings(payload);
setForm({
model: payload.agent.model,
provider: payload.agent.provider,
});
}, []);
useEffect(() => {
let cancelled = false;
setLoading(true);
fetchSettings(token)
.then((payload) => {
if (!cancelled) {
applyPayload(payload);
setError(null);
}
})
.catch((err) => {
if (!cancelled) setError((err as Error).message);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [applyPayload, token]);
const dirty = useMemo(() => {
if (!settings) return false;
return (
form.model !== settings.agent.model ||
form.provider !== settings.agent.provider
);
}, [form, settings]);
const save = async () => {
if (!dirty || saving) return;
setSaving(true);
try {
const payload = await updateSettings(token, form);
applyPayload(payload);
onModelNameChange(payload.agent.model || null);
setError(null);
} catch (err) {
setError((err as Error).message);
} finally {
setSaving(false);
}
};
return (
<div className="min-h-0 flex-1 overflow-y-auto bg-background">
<main className="mx-auto w-full max-w-[1000px] px-6 py-6">
<button
type="button"
onClick={onBackToChat}
className="mb-4 inline-flex items-center gap-1.5 text-xs font-medium text-muted-foreground hover:text-foreground"
>
<ChevronLeft className="h-3.5 w-3.5" />
Back to chat
</button>
<h1 className="mb-6 text-base font-semibold tracking-tight">General</h1>
{loading ? (
<div className="flex h-48 items-center justify-center text-sm text-muted-foreground">
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Loading settings...
</div>
) : error ? (
<SettingsGroup>
<SettingsRow title="Could not load settings">
<span className="max-w-[520px] text-sm text-muted-foreground">{error}</span>
</SettingsRow>
</SettingsGroup>
) : settings ? (
<SettingsSection
form={form}
setForm={setForm}
settings={settings}
dirty={dirty}
saving={saving}
onSave={save}
/>
) : null}
</main>
</div>
);
}
function SettingsSection({
form,
setForm,
settings,
dirty,
saving,
onSave,
}: {
form: {
model: string;
provider: string;
};
setForm: React.Dispatch<React.SetStateAction<{
model: string;
provider: string;
}>>;
settings: SettingsPayload;
dirty: boolean;
saving: boolean;
onSave: () => void;
}) {
return (
<div className="space-y-7">
<section>
<h2 className="mb-2 px-2 text-xs font-medium text-muted-foreground">AI</h2>
<SettingsGroup>
<SettingsRow title="Provider">
<select
value={form.provider}
onChange={(event) => setForm((prev) => ({ ...prev, provider: event.target.value }))}
className={cn(
"h-8 w-[210px] rounded-md border border-input bg-background px-2 text-sm",
"outline-none transition-colors hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring",
)}
>
{settings.providers.map((provider) => (
<option key={provider.name} value={provider.name}>
{provider.label}
</option>
))}
</select>
</SettingsRow>
<SettingsRow title="Model">
<Input
value={form.model}
onChange={(event) => setForm((prev) => ({ ...prev, model: event.target.value }))}
className="h-8 w-[280px]"
/>
</SettingsRow>
{(dirty || saving || settings.requires_restart) ? (
<SettingsFooter
dirty={dirty}
saving={saving}
saved={settings.requires_restart && !dirty}
onSave={onSave}
/>
) : null}
</SettingsGroup>
</section>
<section>
<h2 className="mb-2 px-2 text-xs font-medium text-muted-foreground">Interface</h2>
<SettingsGroup>
<SettingsRow title="Language">
<LanguageSwitcher />
</SettingsRow>
</SettingsGroup>
</section>
</div>
);
}
function SettingsGroup({ children }: { children: React.ReactNode }) {
return (
<div className="overflow-hidden rounded-xl border border-border/60 bg-card/80">
<div className="divide-y divide-border/50">{children}</div>
</div>
);
}
function SettingsRow({
title,
children,
}: {
title: string;
children?: React.ReactNode;
}) {
return (
<div className="flex min-h-[52px] flex-col gap-3 px-3 py-2.5 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0">
<div className="text-sm font-medium leading-5">{title}</div>
</div>
{children ? <div className="shrink-0 sm:ml-6">{children}</div> : null}
</div>
);
}
function SettingsFooter({
dirty,
saving,
saved,
onSave,
}: {
dirty: boolean;
saving: boolean;
saved: boolean;
onSave: () => void;
}) {
return (
<div className="flex min-h-[52px] items-center justify-between gap-4 px-3 py-2.5">
<div className="text-sm text-muted-foreground">
{saved ? "Saved. Restart nanobot to apply." : "Unsaved changes."}
</div>
<Button size="sm" variant="outline" onClick={onSave} disabled={!dirty || saving}>
{saving ? "Saving" : "Save"}
</Button>
</div>
);
}
+19 -1
View File
@@ -1,4 +1,4 @@
import type { ChatSummary } from "./types";
import type { ChatSummary, SettingsPayload, SettingsUpdate } from "./types";
export class ApiError extends Error {
status: number;
@@ -104,3 +104,21 @@ export async function deleteSession(
);
return body.deleted;
}
export async function fetchSettings(
token: string,
base: string = "",
): Promise<SettingsPayload> {
return request<SettingsPayload>(`${base}/api/settings`, token);
}
export async function updateSettings(
token: string,
update: SettingsUpdate,
base: string = "",
): Promise<SettingsPayload> {
const query = new URLSearchParams();
if (update.model !== undefined) query.set("model", update.model);
if (update.provider !== undefined) query.set("provider", update.provider);
return request<SettingsPayload>(`${base}/api/settings/update?${query}`, token);
}
+22
View File
@@ -66,6 +66,28 @@ export interface BootstrapResponse {
model_name?: string | null;
}
export interface SettingsPayload {
agent: {
model: string;
provider: string;
resolved_provider: string | null;
has_api_key: boolean;
};
providers: Array<{
name: string;
label: string;
}>;
runtime: {
config_path: string;
};
requires_restart: boolean;
}
export interface SettingsUpdate {
model?: string;
provider?: string;
}
export type ConnectionStatus =
| "idle"
| "connecting"
+15 -1
View File
@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { deleteSession, fetchSessionMessages } from "@/lib/api";
import { deleteSession, fetchSessionMessages, updateSettings } from "@/lib/api";
describe("webui API helpers", () => {
beforeEach(() => {
@@ -34,4 +34,18 @@ describe("webui API helpers", () => {
}),
);
});
it("serializes settings updates as a narrow query string", async () => {
await updateSettings("tok", {
model: "openrouter/test",
provider: "openrouter",
});
expect(fetch).toHaveBeenCalledWith(
"/api/settings/update?model=openrouter%2Ftest&provider=openrouter",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
);
});
});
+40
View File
@@ -146,4 +146,44 @@ describe("App layout", () => {
expect(screen.queryByText('Delete “First chat”?')).not.toBeInTheDocument();
expect(document.body.style.pointerEvents).not.toBe("none");
}, 15_000);
it("opens the Cursor-style settings view from the sidebar", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
if (String(input).includes("/api/settings")) {
return {
ok: true,
status: 200,
json: async () => ({
agent: {
model: "openai/gpt-4o",
provider: "auto",
resolved_provider: "openai",
has_api_key: true,
},
providers: [
{ name: "auto", label: "Auto" },
{ name: "openai", label: "OpenAI" },
],
runtime: {
config_path: "/tmp/config.json",
},
requires_restart: false,
}),
};
}
return { ok: false, status: 404, json: async () => ({}) };
}),
);
render(<App />);
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
fireEvent.click(screen.getByRole("button", { name: "Settings" }));
expect(await screen.findByRole("heading", { name: "General" })).toBeInTheDocument();
expect(screen.getByText("AI")).toBeInTheDocument();
expect(screen.getByDisplayValue("openai/gpt-4o")).toBeInTheDocument();
});
});