diff --git a/src/app/(dashboard)/dashboard/settings/components/modalityBridge/ModalityBridgeStatsRow.tsx b/src/app/(dashboard)/dashboard/settings/components/modalityBridge/ModalityBridgeStatsRow.tsx new file mode 100644 index 0000000000..64800aed98 --- /dev/null +++ b/src/app/(dashboard)/dashboard/settings/components/modalityBridge/ModalityBridgeStatsRow.tsx @@ -0,0 +1,80 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useTranslations } from "next-intl"; + +type BridgeKind = "vision" | "audio"; + +interface BridgeStats { + bridged: number; + cacheHits: number; + failures: number; + lastUsedAt: string | null; +} + +interface ModalityBridgeStatsRowProps { + kind: BridgeKind; +} + +function parseStats(value: unknown): BridgeStats | null { + if (!value || typeof value !== "object") return null; + const record = value as Record; + if ( + typeof record.bridged !== "number" || + typeof record.cacheHits !== "number" || + typeof record.failures !== "number" || + (record.lastUsedAt !== null && typeof record.lastUsedAt !== "string") + ) { + return null; + } + return { + bridged: record.bridged, + cacheHits: record.cacheHits, + failures: record.failures, + lastUsedAt: record.lastUsedAt, + }; +} + +export default function ModalityBridgeStatsRow({ kind }: ModalityBridgeStatsRowProps) { + const t = useTranslations("settings"); + const [stats, setStats] = useState(null); + + useEffect(() => { + let cancelled = false; + fetch("/api/modality-bridge/stats") + .then((response) => (response.ok ? response.json() : Promise.reject(new Error("fetch")))) + .then((data: unknown) => { + if (cancelled || !data || typeof data !== "object") return; + setStats(parseStats((data as Record)[kind])); + }) + .catch(() => { + if (!cancelled) setStats(null); + }); + return () => { + cancelled = true; + }; + }, [kind]); + + if (!stats) return null; + + const lastUsed = stats.lastUsedAt + ? new Date(stats.lastUsedAt).toLocaleString() + : t("modalityBridgeStatsNever"); + + return ( +
+ + {stats.bridged} {t("modalityBridgeStatsBridged")} + + + {stats.cacheHits} {t("modalityBridgeStatsCacheHits")} + + + {stats.failures} {t("modalityBridgeStatsFailures")} + + + {t("modalityBridgeStatsLastUsed")}: {lastUsed} + +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/settings/components/modalityBridge/ModalityBridgeTestButton.tsx b/src/app/(dashboard)/dashboard/settings/components/modalityBridge/ModalityBridgeTestButton.tsx new file mode 100644 index 0000000000..f75e1321a7 --- /dev/null +++ b/src/app/(dashboard)/dashboard/settings/components/modalityBridge/ModalityBridgeTestButton.tsx @@ -0,0 +1,110 @@ +"use client"; + +import { useState } from "react"; +import { useTranslations } from "next-intl"; + +const SAMPLE_INPUT = { + model: "modality-bridge/self-test", + messages: [ + { + role: "user", + content: [ + { type: "text", text: "What is in this image?" }, + { + type: "image_url", + image_url: { + url: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==", + }, + }, + ], + }, + ], +}; + +const DISABLED_GUARDRAILS = ["pii-masker", "prompt-injection", "credential-masker"]; + +function asRecord(value: unknown): Record | null { + return value && typeof value === "object" ? (value as Record) : null; +} + +function findVisionMeta(value: unknown): Record | null { + const body = asRecord(value); + if (!Array.isArray(body?.results)) return null; + for (const entry of body.results) { + const result = asRecord(entry); + if (result?.guardrail === "vision-bridge") return asRecord(result.meta); + } + return null; +} + +function readErrorMessage(value: unknown): string | null { + const body = asRecord(value); + const error = asRecord(body?.error); + return typeof error?.message === "string" ? error.message : null; +} + +export default function ModalityBridgeTestButton() { + const t = useTranslations("settings"); + const [running, setRunning] = useState(false); + const [result, setResult] = useState(null); + + const runTest = async () => { + setRunning(true); + setResult(null); + try { + const response = await fetch("/api/guardrails/test", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + input: SAMPLE_INPUT, + disabledGuardrails: DISABLED_GUARDRAILS, + }), + }); + const body: unknown = await response.json().catch(() => null); + if (!response.ok) { + throw new Error(readErrorMessage(body) ?? `HTTP ${response.status}`); + } + + const meta = findVisionMeta(body); + if (meta?.rerouted === true) { + setResult( + t("modalityBridgeTestReroute", { + model: String(meta.toModel ?? "unknown"), + }) + ); + } else if (typeof meta?.imagesProcessed === "number" && meta.imagesProcessed >= 1) { + setResult( + t("modalityBridgeTestOk", { + count: meta.imagesProcessed, + model: String(meta.visionModel ?? "unknown"), + }) + ); + } else { + setResult(t("modalityBridgeTestNoop")); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + setResult(t("modalityBridgeTestError", { message })); + } finally { + setRunning(false); + } + }; + + return ( +
+ + {result && ( +

+ {result} +

+ )} +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/settings/components/modalityBridge/ModalityBridgeVisionTab.tsx b/src/app/(dashboard)/dashboard/settings/components/modalityBridge/ModalityBridgeVisionTab.tsx new file mode 100644 index 0000000000..9079578bbb --- /dev/null +++ b/src/app/(dashboard)/dashboard/settings/components/modalityBridge/ModalityBridgeVisionTab.tsx @@ -0,0 +1,303 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useTranslations } from "next-intl"; + +import { Card, ModelSelectField, Toggle } from "@/shared/components"; +import { + MODALITY_BRIDGE_DEFAULTS, + resolveVisionBridgeRuntimeSettings, + type VisionBridgeMode, +} from "@/shared/constants/modalityBridgeDefaults"; +import { VISION_BRIDGE_DEFAULTS } from "@/shared/constants/visionBridgeDefaults"; + +import ModalityBridgeStatsRow from "./ModalityBridgeStatsRow"; +import ModalityBridgeTestButton from "./ModalityBridgeTestButton"; + +interface VisionState { + modalityBridgeVisionEnabled: boolean; + modalityBridgeVisionMode: VisionBridgeMode; + modalityBridgeVisionModel: string; + modalityBridgeVisionTaskAware: boolean; + modalityBridgeVisionPrompt: string; + modalityBridgeVisionTimeout: number; + modalityBridgeVisionMaxImages: number; + modalityBridgeCacheEnabled: boolean; + modalityBridgeCacheTtlMinutes: number; + modalityBridgeCacheMaxEntries: number; +} + +function fromApi(data: Record): VisionState { + const runtime = resolveVisionBridgeRuntimeSettings(data); + return { + modalityBridgeVisionEnabled: runtime.enabled, + modalityBridgeVisionMode: runtime.mode, + modalityBridgeVisionModel: runtime.model, + modalityBridgeVisionTaskAware: runtime.taskAware, + modalityBridgeVisionPrompt: runtime.prompt, + modalityBridgeVisionTimeout: runtime.timeoutMs, + modalityBridgeVisionMaxImages: runtime.maxImages, + modalityBridgeCacheEnabled: runtime.cacheEnabled, + modalityBridgeCacheTtlMinutes: runtime.cacheTtlMinutes, + modalityBridgeCacheMaxEntries: runtime.cacheMaxEntries, + }; +} + +function clampNumber(raw: string, min: number, max: number, fallback: number): number { + const parsed = Number.parseInt(raw, 10); + return Math.min(max, Math.max(min, Number.isFinite(parsed) ? parsed : fallback)); +} + +export default function ModalityBridgeVisionTab() { + const t = useTranslations("settings"); + const [settings, setSettings] = useState(null); + + useEffect(() => { + let cancelled = false; + fetch("/api/settings") + .then((response) => (response.ok ? response.json() : null)) + .then((data: unknown) => { + if (cancelled) return; + setSettings(fromApi(asSettingsRecord(data))); + }) + .catch(() => { + if (!cancelled) setSettings(fromApi({})); + }); + return () => { + cancelled = true; + }; + }, []); + + const update = async (patch: Partial) => { + try { + const response = await fetch("/api/settings", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(patch), + }); + if (response.ok) { + setSettings((previous) => (previous ? { ...previous, ...patch } : previous)); + } + } catch (error) { + console.error("Failed to update Modality Bridge settings:", error); + } + }; + + if (!settings) return null; + + const setLocal = (patch: Partial) => { + setSettings((previous) => (previous ? { ...previous, ...patch } : previous)); + }; + + const commitNumber = ( + key: + | "modalityBridgeVisionTimeout" + | "modalityBridgeVisionMaxImages" + | "modalityBridgeCacheTtlMinutes" + | "modalityBridgeCacheMaxEntries", + raw: string, + min: number, + max: number, + fallback: number + ) => { + const value = clampNumber(raw, min, max, fallback); + setLocal({ [key]: value }); + void update({ [key]: value }); + }; + + return ( + +
+ void update({ modalityBridgeVisionEnabled: checked })} + label={t("visionBridgeEnabledLabel")} + description={t("visionBridgeEnabledDesc")} + /> + +
+ + +

+ {settings.modalityBridgeVisionMode === "auto" && t("modalityBridgeModeAutoHint")} + {settings.modalityBridgeVisionMode === "describe" && + t("modalityBridgeModeDescribeHint")} + {settings.modalityBridgeVisionMode === "reroute" && t("modalityBridgeModeRerouteHint")} +

+
+ + void update({ modalityBridgeVisionModel: value })} + className="text-sm" + /> + + void update({ modalityBridgeVisionTaskAware: checked })} + label={t("modalityBridgeTaskAware")} + description={t("modalityBridgeTaskAwareDesc")} + /> + +
+ +