feat(webui): switch model presets from the composer (#5077)

This commit is contained in:
chengyongru
2026-07-24 17:34:26 +08:00
committed by GitHub
parent 9aab94c766
commit 5be176a6a0
16 changed files with 991 additions and 138 deletions
+20 -1
View File
@@ -16,6 +16,25 @@ const LOW_INFORMATION_TITLE_PREVIEWS = new Set([
"在吗",
]);
export function isModelCommandText(text: string | null | undefined): boolean {
return /^\/model(?:@[A-Za-z0-9_]+)?(?:\s|$)/i.test(text?.trim() ?? "");
}
export function isModelCommandResponseText(text: string | null | undefined): boolean {
const normalized = text?.trim() ?? "";
return (
/^## Model\s+- Current (?:model|selection error):/.test(normalized)
|| normalized.startsWith("Switched model preset to ")
|| normalized.startsWith("Could not switch model preset:")
|| normalized === "Usage: `/model [preset]`"
);
}
export function visibleSessionPreview(preview: string | null | undefined): string {
const normalized = preview?.trim() ?? "";
return isModelCommandText(normalized) || isModelCommandResponseText(normalized) ? "" : normalized;
}
function isLowInformationTitlePreview(text: string): boolean {
const normalized = text.toLowerCase().replace(/[.!?。!?~\s]+$/g, "").trim();
return (
@@ -27,7 +46,7 @@ function isLowInformationTitlePreview(text: string): boolean {
/** Truncate the first user message into a chat title. */
export function deriveTitle(preview: string | undefined, fallback: string): string {
if (!preview) return fallback;
const oneLine = preview.replace(/\s+/g, " ").trim();
const oneLine = visibleSessionPreview(preview).replace(/\s+/g, " ").trim();
if (!oneLine) return fallback;
if (isLowInformationTitlePreview(oneLine)) return fallback;
return oneLine.length > 60 ? `${oneLine.slice(0, 57)}` : oneLine;
+45 -8
View File
@@ -88,16 +88,16 @@ export type StreamError =
type ErrorHandler = (error: StreamError) => void;
interface PendingNewChat {
resolve: (chatId: string) => void;
interface PendingRequest<T> {
resolve: (value: T) => void;
reject: (err: Error) => void;
timer: ReturnType<typeof setTimeout>;
}
interface PendingTranscription {
resolve: (text: string) => void;
reject: (err: Error) => void;
timer: ReturnType<typeof setTimeout>;
const SYSTEM_COMMAND_TURN_PREFIX = "webui-system:";
export function isSystemCommandTurnId(value: string | null | undefined): value is string {
return typeof value === "string" && value.startsWith(SYSTEM_COMMAND_TURN_PREFIX);
}
export interface NanobotClientOptions {
@@ -136,8 +136,9 @@ export class NanobotClient {
private runStartedAtByChatId = new Map<string, number>();
/** Latest ``goal_state`` snapshot per ``chat_id`` (multi-session isolation). */
private goalStateByChatId = new Map<string, GoalStateWsPayload>();
private pendingNewChat: PendingNewChat | null = null;
private pendingTranscriptions = new Map<string, PendingTranscription>();
private pendingNewChat: PendingRequest<string> | null = null;
private pendingTranscriptions = new Map<string, PendingRequest<string>>();
private pendingSystemCommands = new Map<string, PendingRequest<void>>();
// Frames queued while the socket is not yet OPEN
private sendQueue: Outbound[] = [];
private reconnectAttempts = 0;
@@ -407,6 +408,19 @@ export class NanobotClient {
this.queueSend(frame);
}
sendSystemCommand(chatId: string, command: string, timeoutMs = 5_000): Promise<void> {
const normalized = command.trim();
const turnId = `${SYSTEM_COMMAND_TURN_PREFIX}${crypto.randomUUID()}`;
return new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => {
this.pendingSystemCommands.delete(turnId);
reject(new Error("system command timed out"));
}, timeoutMs);
this.pendingSystemCommands.set(turnId, { resolve, reject, timer });
this.sendMessage(chatId, normalized, undefined, { turnId });
});
}
setWorkspaceScope(chatId: string, workspaceScope: WorkspaceScopePayload): void {
this.knownChats.add(chatId);
this.queueSend({
@@ -462,6 +476,16 @@ export class NanobotClient {
console.log("[nanobot ws inbound]", summarizeInboundWsPayload(parsed));
}
const turnId = "turn_id" in parsed && typeof parsed.turn_id === "string"
? parsed.turn_id
: null;
if (isSystemCommandTurnId(turnId)) {
if (parsed.event === "message" || parsed.event === "turn_end") {
this.resolveSystemCommand(turnId);
}
return;
}
if (parsed.event === "ready") {
this.readyChatId = parsed.chat_id;
this.knownChats.add(parsed.chat_id);
@@ -578,6 +602,11 @@ export class NanobotClient {
this.pendingNewChat = null;
}
this.rejectAllTranscriptions("socket closed");
for (const pending of this.pendingSystemCommands.values()) {
clearTimeout(pending.timer);
pending.reject(new Error("socket closed"));
}
this.pendingSystemCommands.clear();
// Surface structured reasons *before* reconnect logic so the UI can
// display the error even while the client transparently reconnects.
// Browsers populate ``CloseEvent.code`` with the wire-level close code;
@@ -634,6 +663,14 @@ export class NanobotClient {
}
}
private resolveSystemCommand(turnId: string): void {
const pending = this.pendingSystemCommands.get(turnId);
if (!pending) return;
clearTimeout(pending.timer);
this.pendingSystemCommands.delete(turnId);
pending.resolve();
}
private scheduleReconnect(): void {
this.clearRunStatusesForReconnect();
this.setStatus("reconnecting");
+18
View File
@@ -1,3 +1,6 @@
import { isModelCommandResponseText, isModelCommandText } from "@/lib/format";
import { isSystemCommandTurnId } from "@/lib/nanobot-client";
import { scrubSubagentUiMessages } from "@/lib/subagent-channel-display";
import type { UIMessage } from "@/lib/types";
/**
@@ -20,3 +23,18 @@ export function normalizeLegacyLongTaskMessages(messages: UIMessage[]): UIMessag
};
});
}
export function projectWebuiThreadMessages(messages: UIMessage[]): UIMessage[] {
const normalized = scrubSubagentUiMessages(normalizeLegacyLongTaskMessages(messages));
const hiddenTurns = new Set(normalized.flatMap((message) => (
message.role === "user" && isModelCommandText(message.content) && message.turnId
? [message.turnId]
: []
)));
return normalized.filter((message) => (
!isSystemCommandTurnId(message.turnId)
&& (!message.turnId || !hiddenTurns.has(message.turnId))
&& !(message.role === "user" && isModelCommandText(message.content))
&& !(message.role === "assistant" && isModelCommandResponseText(message.content))
));
}