From e0b4f3b995cc69920c19e67edbde13bcc3d4c105 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 8 Apr 2026 01:13:25 +0100 Subject: [PATCH] refactor: dedupe provider ui trimmed readers --- .../byteplus/video-generation-provider.ts | 31 +++++++----- extensions/fal/video-generation-provider.ts | 47 +++++++++++-------- extensions/google/api.ts | 3 +- .../google/music-generation-provider.ts | 23 +++++---- .../google/video-generation-provider.ts | 17 +++---- .../minimax/music-generation-provider.ts | 17 ++++--- extensions/minimax/provider-registration.ts | 7 ++- .../minimax/video-generation-provider.ts | 34 ++++++++------ extensions/openai/shared.ts | 7 +-- .../openai/video-generation-provider.ts | 22 +++++---- .../runway/video-generation-provider.ts | 38 ++++++++------- .../together/video-generation-provider.ts | 33 ++++++++----- extensions/xai/video-generation-provider.ts | 31 +++++++----- ui/src/ui/app-render.helpers.ts | 21 +++++---- ui/src/ui/app-render.ts | 20 ++++---- ui/src/ui/string-coerce.ts | 10 ++++ ui/src/ui/views/agents-utils.ts | 36 +++++++------- ui/src/ui/views/nodes.ts | 7 +-- 18 files changed, 238 insertions(+), 166 deletions(-) diff --git a/extensions/byteplus/video-generation-provider.ts b/extensions/byteplus/video-generation-provider.ts index c0b84a4aaa3..90ebdce7704 100644 --- a/extensions/byteplus/video-generation-provider.ts +++ b/extensions/byteplus/video-generation-provider.ts @@ -6,6 +6,7 @@ import { postJsonRequest, resolveProviderHttpRequestConfig, } from "openclaw/plugin-sdk/provider-http"; +import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime"; import type { GeneratedVideoAsset, VideoGenerationProvider, @@ -41,7 +42,9 @@ type BytePlusTaskResponse = { }; function resolveBytePlusVideoBaseUrl(req: VideoGenerationRequest): string { - return req.cfg?.models?.providers?.byteplus?.baseUrl?.trim() || BYTEPLUS_BASE_URL; + return ( + normalizeOptionalString(req.cfg?.models?.providers?.byteplus?.baseUrl) ?? BYTEPLUS_BASE_URL + ); } function toDataUrl(buffer: Buffer, mimeType: string): string { @@ -53,13 +56,14 @@ function resolveBytePlusImageUrl(req: VideoGenerationRequest): string | undefine if (!input) { return undefined; } - if (input.url?.trim()) { - return input.url.trim(); + const inputUrl = normalizeOptionalString(input.url); + if (inputUrl) { + return inputUrl; } if (!input.buffer) { throw new Error("BytePlus reference image is missing image data."); } - return toDataUrl(input.buffer, input.mimeType?.trim() || "image/png"); + return toDataUrl(input.buffer, normalizeOptionalString(input.mimeType) ?? "image/png"); } async function pollBytePlusTask(params: { @@ -81,12 +85,14 @@ async function pollBytePlusTask(params: { ); await assertOkOrThrowHttpError(response, "BytePlus video status request failed"); const payload = (await response.json()) as BytePlusTaskResponse; - switch (payload.status?.trim()) { + switch (normalizeOptionalString(payload.status)) { case "succeeded": return payload; case "failed": case "cancelled": - throw new Error(payload.error?.message?.trim() || "BytePlus video generation failed"); + throw new Error( + normalizeOptionalString(payload.error?.message) || "BytePlus video generation failed", + ); case "queued": case "running": default: @@ -109,7 +115,7 @@ async function downloadBytePlusVideo(params: { params.fetchFn, ); await assertOkOrThrowHttpError(response, "BytePlus generated video download failed"); - const mimeType = response.headers.get("content-type")?.trim() || "video/mp4"; + const mimeType = normalizeOptionalString(response.headers.get("content-type")) ?? "video/mp4"; const arrayBuffer = await response.arrayBuffer(); return { buffer: Buffer.from(arrayBuffer), @@ -195,11 +201,12 @@ export function buildBytePlusVideoGenerationProvider(): VideoGenerationProvider }); } const body: Record = { - model: req.model?.trim() || DEFAULT_BYTEPLUS_VIDEO_MODEL, + model: normalizeOptionalString(req.model) || DEFAULT_BYTEPLUS_VIDEO_MODEL, content, }; - if (req.aspectRatio?.trim()) { - body.ratio = req.aspectRatio.trim(); + const aspectRatio = normalizeOptionalString(req.aspectRatio); + if (aspectRatio) { + body.ratio = aspectRatio; } if (req.resolution) { body.resolution = req.resolution; @@ -226,7 +233,7 @@ export function buildBytePlusVideoGenerationProvider(): VideoGenerationProvider try { await assertOkOrThrowHttpError(response, "BytePlus video generation failed"); const submitted = (await response.json()) as BytePlusTaskCreateResponse; - const taskId = submitted.id?.trim(); + const taskId = normalizeOptionalString(submitted.id); if (!taskId) { throw new Error("BytePlus video generation response missing task id"); } @@ -237,7 +244,7 @@ export function buildBytePlusVideoGenerationProvider(): VideoGenerationProvider baseUrl, fetchFn, }); - const videoUrl = completed.content?.video_url?.trim(); + const videoUrl = normalizeOptionalString(completed.content?.video_url); if (!videoUrl) { throw new Error("BytePlus video generation completed without a video URL"); } diff --git a/extensions/fal/video-generation-provider.ts b/extensions/fal/video-generation-provider.ts index bc911d49e2c..f05697fd2f3 100644 --- a/extensions/fal/video-generation-provider.ts +++ b/extensions/fal/video-generation-provider.ts @@ -9,7 +9,10 @@ import { type SsrFPolicy, ssrfPolicyFromDangerouslyAllowPrivateNetwork, } from "openclaw/plugin-sdk/ssrf-runtime"; -import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime"; +import { + normalizeLowercaseStringOrEmpty, + normalizeOptionalString, +} from "openclaw/plugin-sdk/text-runtime"; import type { GeneratedVideoAsset, VideoGenerationProvider, @@ -64,10 +67,10 @@ function buildPolicy(allowPrivateNetwork: boolean): SsrFPolicy | undefined { } function extractFalVideoEntry(payload: FalVideoResponse) { - if (payload.video?.url?.trim()) { + if (normalizeOptionalString(payload.video?.url)) { return payload.video; } - return payload.videos?.find((entry) => entry.url?.trim()); + return payload.videos?.find((entry) => normalizeOptionalString(entry.url)); } async function downloadFalVideo( @@ -82,7 +85,7 @@ async function downloadFalVideo( }); try { await assertOkOrThrowHttpError(response, "fal generated video download failed"); - const mimeType = response.headers.get("content-type")?.trim() || "video/mp4"; + const mimeType = normalizeOptionalString(response.headers.get("content-type")) ?? "video/mp4"; const arrayBuffer = await response.arrayBuffer(); return { buffer: Buffer.from(arrayBuffer), @@ -120,10 +123,10 @@ function buildFalVideoRequestBody(params: { }; const input = params.req.inputImages?.[0]; if (input) { - requestBody.image_url = input.url?.trim() - ? input.url.trim() + requestBody.image_url = normalizeOptionalString(input.url) + ? normalizeOptionalString(input.url) : input.buffer - ? toDataUrl(input.buffer, input.mimeType?.trim() || "image/png") + ? toDataUrl(input.buffer, normalizeOptionalString(input.mimeType) ?? "image/png") : undefined; } // MiniMax Live on fal currently documents prompt + optional image_url only. @@ -132,11 +135,13 @@ function buildFalVideoRequestBody(params: { if (isFalMiniMaxLiveModel(params.model)) { return requestBody; } - if (params.req.aspectRatio?.trim()) { - requestBody.aspect_ratio = params.req.aspectRatio.trim(); + const aspectRatio = normalizeOptionalString(params.req.aspectRatio); + if (aspectRatio) { + requestBody.aspect_ratio = aspectRatio; } - if (params.req.size?.trim()) { - requestBody.size = params.req.size.trim(); + const size = normalizeOptionalString(params.req.size); + if (size) { + requestBody.size = size; } if (params.req.resolution) { requestBody.resolution = params.req.resolution; @@ -198,7 +203,7 @@ async function waitForFalQueueResult(params: { auditContext: "fal-video-status", errorContext: "fal video status request failed", })) as FalQueueResponse; - const status = payload.status?.trim().toUpperCase(); + const status = normalizeOptionalString(payload.status)?.toUpperCase(); if (status) { lastStatus = status; } @@ -218,8 +223,8 @@ async function waitForFalQueueResult(params: { } if (status === "FAILED" || status === "CANCELLED") { throw new Error( - payload.detail?.trim() || - payload.error?.message?.trim() || + normalizeOptionalString(payload.detail) || + normalizeOptionalString(payload.error?.message) || `fal video generation ${normalizeLowercaseStringOrEmpty(status)}`, ); } @@ -288,7 +293,7 @@ export function buildFalVideoGenerationProvider(): VideoGenerationProvider { } const { baseUrl, allowPrivateNetwork, headers, dispatcherPolicy } = resolveProviderHttpRequestConfig({ - baseUrl: req.cfg?.models?.providers?.fal?.baseUrl?.trim(), + baseUrl: normalizeOptionalString(req.cfg?.models?.providers?.fal?.baseUrl), defaultBaseUrl: DEFAULT_FAL_BASE_URL, allowPrivateNetwork: false, defaultHeaders: { @@ -299,7 +304,7 @@ export function buildFalVideoGenerationProvider(): VideoGenerationProvider { capability: "video", transport: "http", }); - const model = req.model?.trim() || DEFAULT_FAL_VIDEO_MODEL; + const model = normalizeOptionalString(req.model) || DEFAULT_FAL_VIDEO_MODEL; const requestBody = buildFalVideoRequestBody({ req, model }); const policy = buildPolicy(allowPrivateNetwork); const queueBaseUrl = resolveFalQueueBaseUrl(baseUrl); @@ -316,8 +321,8 @@ export function buildFalVideoGenerationProvider(): VideoGenerationProvider { auditContext: "fal-video-submit", errorContext: "fal video generation failed", })) as FalQueueResponse; - const statusUrl = submitted.status_url?.trim(); - const responseUrl = submitted.response_url?.trim(); + const statusUrl = normalizeOptionalString(submitted.status_url); + const responseUrl = normalizeOptionalString(submitted.response_url); if (!statusUrl || !responseUrl) { throw new Error("fal video generation response missing queue URLs"); } @@ -331,7 +336,7 @@ export function buildFalVideoGenerationProvider(): VideoGenerationProvider { }); const videoPayload = extractFalVideoPayload(payload); const entry = extractFalVideoEntry(videoPayload); - const url = entry?.url?.trim(); + const url = normalizeOptionalString(entry?.url); if (!url) { throw new Error("fal video generation response missing output URL"); } @@ -340,7 +345,9 @@ export function buildFalVideoGenerationProvider(): VideoGenerationProvider { videos: [video], model, metadata: { - ...(submitted.request_id?.trim() ? { requestId: submitted.request_id.trim() } : {}), + ...(normalizeOptionalString(submitted.request_id) + ? { requestId: normalizeOptionalString(submitted.request_id) } + : {}), ...(videoPayload.prompt ? { prompt: videoPayload.prompt } : {}), }, }; diff --git a/extensions/google/api.ts b/extensions/google/api.ts index efafc66a2f3..9c573eac125 100644 --- a/extensions/google/api.ts +++ b/extensions/google/api.ts @@ -8,6 +8,7 @@ import { applyAgentDefaultModelPrimary, type OpenClawConfig, } from "openclaw/plugin-sdk/provider-onboard"; +import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime"; import { normalizeAntigravityModelId, normalizeGoogleModelId } from "./model-id.js"; import { parseGoogleOauthApiKey } from "./oauth-token-shared.js"; export { normalizeAntigravityModelId, normalizeGoogleModelId }; @@ -31,7 +32,7 @@ function isCanonicalGoogleApiOriginShorthand(value: string): boolean { } export function normalizeGoogleApiBaseUrl(baseUrl?: string): string { - const raw = trimTrailingSlashes(baseUrl?.trim() || DEFAULT_GOOGLE_API_BASE_URL); + const raw = trimTrailingSlashes(normalizeOptionalString(baseUrl) || DEFAULT_GOOGLE_API_BASE_URL); try { const url = new URL(raw); url.hash = ""; diff --git a/extensions/google/music-generation-provider.ts b/extensions/google/music-generation-provider.ts index e4c98cf7e83..51e3ccf73b2 100644 --- a/extensions/google/music-generation-provider.ts +++ b/extensions/google/music-generation-provider.ts @@ -7,6 +7,7 @@ import type { } from "openclaw/plugin-sdk/music-generation"; import { isProviderApiKeyConfigured } from "openclaw/plugin-sdk/provider-auth"; import { resolveApiKeyForProvider } from "openclaw/plugin-sdk/provider-auth-runtime"; +import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime"; import { normalizeGoogleApiBaseUrl } from "./api.js"; const DEFAULT_GOOGLE_MUSIC_MODEL = "lyria-3-clip-preview"; @@ -33,13 +34,13 @@ type GoogleGenerateMusicResponse = { }; function resolveConfiguredGoogleMusicBaseUrl(req: MusicGenerationRequest): string | undefined { - const configured = req.cfg?.models?.providers?.google?.baseUrl?.trim(); + const configured = normalizeOptionalString(req.cfg?.models?.providers?.google?.baseUrl); return configured ? normalizeGoogleApiBaseUrl(configured) : undefined; } function buildMusicPrompt(req: MusicGenerationRequest): string { const parts = [req.prompt.trim()]; - const lyrics = req.lyrics?.trim(); + const lyrics = normalizeOptionalString(req.lyrics); if (req.instrumental === true) { parts.push("Instrumental only. No vocals, no sung lyrics, no spoken word."); } @@ -68,16 +69,20 @@ function extractTracks(params: { payload: GoogleGenerateMusicResponse; model: st const tracks: GeneratedMusicAsset[] = []; for (const candidate of params.payload.candidates ?? []) { for (const part of candidate.content?.parts ?? []) { - if (part.text?.trim()) { - lyrics.push(part.text.trim()); + const text = normalizeOptionalString(part.text); + if (text) { + lyrics.push(text); continue; } const inline = part.inlineData ?? part.inline_data; - const data = inline?.data?.trim(); + const data = normalizeOptionalString(inline?.data); if (!data) { continue; } - const mimeType = inline?.mimeType?.trim() || inline?.mime_type?.trim() || "audio/mpeg"; + const mimeType = + normalizeOptionalString(inline?.mimeType) || + normalizeOptionalString(inline?.mime_type) || + "audio/mpeg"; tracks.push({ buffer: Buffer.from(data, "base64"), mimeType, @@ -143,7 +148,7 @@ export function buildGoogleMusicGenerationProvider(): MusicGenerationProvider { throw new Error("Google API key missing"); } - const model = req.model?.trim() || DEFAULT_GOOGLE_MUSIC_MODEL; + const model = normalizeOptionalString(req.model) || DEFAULT_GOOGLE_MUSIC_MODEL; if (req.format) { const supportedFormats = resolveSupportedFormats(model); if (!supportedFormats.includes(req.format)) { @@ -168,7 +173,7 @@ export function buildGoogleMusicGenerationProvider(): MusicGenerationProvider { { text: buildMusicPrompt(req) }, ...(req.inputImages ?? []).map((image) => ({ inlineData: { - mimeType: image.mimeType?.trim() || "image/png", + mimeType: normalizeOptionalString(image.mimeType) || "image/png", data: image.buffer?.toString("base64") ?? "", }, })), @@ -192,7 +197,7 @@ export function buildGoogleMusicGenerationProvider(): MusicGenerationProvider { metadata: { inputImageCount: req.inputImages?.length ?? 0, instrumental: req.instrumental === true, - ...(req.lyrics?.trim() ? { requestedLyrics: true } : {}), + ...(normalizeOptionalString(req.lyrics) ? { requestedLyrics: true } : {}), ...(req.format ? { requestedFormat: req.format } : {}), }, }; diff --git a/extensions/google/video-generation-provider.ts b/extensions/google/video-generation-provider.ts index e2c5ea8bdf4..619f3691a1d 100644 --- a/extensions/google/video-generation-provider.ts +++ b/extensions/google/video-generation-provider.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { GoogleGenAI } from "@google/genai"; import { isProviderApiKeyConfigured } from "openclaw/plugin-sdk/provider-auth"; import { resolveApiKeyForProvider } from "openclaw/plugin-sdk/provider-auth-runtime"; +import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime"; import type { GeneratedVideoAsset, VideoGenerationProvider, @@ -21,12 +22,12 @@ const GOOGLE_VIDEO_MAX_DURATION_SECONDS = GOOGLE_VIDEO_ALLOWED_DURATION_SECONDS[GOOGLE_VIDEO_ALLOWED_DURATION_SECONDS.length - 1]; function resolveConfiguredGoogleVideoBaseUrl(req: VideoGenerationRequest): string | undefined { - const configured = req.cfg?.models?.providers?.google?.baseUrl?.trim(); + const configured = normalizeOptionalString(req.cfg?.models?.providers?.google?.baseUrl); return configured ? normalizeGoogleApiBaseUrl(configured) : undefined; } function parseVideoSize(size: string | undefined): { width: number; height: number } | undefined { - const trimmed = size?.trim(); + const trimmed = normalizeOptionalString(size); if (!trimmed) { return undefined; } @@ -46,7 +47,7 @@ function resolveAspectRatio(params: { aspectRatio?: string; size?: string; }): "16:9" | "9:16" | undefined { - const direct = params.aspectRatio?.trim(); + const direct = normalizeOptionalString(params.aspectRatio); if (direct === "16:9" || direct === "9:16") { return direct; } @@ -103,7 +104,7 @@ function resolveInputImage(req: VideoGenerationRequest) { } return { imageBytes: input.buffer.toString("base64"), - mimeType: input.mimeType?.trim() || "image/png", + mimeType: normalizeOptionalString(input.mimeType) || "image/png", }; } @@ -114,7 +115,7 @@ function resolveInputVideo(req: VideoGenerationRequest) { } return { videoBytes: input.buffer.toString("base64"), - mimeType: input.mimeType?.trim() || "video/mp4", + mimeType: normalizeOptionalString(input.mimeType) || "video/mp4", }; } @@ -230,7 +231,7 @@ export function buildGoogleVideoGenerationProvider(): VideoGenerationProvider { }, }); let operation = await client.models.generateVideos({ - model: req.model?.trim() || DEFAULT_GOOGLE_VIDEO_MODEL, + model: normalizeOptionalString(req.model) || DEFAULT_GOOGLE_VIDEO_MODEL, prompt: req.prompt, image: resolveInputImage(req), video: resolveInputVideo(req), @@ -267,7 +268,7 @@ export function buildGoogleVideoGenerationProvider(): VideoGenerationProvider { if (inline?.videoBytes) { return { buffer: Buffer.from(inline.videoBytes, "base64"), - mimeType: inline.mimeType?.trim() || "video/mp4", + mimeType: normalizeOptionalString(inline.mimeType) || "video/mp4", fileName: `video-${index + 1}.mp4`, }; } @@ -283,7 +284,7 @@ export function buildGoogleVideoGenerationProvider(): VideoGenerationProvider { ); return { videos, - model: req.model?.trim() || DEFAULT_GOOGLE_VIDEO_MODEL, + model: normalizeOptionalString(req.model) || DEFAULT_GOOGLE_VIDEO_MODEL, metadata: operation.name ? { operationName: operation.name, diff --git a/extensions/minimax/music-generation-provider.ts b/extensions/minimax/music-generation-provider.ts index 6d08cbe7738..22c1ac671c2 100644 --- a/extensions/minimax/music-generation-provider.ts +++ b/extensions/minimax/music-generation-provider.ts @@ -95,7 +95,7 @@ async function downloadTrackFromUrl(params: { params.fetchFn, ); await assertOkOrThrowHttpError(response, "MiniMax generated music download failed"); - const mimeType = response.headers.get("content-type")?.trim() || "audio/mpeg"; + const mimeType = normalizeOptionalString(response.headers.get("content-type")) ?? "audio/mpeg"; const ext = extensionForMime(mimeType)?.replace(/^\./u, "") || "mp3"; return { buffer: Buffer.from(await response.arrayBuffer()), @@ -148,7 +148,7 @@ export function buildMinimaxMusicGenerationProvider(): MusicGenerationProvider { if ((req.inputImages?.length ?? 0) > 0) { throw new Error("MiniMax music generation does not support image reference inputs."); } - if (req.instrumental === true && req.lyrics?.trim()) { + if (req.instrumental === true && normalizeOptionalString(req.lyrics)) { throw new Error("MiniMax music generation cannot use lyrics when instrumental=true."); } if (req.format && req.format !== "mp3") { @@ -179,7 +179,7 @@ export function buildMinimaxMusicGenerationProvider(): MusicGenerationProvider { jsonHeaders.set("Content-Type", "application/json"); const model = resolveMinimaxMusicModel(req.model); - const lyrics = req.lyrics?.trim(); + const lyrics = normalizeOptionalString(req.lyrics); const body = { model, prompt: buildPrompt(req), @@ -209,10 +209,11 @@ export function buildMinimaxMusicGenerationProvider(): MusicGenerationProvider { const payload = (await res.json()) as MinimaxMusicCreateResponse; assertMinimaxBaseResp(payload.base_resp, "MiniMax music generation failed"); - const audioCandidate = payload.audio?.trim() || payload.data?.audio?.trim(); + const audioCandidate = + normalizeOptionalString(payload.audio) ?? normalizeOptionalString(payload.data?.audio); const audioUrl = - payload.audio_url?.trim() || - payload.data?.audio_url?.trim() || + normalizeOptionalString(payload.audio_url) || + normalizeOptionalString(payload.data?.audio_url) || (isLikelyRemoteUrl(audioCandidate) ? audioCandidate : undefined); const inlineAudio = isLikelyRemoteUrl(audioCandidate) ? undefined : audioCandidate; const lyrics = decodePossibleText(payload.lyrics ?? payload.data?.lyrics ?? ""); @@ -239,7 +240,9 @@ export function buildMinimaxMusicGenerationProvider(): MusicGenerationProvider { ...(lyrics ? { lyrics: [lyrics] } : {}), model, metadata: { - ...(payload.task_id?.trim() ? { taskId: payload.task_id.trim() } : {}), + ...(normalizeOptionalString(payload.task_id) + ? { taskId: normalizeOptionalString(payload.task_id) } + : {}), ...(audioUrl ? { audioUrl } : {}), instrumental: req.instrumental === true, ...(lyrics ? { requestedLyrics: true } : {}), diff --git a/extensions/minimax/provider-registration.ts b/extensions/minimax/provider-registration.ts index 4a263b3b09e..991b0e6b73b 100644 --- a/extensions/minimax/provider-registration.ts +++ b/extensions/minimax/provider-registration.ts @@ -15,6 +15,7 @@ import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-aut import { buildProviderReplayFamilyHooks } from "openclaw/plugin-sdk/provider-model-shared"; import { buildProviderStreamFamilyHooks } from "openclaw/plugin-sdk/provider-stream-family"; import { fetchMinimaxUsage } from "openclaw/plugin-sdk/provider-usage"; +import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime"; import { isMiniMaxModernModelId, MINIMAX_DEFAULT_MODEL_ID } from "./api.js"; import type { MiniMaxRegion } from "./oauth.js"; import { applyMinimaxApiConfig, applyMinimaxApiConfigCn } from "./onboard.js"; @@ -82,15 +83,13 @@ function resolvePortalCatalog(ctx: ProviderCatalogContext) { allowKeychainPrompt: false, }); const hasProfiles = listProfilesForProvider(authStore, PORTAL_PROVIDER_ID).length > 0; - const explicitApiKey = - typeof explicitProvider?.apiKey === "string" ? explicitProvider.apiKey.trim() : undefined; + const explicitApiKey = normalizeOptionalString(explicitProvider?.apiKey); const apiKey = envApiKey ?? explicitApiKey ?? (hasProfiles ? MINIMAX_OAUTH_MARKER : undefined); if (!apiKey) { return null; } - const explicitBaseUrl = - typeof explicitProvider?.baseUrl === "string" ? explicitProvider.baseUrl.trim() : undefined; + const explicitBaseUrl = normalizeOptionalString(explicitProvider?.baseUrl); return { provider: buildPortalProviderCatalog({ diff --git a/extensions/minimax/video-generation-provider.ts b/extensions/minimax/video-generation-provider.ts index 88058d2b215..cb5dd389b96 100644 --- a/extensions/minimax/video-generation-provider.ts +++ b/extensions/minimax/video-generation-provider.ts @@ -6,6 +6,7 @@ import { postJsonRequest, resolveProviderHttpRequestConfig, } from "openclaw/plugin-sdk/provider-http"; +import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime"; import type { GeneratedVideoAsset, VideoGenerationProvider, @@ -51,7 +52,7 @@ type MinimaxFileRetrieveResponse = { function resolveMinimaxVideoBaseUrl( cfg: Parameters[0]["cfg"], ): string { - const direct = cfg?.models?.providers?.minimax?.baseUrl?.trim(); + const direct = normalizeOptionalString(cfg?.models?.providers?.minimax?.baseUrl); if (!direct) { return DEFAULT_MINIMAX_VIDEO_BASE_URL; } @@ -80,13 +81,14 @@ function resolveFirstFrameImage(req: VideoGenerationRequest): string | undefined if (!input) { return undefined; } - if (input.url?.trim()) { - return input.url.trim(); + const inputUrl = normalizeOptionalString(input.url); + if (inputUrl) { + return inputUrl; } if (!input.buffer) { throw new Error("MiniMax image-to-video input is missing image data."); } - return toDataUrl(input.buffer, input.mimeType?.trim() || "image/png"); + return toDataUrl(input.buffer, normalizeOptionalString(input.mimeType) ?? "image/png"); } function resolveDurationSeconds(params: { @@ -128,11 +130,14 @@ async function pollMinimaxVideo(params: { await assertOkOrThrowHttpError(response, "MiniMax video status request failed"); const payload = (await response.json()) as MinimaxQueryResponse; assertMinimaxBaseResp(payload.base_resp, "MiniMax video generation failed"); - switch (payload.status?.trim()) { + switch (normalizeOptionalString(payload.status)) { case "Success": return payload; case "Fail": - throw new Error(payload.base_resp?.status_msg?.trim() || "MiniMax video generation failed"); + throw new Error( + normalizeOptionalString(payload.base_resp?.status_msg) || + "MiniMax video generation failed", + ); case "Preparing": case "Processing": default: @@ -155,7 +160,7 @@ async function downloadVideoFromUrl(params: { params.fetchFn, ); await assertOkOrThrowHttpError(response, "MiniMax generated video download failed"); - const mimeType = response.headers.get("content-type")?.trim() || "video/mp4"; + const mimeType = normalizeOptionalString(response.headers.get("content-type")) ?? "video/mp4"; const arrayBuffer = await response.arrayBuffer(); return { buffer: Buffer.from(arrayBuffer), @@ -188,7 +193,7 @@ async function downloadVideoFromFileId(params: { ); const metadata = (await metadataResponse.json()) as MinimaxFileRetrieveResponse; assertMinimaxBaseResp(metadata.base_resp, "MiniMax generated video metadata request failed"); - const downloadUrl = metadata.file?.download_url?.trim(); + const downloadUrl = normalizeOptionalString(metadata.file?.download_url); if (!downloadUrl) { throw new Error("MiniMax generated video metadata missing download_url"); } @@ -199,13 +204,14 @@ async function downloadVideoFromFileId(params: { params.fetchFn, ); await assertOkOrThrowHttpError(response, "MiniMax generated video download failed"); - const mimeType = response.headers.get("content-type")?.trim() || "video/mp4"; + const mimeType = normalizeOptionalString(response.headers.get("content-type")) ?? "video/mp4"; const arrayBuffer = await response.arrayBuffer(); return { buffer: Buffer.from(arrayBuffer), mimeType, fileName: - metadata.file?.filename?.trim() || `video-1.${mimeType.includes("webm") ? "webm" : "mp4"}`, + normalizeOptionalString(metadata.file?.filename) || + `video-1.${mimeType.includes("webm") ? "webm" : "mp4"}`, }; } @@ -276,7 +282,7 @@ export function buildMinimaxVideoGenerationProvider(): VideoGenerationProvider { capability: "video", transport: "http", }); - const model = req.model?.trim() || DEFAULT_MINIMAX_VIDEO_MODEL; + const model = normalizeOptionalString(req.model) ?? DEFAULT_MINIMAX_VIDEO_MODEL; const body: Record = { model, prompt: req.prompt, @@ -308,7 +314,7 @@ export function buildMinimaxVideoGenerationProvider(): VideoGenerationProvider { await assertOkOrThrowHttpError(response, "MiniMax video generation failed"); const submitted = (await response.json()) as MinimaxCreateResponse; assertMinimaxBaseResp(submitted.base_resp, "MiniMax video generation failed"); - const taskId = submitted.task_id?.trim(); + const taskId = normalizeOptionalString(submitted.task_id); if (!taskId) { throw new Error("MiniMax video generation response missing task_id"); } @@ -319,8 +325,8 @@ export function buildMinimaxVideoGenerationProvider(): VideoGenerationProvider { baseUrl, fetchFn, }); - const videoUrl = completed.video_url?.trim(); - const fileId = completed.file_id?.trim(); + const videoUrl = normalizeOptionalString(completed.video_url); + const fileId = normalizeOptionalString(completed.file_id); const video = videoUrl ? await downloadVideoFromUrl({ url: videoUrl, diff --git a/extensions/openai/shared.ts b/extensions/openai/shared.ts index 57f8898fe23..2106d203c15 100644 --- a/extensions/openai/shared.ts +++ b/extensions/openai/shared.ts @@ -4,6 +4,7 @@ import { cloneFirstTemplateModel, matchesExactOrPrefix, } from "openclaw/plugin-sdk/provider-model-shared"; +import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime"; type SyntheticOpenAIModelCatalogEntry = { provider: string; @@ -22,11 +23,11 @@ export function toOpenAIDataUrl(buffer: Buffer, mimeType: string): string { } export function resolveConfiguredOpenAIBaseUrl(cfg: OpenClawConfig | undefined): string { - return cfg?.models?.providers?.openai?.baseUrl?.trim() || OPENAI_API_BASE_URL; + return normalizeOptionalString(cfg?.models?.providers?.openai?.baseUrl) ?? OPENAI_API_BASE_URL; } export function isOpenAIApiBaseUrl(baseUrl?: string): boolean { - const trimmed = baseUrl?.trim(); + const trimmed = normalizeOptionalString(baseUrl); if (!trimmed) { return false; } @@ -34,7 +35,7 @@ export function isOpenAIApiBaseUrl(baseUrl?: string): boolean { } export function isOpenAICodexBaseUrl(baseUrl?: string): boolean { - const trimmed = baseUrl?.trim(); + const trimmed = normalizeOptionalString(baseUrl); if (!trimmed) { return false; } diff --git a/extensions/openai/video-generation-provider.ts b/extensions/openai/video-generation-provider.ts index e7a88bb7ca8..0aef298e4cc 100644 --- a/extensions/openai/video-generation-provider.ts +++ b/extensions/openai/video-generation-provider.ts @@ -6,6 +6,7 @@ import { postJsonRequest, resolveProviderHttpRequestConfig, } from "openclaw/plugin-sdk/provider-http"; +import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime"; import type { GeneratedVideoAsset, VideoGenerationProvider, @@ -58,14 +59,14 @@ function resolveSize(params: { aspectRatio?: string; resolution?: string; }): (typeof OPENAI_VIDEO_SIZES)[number] | undefined { - const explicitSize = params.size?.trim(); + const explicitSize = normalizeOptionalString(params.size); if ( explicitSize && OPENAI_VIDEO_SIZES.includes(explicitSize as (typeof OPENAI_VIDEO_SIZES)[number]) ) { return explicitSize as (typeof OPENAI_VIDEO_SIZES)[number]; } - switch (params.aspectRatio?.trim()) { + switch (normalizeOptionalString(params.aspectRatio)) { case "9:16": return "720x1280"; case "16:9": @@ -98,7 +99,8 @@ function resolveReferenceAsset(req: VideoGenerationRequest) { ); } const mimeType = - asset.mimeType?.trim() || ((req.inputVideos?.length ?? 0) > 0 ? "video/mp4" : "image/png"); + normalizeOptionalString(asset.mimeType) || + ((req.inputVideos?.length ?? 0) > 0 ? "video/mp4" : "image/png"); const extension = mimeType.includes("video") ? "mp4" : mimeType.includes("jpeg") @@ -107,7 +109,7 @@ function resolveReferenceAsset(req: VideoGenerationRequest) { ? "webp" : "png"; const fileName = - asset.fileName?.trim() || + normalizeOptionalString(asset.fileName) || `${(req.inputVideos?.length ?? 0) > 0 ? "reference-video" : "reference-image"}.${extension}`; return new File([toBlobBytes(asset.buffer)], fileName, { type: mimeType }); } @@ -135,7 +137,9 @@ async function pollOpenAIVideo(params: { return payload; } if (payload.status === "failed") { - throw new Error(payload.error?.message?.trim() || "OpenAI video generation failed"); + throw new Error( + normalizeOptionalString(payload.error?.message) || "OpenAI video generation failed", + ); } await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)); } @@ -164,7 +168,7 @@ async function downloadOpenAIVideo(params: { params.fetchFn, ); await assertOkOrThrowHttpError(response, "OpenAI video download failed"); - const mimeType = response.headers.get("content-type")?.trim() || "video/mp4"; + const mimeType = normalizeOptionalString(response.headers.get("content-type")) ?? "video/mp4"; const arrayBuffer = await response.arrayBuffer(); return { buffer: Buffer.from(arrayBuffer), @@ -236,7 +240,7 @@ export function buildOpenAIVideoGenerationProvider(): VideoGenerationProvider { transport: "http", }); - const model = req.model?.trim() || DEFAULT_OPENAI_VIDEO_MODEL; + const model = normalizeOptionalString(req.model) ?? DEFAULT_OPENAI_VIDEO_MODEL; const seconds = resolveDurationSeconds(req.durationSeconds); const size = resolveSize({ size: req.size, @@ -262,7 +266,7 @@ export function buildOpenAIVideoGenerationProvider(): VideoGenerationProvider { input_reference: { image_url: toOpenAIDataUrl( inputImage.buffer, - inputImage.mimeType?.trim() || "image/png", + normalizeOptionalString(inputImage.mimeType) ?? "image/png", ), }, }, @@ -322,7 +326,7 @@ export function buildOpenAIVideoGenerationProvider(): VideoGenerationProvider { try { await assertOkOrThrowHttpError(response, "OpenAI video generation failed"); const submitted = (await response.json()) as OpenAIVideoResponse; - const videoId = submitted.id?.trim(); + const videoId = normalizeOptionalString(submitted.id); if (!videoId) { throw new Error("OpenAI video generation response missing video id"); } diff --git a/extensions/runway/video-generation-provider.ts b/extensions/runway/video-generation-provider.ts index da3b0b2789b..09f9edff403 100644 --- a/extensions/runway/video-generation-provider.ts +++ b/extensions/runway/video-generation-provider.ts @@ -6,7 +6,10 @@ import { postJsonRequest, resolveProviderHttpRequestConfig, } from "openclaw/plugin-sdk/provider-http"; -import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime"; +import { + normalizeLowercaseStringOrEmpty, + normalizeOptionalString, +} from "openclaw/plugin-sdk/text-runtime"; import type { GeneratedVideoAsset, VideoGenerationProvider, @@ -50,7 +53,9 @@ const RUNWAY_TEXT_ASPECT_RATIOS = ["16:9", "9:16"] as const; const RUNWAY_EDIT_ASPECT_RATIOS = ["1:1", "16:9", "9:16", "3:4", "4:3", "21:9"] as const; function resolveRunwayBaseUrl(req: VideoGenerationRequest): string { - return req.cfg?.models?.providers?.runway?.baseUrl?.trim() || DEFAULT_RUNWAY_BASE_URL; + return ( + normalizeOptionalString(req.cfg?.models?.providers?.runway?.baseUrl) ?? DEFAULT_RUNWAY_BASE_URL + ); } function toDataUrl(buffer: Buffer, mimeType: string): string { @@ -64,14 +69,14 @@ function resolveSourceUri( if (!asset) { return undefined; } - const url = asset.url?.trim(); + const url = normalizeOptionalString(asset.url); if (url) { return url; } if (!asset.buffer) { return undefined; } - return toDataUrl(asset.buffer, asset.mimeType?.trim() || fallbackMimeType); + return toDataUrl(asset.buffer, normalizeOptionalString(asset.mimeType) ?? fallbackMimeType); } function resolveDurationSeconds(value: number | undefined): number { @@ -84,9 +89,9 @@ function resolveDurationSeconds(value: number | undefined): number { function resolveRunwayRatio(req: VideoGenerationRequest): string { const hasImageInput = (req.inputImages?.length ?? 0) > 0; const requested = - req.size?.trim() || + normalizeOptionalString(req.size) || (() => { - switch (req.aspectRatio?.trim()) { + switch (normalizeOptionalString(req.aspectRatio)) { case "9:16": return "720:1280"; case "16:9": @@ -136,7 +141,7 @@ function buildCreateBody(req: VideoGenerationRequest): Record { const endpoint = resolveEndpoint(req); const duration = resolveDurationSeconds(req.durationSeconds); const ratio = resolveRunwayRatio(req); - const model = req.model?.trim() || DEFAULT_RUNWAY_MODEL; + const model = normalizeOptionalString(req.model) ?? DEFAULT_RUNWAY_MODEL; if (endpoint === "/v1/text_to_video") { if (!TEXT_ONLY_MODELS.has(model)) { throw new Error( @@ -210,10 +215,9 @@ async function pollRunwayTask(params: { case "FAILED": case "CANCELLED": throw new Error( - (typeof payload.failure === "string" - ? payload.failure - : payload.failure?.message - )?.trim() || `Runway video generation ${normalizeLowercaseStringOrEmpty(payload.status)}`, + normalizeOptionalString( + typeof payload.failure === "string" ? payload.failure : payload.failure?.message, + ) || `Runway video generation ${normalizeLowercaseStringOrEmpty(payload.status)}`, ); case "PENDING": case "RUNNING": @@ -240,7 +244,7 @@ async function downloadRunwayVideos(params: { params.fetchFn, ); await assertOkOrThrowHttpError(response, "Runway generated video download failed"); - const mimeType = response.headers.get("content-type")?.trim() || "video/mp4"; + const mimeType = normalizeOptionalString(response.headers.get("content-type")) ?? "video/mp4"; const arrayBuffer = await response.arrayBuffer(); videos.push({ buffer: Buffer.from(arrayBuffer), @@ -325,7 +329,7 @@ export function buildRunwayVideoGenerationProvider(): VideoGenerationProvider { try { await assertOkOrThrowHttpError(response, "Runway video generation failed"); const submitted = (await response.json()) as RunwayTaskCreateResponse; - const taskId = submitted.id?.trim(); + const taskId = normalizeOptionalString(submitted.id); if (!taskId) { throw new Error("Runway video generation response missing task id"); } @@ -336,9 +340,9 @@ export function buildRunwayVideoGenerationProvider(): VideoGenerationProvider { baseUrl, fetchFn, }); - const outputUrls = completed.output?.filter( - (value) => typeof value === "string" && value.trim(), - ); + const outputUrls = completed.output + ?.map((value) => normalizeOptionalString(value)) + .filter((value): value is string => Boolean(value)); if (!outputUrls?.length) { throw new Error("Runway video generation completed without output URLs"); } @@ -349,7 +353,7 @@ export function buildRunwayVideoGenerationProvider(): VideoGenerationProvider { }); return { videos, - model: req.model?.trim() || DEFAULT_RUNWAY_MODEL, + model: normalizeOptionalString(req.model) ?? DEFAULT_RUNWAY_MODEL, metadata: { taskId, status: completed.status, diff --git a/extensions/together/video-generation-provider.ts b/extensions/together/video-generation-provider.ts index 9c0380b29ab..5114465d83e 100644 --- a/extensions/together/video-generation-provider.ts +++ b/extensions/together/video-generation-provider.ts @@ -6,6 +6,7 @@ import { postJsonRequest, resolveProviderHttpRequestConfig, } from "openclaw/plugin-sdk/provider-http"; +import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime"; import type { GeneratedVideoAsset, VideoGenerationProvider, @@ -38,7 +39,9 @@ type TogetherVideoResponse = { }; function resolveTogetherVideoBaseUrl(req: VideoGenerationRequest): string { - return req.cfg?.models?.providers?.together?.baseUrl?.trim() || TOGETHER_BASE_URL; + return ( + normalizeOptionalString(req.cfg?.models?.providers?.together?.baseUrl) ?? TOGETHER_BASE_URL + ); } function toDataUrl(buffer: Buffer, mimeType: string): string { @@ -48,14 +51,17 @@ function toDataUrl(buffer: Buffer, mimeType: string): string { function extractTogetherVideoUrl(payload: TogetherVideoResponse): string | undefined { if (Array.isArray(payload.outputs)) { for (const entry of payload.outputs) { - const url = entry.video_url?.trim() || entry.url?.trim(); + const url = normalizeOptionalString(entry.video_url) ?? normalizeOptionalString(entry.url); if (url) { return url; } } return undefined; } - return payload.outputs?.video_url?.trim() || payload.outputs?.url?.trim(); + return ( + normalizeOptionalString(payload.outputs?.video_url) ?? + normalizeOptionalString(payload.outputs?.url) + ); } async function pollTogetherVideo(params: { @@ -81,7 +87,9 @@ async function pollTogetherVideo(params: { return payload; } if (payload.status === "failed") { - throw new Error(payload.error?.message?.trim() || "Together video generation failed"); + throw new Error( + normalizeOptionalString(payload.error?.message) ?? "Together video generation failed", + ); } await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)); } @@ -100,7 +108,7 @@ async function downloadTogetherVideo(params: { params.fetchFn, ); await assertOkOrThrowHttpError(response, "Together generated video download failed"); - const mimeType = response.headers.get("content-type")?.trim() || "video/mp4"; + const mimeType = normalizeOptionalString(response.headers.get("content-type")) ?? "video/mp4"; const arrayBuffer = await response.arrayBuffer(); return { buffer: Buffer.from(arrayBuffer), @@ -171,14 +179,15 @@ export function buildTogetherVideoGenerationProvider(): VideoGenerationProvider transport: "http", }); const body: Record = { - model: req.model?.trim() || DEFAULT_TOGETHER_VIDEO_MODEL, + model: normalizeOptionalString(req.model) ?? DEFAULT_TOGETHER_VIDEO_MODEL, prompt: req.prompt, }; if (typeof req.durationSeconds === "number" && Number.isFinite(req.durationSeconds)) { body.seconds = String(Math.max(1, Math.round(req.durationSeconds))); } - if (req.size?.trim()) { - const match = /^(\d+)x(\d+)$/u.exec(req.size.trim()); + const size = normalizeOptionalString(req.size); + if (size) { + const match = /^(\d+)x(\d+)$/u.exec(size); if (match) { body.width = Number.parseInt(match[1] ?? "", 10); body.height = Number.parseInt(match[2] ?? "", 10); @@ -186,10 +195,10 @@ export function buildTogetherVideoGenerationProvider(): VideoGenerationProvider } if (req.inputImages?.[0]) { const input = req.inputImages[0]; - const value = input.url?.trim() - ? input.url.trim() + const value = normalizeOptionalString(input.url) + ? normalizeOptionalString(input.url) : input.buffer - ? toDataUrl(input.buffer, input.mimeType?.trim() || "image/png") + ? toDataUrl(input.buffer, normalizeOptionalString(input.mimeType) ?? "image/png") : undefined; if (!value) { throw new Error("Together reference image is missing image data."); @@ -208,7 +217,7 @@ export function buildTogetherVideoGenerationProvider(): VideoGenerationProvider try { await assertOkOrThrowHttpError(response, "Together video generation failed"); const submitted = (await response.json()) as TogetherVideoResponse; - const videoId = submitted.id?.trim(); + const videoId = normalizeOptionalString(submitted.id); if (!videoId) { throw new Error("Together video generation response missing id"); } diff --git a/extensions/xai/video-generation-provider.ts b/extensions/xai/video-generation-provider.ts index d8ffd43e30a..be9a3929f5b 100644 --- a/extensions/xai/video-generation-provider.ts +++ b/extensions/xai/video-generation-provider.ts @@ -47,7 +47,9 @@ type VideoGenerationSourceInput = { }; function resolveXaiVideoBaseUrl(req: VideoGenerationRequest): string { - return req.cfg?.models?.providers?.xai?.baseUrl?.trim() || DEFAULT_XAI_VIDEO_BASE_URL; + return ( + normalizeOptionalString(req.cfg?.models?.providers?.xai?.baseUrl) ?? DEFAULT_XAI_VIDEO_BASE_URL + ); } function toDataUrl(buffer: Buffer, mimeType: string): string { @@ -58,20 +60,21 @@ function resolveImageUrl(input: VideoGenerationSourceInput | undefined): string if (!input) { return undefined; } - if (input.url?.trim()) { - return input.url.trim(); + const inputUrl = normalizeOptionalString(input.url); + if (inputUrl) { + return inputUrl; } if (!input.buffer) { throw new Error("xAI image-to-video input is missing image data."); } - return toDataUrl(input.buffer, input.mimeType?.trim() || "image/png"); + return toDataUrl(input.buffer, normalizeOptionalString(input.mimeType) ?? "image/png"); } function resolveInputVideoUrl(input: VideoGenerationSourceInput | undefined): string | undefined { if (!input) { return undefined; } - const url = input.url?.trim(); + const url = normalizeOptionalString(input.url); if (url) { return url; } @@ -138,7 +141,7 @@ function buildCreateBody(req: VideoGenerationRequest): Record { const mode = resolveXaiVideoMode(req); const body: Record = { - model: req.model?.trim() || DEFAULT_XAI_VIDEO_MODEL, + model: normalizeOptionalString(req.model) ?? DEFAULT_XAI_VIDEO_MODEL, prompt: req.prompt, }; @@ -216,7 +219,10 @@ async function pollXaiVideo(params: { return payload; case "failed": case "expired": - throw new Error(payload.error?.message?.trim() || `xAI video generation ${payload.status}`); + throw new Error( + normalizeOptionalString(payload.error?.message) ?? + `xAI video generation ${payload.status}`, + ); case "queued": case "processing": default: @@ -239,7 +245,7 @@ async function downloadXaiVideo(params: { params.fetchFn, ); await assertOkOrThrowHttpError(response, "xAI generated video download failed"); - const mimeType = response.headers.get("content-type")?.trim() || "video/mp4"; + const mimeType = normalizeOptionalString(response.headers.get("content-type")) ?? "video/mp4"; const arrayBuffer = await response.arrayBuffer(); return { buffer: Buffer.from(arrayBuffer), @@ -324,10 +330,11 @@ export function buildXaiVideoGenerationProvider(): VideoGenerationProvider { try { await assertOkOrThrowHttpError(response, "xAI video generation failed"); const submitted = (await response.json()) as XaiVideoCreateResponse; - const requestId = submitted.request_id?.trim(); + const requestId = normalizeOptionalString(submitted.request_id); if (!requestId) { throw new Error( - submitted.error?.message?.trim() || "xAI video generation response missing request_id", + normalizeOptionalString(submitted.error?.message) ?? + "xAI video generation response missing request_id", ); } const completed = await pollXaiVideo({ @@ -337,7 +344,7 @@ export function buildXaiVideoGenerationProvider(): VideoGenerationProvider { baseUrl, fetchFn, }); - const videoUrl = completed.video?.url?.trim(); + const videoUrl = normalizeOptionalString(completed.video?.url); if (!videoUrl) { throw new Error("xAI video generation completed without an output URL"); } @@ -348,7 +355,7 @@ export function buildXaiVideoGenerationProvider(): VideoGenerationProvider { }); return { videos: [video], - model: req.model?.trim() || DEFAULT_XAI_VIDEO_MODEL, + model: normalizeOptionalString(req.model) ?? DEFAULT_XAI_VIDEO_MODEL, metadata: { requestId, status: completed.status, diff --git a/ui/src/ui/app-render.helpers.ts b/ui/src/ui/app-render.helpers.ts index 3e9bd70f6a4..46a88bd4c5f 100644 --- a/ui/src/ui/app-render.helpers.ts +++ b/ui/src/ui/app-render.helpers.ts @@ -16,7 +16,7 @@ import { loadSessions } from "./controllers/sessions.ts"; import { icons } from "./icons.ts"; import { iconForTab, pathForTab, titleForTab, type Tab } from "./navigation.ts"; import { parseAgentSessionKey } from "./session-key.ts"; -import { normalizeLowercaseStringOrEmpty } from "./string-coerce.ts"; +import { normalizeLowercaseStringOrEmpty, normalizeOptionalString } from "./string-coerce.ts"; import type { ThemeMode } from "./theme.ts"; import { listThinkingLevelLabels, @@ -34,11 +34,11 @@ function resolveSidebarChatSessionKey(state: AppViewState): string { const snapshot = state.hello?.snapshot as | { sessionDefaults?: SessionDefaultsSnapshot } | undefined; - const mainSessionKey = snapshot?.sessionDefaults?.mainSessionKey?.trim(); + const mainSessionKey = normalizeOptionalString(snapshot?.sessionDefaults?.mainSessionKey); if (mainSessionKey) { return mainSessionKey; } - const mainKey = snapshot?.sessionDefaults?.mainKey?.trim(); + const mainKey = normalizeOptionalString(snapshot?.sessionDefaults?.mainKey); if (mainKey) { return mainKey; } @@ -857,8 +857,8 @@ export function resolveSessionDisplayName( key: string, row?: SessionsListResult["sessions"][number], ): string { - const label = row?.label?.trim() || ""; - const displayName = row?.displayName?.trim() || ""; + const label = normalizeOptionalString(row?.label) ?? ""; + const displayName = normalizeOptionalString(row?.displayName) ?? ""; const { prefix, fallbackName } = parseSessionKey(key); const applyTypedPrefix = (name: string): string => { @@ -951,7 +951,7 @@ export function resolveSessionOptionGroups( resolveAgentGroupLabel(state, parsed.agentId), ) : ensureGroup("other", "Other Sessions"); - const scopeLabel = parsed?.rest?.trim() || key; + const scopeLabel = normalizeOptionalString(parsed?.rest) ?? key; const label = resolveSessionScopedOptionLabel(key, row, parsed?.rest); group.options.push({ key, @@ -1065,7 +1065,8 @@ function resolveAgentGroupLabel(state: AppViewState, agentIdRaw: string): string const agent = (state.agentsList?.agents ?? []).find( (entry) => normalizeLowercaseStringOrEmpty(entry.id) === normalized, ); - const name = agent?.identity?.name?.trim() || agent?.name?.trim() || ""; + const name = + normalizeOptionalString(agent?.identity?.name) ?? normalizeOptionalString(agent?.name) ?? ""; return name && name !== agentIdRaw ? `${name} (${agentIdRaw})` : agentIdRaw; } @@ -1074,13 +1075,13 @@ function resolveSessionScopedOptionLabel( row?: SessionsListResult["sessions"][number], rest?: string, ) { - const base = rest?.trim() || key; + const base = normalizeOptionalString(rest) ?? key; if (!row) { return base; } - const label = row.label?.trim() || ""; - const displayName = row.displayName?.trim() || ""; + const label = normalizeOptionalString(row.label) ?? ""; + const displayName = normalizeOptionalString(row.displayName) ?? ""; if ((label && label !== key) || (displayName && displayName !== key)) { return resolveSessionDisplayName(key, row); } diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index 4151f47a64f..eecd3c7ff91 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -109,7 +109,11 @@ import { parseAgentSessionKey, resolveAgentIdFromSessionKey, } from "./session-key.ts"; -import { normalizeLowercaseStringOrEmpty } from "./string-coerce.ts"; +import { + normalizeLowercaseStringOrEmpty, + normalizeOptionalString, + normalizeStringifiedOptionalString, +} from "./string-coerce.ts"; import { agentLogoUrl } from "./views/agents-utils.ts"; import { resolveAgentConfig, @@ -208,7 +212,7 @@ function isHttpUrl(value: string): boolean { } function normalizeSuggestionValue(value: unknown): string { - return typeof value === "string" ? value.trim() : ""; + return normalizeOptionalString(value) ?? ""; } function uniquePreserveOrder(values: string[]): string[] { @@ -408,9 +412,7 @@ export function renderApp(state: AppViewState) { new Set( [ ...(state.agentsList?.agents?.map((entry) => entry.id.trim()) ?? []), - ...state.cronJobs - .map((job) => (typeof job.agentId === "string" ? job.agentId.trim() : "")) - .filter(Boolean), + ...state.cronJobs.map((job) => normalizeOptionalString(job.agentId) ?? "").filter(Boolean), ].filter(Boolean), ), ); @@ -424,7 +426,7 @@ export function renderApp(state: AppViewState) { if (job.payload.kind !== "agentTurn" || typeof job.payload.model !== "string") { return ""; } - return job.payload.model.trim(); + return normalizeOptionalString(job.payload.model) ?? ""; }) .filter(Boolean), ].filter(Boolean), @@ -1289,7 +1291,7 @@ export function renderApp(state: AppViewState) { const entry = Array.isArray(list) ? (list[index] as { skills?: unknown }) : undefined; - const normalizedSkill = skillName.trim(); + const normalizedSkill = normalizeOptionalString(skillName) ?? ""; if (!normalizedSkill) { return; } @@ -1297,7 +1299,9 @@ export function renderApp(state: AppViewState) { state.agentSkillsReport?.skills?.map((skill) => skill.name).filter(Boolean) ?? []; const existing = Array.isArray(entry?.skills) - ? entry.skills.map((name) => String(name).trim()).filter(Boolean) + ? entry.skills + .map((name) => normalizeStringifiedOptionalString(name) ?? "") + .filter(Boolean) : undefined; const base = existing ?? allSkills; const next = new Set(base); diff --git a/ui/src/ui/string-coerce.ts b/ui/src/ui/string-coerce.ts index a43297b14ab..fee006e26c6 100644 --- a/ui/src/ui/string-coerce.ts +++ b/ui/src/ui/string-coerce.ts @@ -6,6 +6,16 @@ export function normalizeOptionalString(value: unknown): string | undefined { return trimmed ? trimmed : undefined; } +export function normalizeStringifiedOptionalString(value: unknown): string | undefined { + if (typeof value === "string") { + return normalizeOptionalString(value); + } + if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") { + return normalizeOptionalString(String(value)); + } + return undefined; +} + export function normalizeOptionalLowercaseString(value: unknown): string | undefined { return normalizeOptionalString(value)?.toLowerCase(); } diff --git a/ui/src/ui/views/agents-utils.ts b/ui/src/ui/views/agents-utils.ts index 48c364efa08..a92fd057097 100644 --- a/ui/src/ui/views/agents-utils.ts +++ b/ui/src/ui/views/agents-utils.ts @@ -4,7 +4,7 @@ import { normalizeToolName, resolveToolProfilePolicy, } from "../../../../src/agents/tool-policy-shared.js"; -import { normalizeLowercaseStringOrEmpty } from "../string-coerce.ts"; +import { normalizeLowercaseStringOrEmpty, normalizeOptionalString } from "../string-coerce.ts"; import type { AgentIdentityResult, AgentsFilesListResult, @@ -193,7 +193,9 @@ export function normalizeAgentLabel(agent: { name?: string; identity?: { name?: string }; }) { - return agent.name?.trim() || agent.identity?.name?.trim() || agent.id; + return ( + normalizeOptionalString(agent.name) ?? normalizeOptionalString(agent.identity?.name) ?? agent.id + ); } const AVATAR_URL_RE = /^(https?:\/\/|data:image\/|\/)/i; @@ -203,9 +205,9 @@ export function resolveAgentAvatarUrl( agentIdentity?: AgentIdentityResult | null, ): string | null { const candidates = [ - agentIdentity?.avatar?.trim(), - agent.identity?.avatarUrl?.trim(), - agent.identity?.avatar?.trim(), + normalizeOptionalString(agentIdentity?.avatar), + normalizeOptionalString(agent.identity?.avatarUrl), + normalizeOptionalString(agent.identity?.avatar), ]; for (const candidate of candidates) { if (!candidate) { @@ -219,7 +221,7 @@ export function resolveAgentAvatarUrl( } export function agentLogoUrl(basePath: string): string { - const base = basePath?.trim() ? basePath.replace(/\/$/, "") : ""; + const base = normalizeOptionalString(basePath)?.replace(/\/$/, "") ?? ""; return base ? `${base}/favicon.svg` : "favicon.svg"; } @@ -251,19 +253,19 @@ export function resolveAgentEmoji( agent: { identity?: { emoji?: string; avatar?: string } }, agentIdentity?: AgentIdentityResult | null, ) { - const identityEmoji = agentIdentity?.emoji?.trim(); + const identityEmoji = normalizeOptionalString(agentIdentity?.emoji); if (identityEmoji && isLikelyEmoji(identityEmoji)) { return identityEmoji; } - const agentEmoji = agent.identity?.emoji?.trim(); + const agentEmoji = normalizeOptionalString(agent.identity?.emoji); if (agentEmoji && isLikelyEmoji(agentEmoji)) { return agentEmoji; } - const identityAvatar = agentIdentity?.avatar?.trim(); + const identityAvatar = normalizeOptionalString(agentIdentity?.avatar); if (identityAvatar && isLikelyEmoji(identityAvatar)) { return identityAvatar; } - const avatar = agent.identity?.avatar?.trim(); + const avatar = normalizeOptionalString(agent.identity?.avatar); if (avatar && isLikelyEmoji(avatar)) { return avatar; } @@ -341,9 +343,9 @@ export function buildAgentContext( ? resolveModelLabel(config.defaults?.model) : resolveModelLabel(agent.model); const identityName = - agentIdentity?.name?.trim() || - agent.identity?.name?.trim() || - agent.name?.trim() || + normalizeOptionalString(agentIdentity?.name) || + normalizeOptionalString(agent.identity?.name) || + normalizeOptionalString(agent.name) || config.entry?.name || agent.id; const identityAvatar = resolveAgentAvatarUrl(agent, agentIdentity) ? "custom" : "—"; @@ -364,11 +366,11 @@ export function resolveModelLabel(model?: unknown): string { return "-"; } if (typeof model === "string") { - return model.trim() || "-"; + return normalizeOptionalString(model) || "-"; } if (typeof model === "object" && model) { const record = model as { primary?: string; fallbacks?: string[] }; - const primary = record.primary?.trim(); + const primary = normalizeOptionalString(record.primary); if (primary) { const fallbackCount = Array.isArray(record.fallbacks) ? record.fallbacks.length : 0; return fallbackCount > 0 ? `${primary} (+${fallbackCount} fallback)` : primary; @@ -387,7 +389,7 @@ export function resolveModelPrimary(model?: unknown): string | null { return null; } if (typeof model === "string") { - const trimmed = model.trim(); + const trimmed = normalizeOptionalString(model); return trimmed || null; } if (typeof model === "object" && model) { @@ -402,7 +404,7 @@ export function resolveModelPrimary(model?: unknown): string | null { : typeof record.value === "string" ? record.value : null; - const primary = candidate?.trim(); + const primary = normalizeOptionalString(candidate); return primary || null; } return null; diff --git a/ui/src/ui/views/nodes.ts b/ui/src/ui/views/nodes.ts index 7020845e6bc..e0670ace691 100644 --- a/ui/src/ui/views/nodes.ts +++ b/ui/src/ui/views/nodes.ts @@ -8,6 +8,7 @@ import type { } from "../controllers/devices.ts"; import type { ExecApprovalsFile, ExecApprovalsSnapshot } from "../controllers/exec-approvals.ts"; import { formatRelativeTimestamp, formatList } from "../format.ts"; +import { normalizeOptionalString } from "../string-coerce.ts"; import { renderExecApprovals, resolveExecApprovalsState } from "./nodes-exec-approvals.ts"; import { resolveConfigAgents, resolveNodeTargets, type NodeTargetOption } from "./nodes-shared.ts"; export type NodesProps = { @@ -111,9 +112,9 @@ function renderDevices(props: NodesProps) { } function renderPendingDevice(req: PendingDevice, props: NodesProps) { - const name = req.displayName?.trim() || req.deviceId; + const name = normalizeOptionalString(req.displayName) || req.deviceId; const age = typeof req.ts === "number" ? formatRelativeTimestamp(req.ts) : t("common.na"); - const roleValue = req.role?.trim() || formatList(req.roles); + const roleValue = normalizeOptionalString(req.role) || formatList(req.roles); const scopesValue = formatList(req.scopes); const repair = req.isRepair ? " · repair" : ""; const ip = req.remoteIp ? ` · ${req.remoteIp}` : ""; @@ -141,7 +142,7 @@ function renderPendingDevice(req: PendingDevice, props: NodesProps) { } function renderPairedDevice(device: PairedDevice, props: NodesProps) { - const name = device.displayName?.trim() || device.deviceId; + const name = normalizeOptionalString(device.displayName) || device.deviceId; const ip = device.remoteIp ? ` · ${device.remoteIp}` : ""; const roles = `roles: ${formatList(device.roles)}`; const scopes = `scopes: ${formatList(device.scopes)}`;