feat(webui): upgrade settings and sidebar controls (#3906)

* feat(settings): expand settings api payload

* feat(webui): build app-style settings center

* feat(webui): add centered chat search dialog

* fix(webui): shorten chat search label

* fix(webui): center dialog entrance animation

* fix(webui): simplify chat search results

* fix(webui): tighten mobile settings navigation

* feat(webui): persist sidebar state

* feat(webui): add sidebar organization controls

* refactor(webui): organize backend helpers

* refactor(webui): remove utils compatibility shims

* refactor(session): move shared webui helpers out of webui package

* feat(webui): add image generation settings

* style(webui): refine settings overview layout

* fix(webui): localize settings zh-CN copy

* style(webui): add settings status indicators

* feat(webui): show sidebar run indicators

* fix(webui): persist sidebar run indicators

* fix(webui): highlight settings pending status

* fix(webui): align settings test with provider update

* fix(utils): preserve legacy webui helper imports
This commit is contained in:
Xubin Ren
2026-05-19 22:42:38 +08:00
committed by GitHub
parent 30fc05c746
commit 57d5276da1
45 changed files with 6306 additions and 1012 deletions
+56
View File
@@ -1,8 +1,10 @@
import type {
ChatSummary,
ImageGenerationSettingsUpdate,
ProviderSettingsUpdate,
SettingsPayload,
SettingsUpdate,
SidebarStatePayload,
SlashCommand,
WebSearchSettingsUpdate,
WebuiThreadPersistedPayload,
@@ -52,6 +54,7 @@ export async function listSessions(
updated_at: string | null;
title?: string;
preview?: string;
run_started_at?: number | null;
};
const body = await request<{ sessions: Row[] }>(
`${base}/api/sessions`,
@@ -64,6 +67,7 @@ export async function listSessions(
updatedAt: s.updated_at,
title: s.title ?? "",
preview: s.preview ?? "",
runStartedAt: s.run_started_at ?? null,
}));
}
@@ -125,14 +129,43 @@ export async function listSlashCommands(
}));
}
export async function fetchSidebarState(
token: string,
base: string = "",
): Promise<SidebarStatePayload> {
return request<SidebarStatePayload>(`${base}/api/webui/sidebar-state`, token);
}
export async function updateSidebarState(
token: string,
state: SidebarStatePayload,
base: string = "",
): Promise<SidebarStatePayload> {
const query = new URLSearchParams();
query.set("state", JSON.stringify(state));
return request<SidebarStatePayload>(
`${base}/api/webui/sidebar-state/update?${query}`,
token,
);
}
export async function updateSettings(
token: string,
update: SettingsUpdate,
base: string = "",
): Promise<SettingsPayload> {
const query = new URLSearchParams();
if (update.modelPreset !== undefined) {
query.set("model_preset", update.modelPreset ?? "default");
}
if (update.model !== undefined) query.set("model", update.model);
if (update.provider !== undefined) query.set("provider", update.provider);
if (update.timezone !== undefined) query.set("timezone", update.timezone);
if (update.botName !== undefined) query.set("bot_name", update.botName);
if (update.botIcon !== undefined) query.set("bot_icon", update.botIcon);
if (update.toolHintMaxLength !== undefined) {
query.set("tool_hint_max_length", String(update.toolHintMaxLength));
}
return request<SettingsPayload>(`${base}/api/settings/update?${query}`, token);
}
@@ -160,8 +193,31 @@ export async function updateWebSearchSettings(
query.set("provider", update.provider);
if (update.apiKey !== undefined) query.set("api_key", update.apiKey);
if (update.baseUrl !== undefined) query.set("base_url", update.baseUrl);
if (update.maxResults !== undefined) query.set("max_results", String(update.maxResults));
if (update.timeout !== undefined) query.set("timeout", String(update.timeout));
if (update.useJinaReader !== undefined) {
query.set("use_jina_reader", String(update.useJinaReader));
}
return request<SettingsPayload>(
`${base}/api/settings/web-search/update?${query}`,
token,
);
}
export async function updateImageGenerationSettings(
token: string,
update: ImageGenerationSettingsUpdate,
base: string = "",
): Promise<SettingsPayload> {
const query = new URLSearchParams();
query.set("enabled", String(update.enabled));
query.set("provider", update.provider);
query.set("model", update.model);
query.set("default_aspect_ratio", update.defaultAspectRatio);
query.set("default_image_size", update.defaultImageSize);
query.set("max_images_per_turn", String(update.maxImagesPerTurn));
return request<SettingsPayload>(
`${base}/api/settings/image-generation/update?${query}`,
token,
);
}
+22 -1
View File
@@ -56,6 +56,7 @@ type StatusHandler = (status: ConnectionStatus) => void;
type RuntimeModelHandler = (modelName: string | null, modelPreset?: string | null) => void;
type SessionUpdateScope = "metadata" | "thread" | string;
type SessionUpdateHandler = (chatId: string, scope?: SessionUpdateScope) => void;
type RunStatusHandler = (chatId: string, startedAt: number | null) => void;
/** Structured connection-level errors surfaced to the UI.
*
@@ -102,6 +103,7 @@ export class NanobotClient {
private statusHandlers = new Set<StatusHandler>();
private runtimeModelHandlers = new Set<RuntimeModelHandler>();
private sessionUpdateHandlers = new Set<SessionUpdateHandler>();
private runStatusHandlers = new Set<RunStatusHandler>();
private errorHandlers = new Set<ErrorHandler>();
// chat_id -> handlers listening on it
private chatHandlers = new Map<string, Set<EventHandler>>();
@@ -172,6 +174,16 @@ export class NanobotClient {
};
}
onRunStatus(handler: RunStatusHandler): Unsubscribe {
this.runStatusHandlers.add(handler);
for (const [chatId, startedAt] of this.runStartedAtByChatId) {
handler(chatId, startedAt);
}
return () => {
this.runStatusHandlers.delete(handler);
};
}
/** Subscribe to transport-level faults (see :type:`StreamError`). */
onError(handler: ErrorHandler): Unsubscribe {
this.errorHandlers.add(handler);
@@ -194,9 +206,12 @@ export class NanobotClient {
private recordGoalStatusForRunStrip(chatId: string, ev: InboundEvent): void {
if (ev.event !== "goal_status") return;
if (ev.status === "running" && typeof ev.started_at === "number") {
const previous = this.runStartedAtByChatId.get(chatId);
this.runStartedAtByChatId.set(chatId, ev.started_at);
} else {
if (previous !== ev.started_at) this.emitRunStatus(chatId, ev.started_at);
} else if (this.runStartedAtByChatId.has(chatId)) {
this.runStartedAtByChatId.delete(chatId);
this.emitRunStatus(chatId, null);
}
}
@@ -389,6 +404,12 @@ export class NanobotClient {
}
}
private emitRunStatus(chatId: string, startedAt: number | null): void {
for (const handler of this.runStatusHandlers) {
handler(chatId, startedAt);
}
}
private dispatch(chatId: string, ev: InboundEvent): void {
const handlers = this.chatHandlers.get(chatId);
if (handlers !== undefined && handlers.size > 0) {
+118
View File
@@ -110,6 +110,30 @@ export interface ChatSummary {
updatedAt: string | null;
title?: string;
preview: string;
/** Unix epoch seconds when this session currently has a turn in flight. */
runStartedAt?: number | null;
}
export type SidebarDensity = "comfortable" | "compact";
export type SidebarSortMode = "updated_desc" | "created_desc" | "title_asc";
export interface SidebarViewState {
density: SidebarDensity;
show_previews: boolean;
show_timestamps: boolean;
show_archived: boolean;
sort: SidebarSortMode;
}
export interface SidebarStatePayload {
schema_version: number;
pinned_keys: string[];
archived_keys: string[];
title_overrides: Record<string, string>;
tags_by_key: Record<string, string[]>;
collapsed_groups: Record<string, boolean>;
view: SidebarViewState;
updated_at?: string | null;
}
export interface BootstrapResponse {
@@ -125,7 +149,28 @@ export interface SettingsPayload {
provider: string;
resolved_provider: string | null;
has_api_key: boolean;
model_preset: string | null;
max_tokens: number;
context_window_tokens: number;
temperature: number;
reasoning_effort: string | null;
timezone: string;
bot_name: string;
bot_icon: string;
tool_hint_max_length: number;
};
model_presets: Array<{
name: string;
label: string;
active: boolean;
is_default: boolean;
model: string;
provider: string;
max_tokens: number;
context_window_tokens: number;
temperature: number;
reasoning_effort: string | null;
}>;
providers: Array<{
name: string;
label: string;
@@ -139,21 +184,82 @@ export interface SettingsPayload {
provider: string;
api_key_hint?: string | null;
base_url?: string | null;
max_results: number;
timeout: number;
providers: Array<{
name: string;
label: string;
credential: "none" | "api_key" | "base_url";
}>;
};
web: {
enable: boolean;
proxy?: string | null;
user_agent?: string | null;
search: {
max_results: number;
timeout: number;
};
fetch: {
use_jina_reader: boolean;
};
};
image_generation: {
enabled: boolean;
provider: string;
provider_configured: boolean;
model: string;
default_aspect_ratio: string;
default_image_size: string;
max_images_per_turn: number;
save_dir: string;
providers: Array<{
name: string;
label: string;
configured: boolean;
api_key_hint?: string | null;
api_base?: string | null;
default_api_base?: string | null;
}>;
};
runtime: {
config_path: string;
workspace_path: string;
gateway_host: string;
gateway_port: number;
heartbeat: {
enabled: boolean;
interval_s: number;
keep_recent_messages: number;
};
dream: {
schedule: string;
max_batch_size: number;
max_iterations: number;
annotate_line_ages: boolean;
};
unified_session: boolean;
};
advanced: {
restrict_to_workspace: boolean;
ssrf_whitelist_count: number;
mcp_server_count: number;
exec_enabled: boolean;
exec_sandbox?: string | null;
exec_path_append_set: boolean;
};
requires_restart: boolean;
restart_required_sections?: Array<"runtime" | "web" | "image">;
}
export interface SettingsUpdate {
model?: string;
provider?: string;
modelPreset?: string | null;
timezone?: string;
botName?: string;
botIcon?: string;
toolHintMaxLength?: number;
}
export interface ProviderSettingsUpdate {
@@ -166,6 +272,18 @@ export interface WebSearchSettingsUpdate {
provider: string;
apiKey?: string;
baseUrl?: string;
maxResults?: number;
timeout?: number;
useJinaReader?: boolean;
}
export interface ImageGenerationSettingsUpdate {
enabled: boolean;
provider: string;
model: string;
defaultAspectRatio: string;
defaultImageSize: string;
maxImagesPerTurn: number;
}
export interface SlashCommand {