feat(webui): switch model presets from the composer (#5077)
This commit is contained in:
+20
-1
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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))
|
||||
));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user