mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-18 21:22:28 +03:00
feat(dashboard): ModalityBridgeVisionTab + stats row + test button
This commit is contained in:
@@ -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<string, unknown>;
|
||||
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<BridgeStats | null>(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<string, unknown>)[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 (
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-1 text-xs text-text-muted" aria-live="polite">
|
||||
<span>
|
||||
{stats.bridged} {t("modalityBridgeStatsBridged")}
|
||||
</span>
|
||||
<span>
|
||||
{stats.cacheHits} {t("modalityBridgeStatsCacheHits")}
|
||||
</span>
|
||||
<span>
|
||||
{stats.failures} {t("modalityBridgeStatsFailures")}
|
||||
</span>
|
||||
<span>
|
||||
{t("modalityBridgeStatsLastUsed")}: {lastUsed}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<string, unknown> | null {
|
||||
return value && typeof value === "object" ? (value as Record<string, unknown>) : null;
|
||||
}
|
||||
|
||||
function findVisionMeta(value: unknown): Record<string, unknown> | 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<string | null>(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 (
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-control border border-border px-3 py-2 text-sm font-medium hover:bg-surface-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={running}
|
||||
onClick={() => void runTest()}
|
||||
>
|
||||
{t(running ? "modalityBridgeTestRunning" : "modalityBridgeTestButton")}
|
||||
</button>
|
||||
{result && (
|
||||
<p className="text-xs text-text-muted" role="status">
|
||||
{result}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<string, unknown>): 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<VisionState | null>(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<VisionState>) => {
|
||||
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<VisionState>) => {
|
||||
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 (
|
||||
<Card
|
||||
title={t("modalityBridgeVisionTitle")}
|
||||
subtitle={t("modalityBridgeVisionDesc")}
|
||||
icon="image_search"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<Toggle
|
||||
checked={settings.modalityBridgeVisionEnabled}
|
||||
onChange={(checked) => void update({ modalityBridgeVisionEnabled: checked })}
|
||||
label={t("visionBridgeEnabledLabel")}
|
||||
description={t("visionBridgeEnabledDesc")}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium" htmlFor="modality-bridge-mode">
|
||||
{t("modalityBridgeMode")}
|
||||
</label>
|
||||
<select
|
||||
id="modality-bridge-mode"
|
||||
data-testid="modality-bridge-mode"
|
||||
className="mt-1 w-full rounded-control border border-border bg-surface px-3 py-2 text-sm"
|
||||
value={settings.modalityBridgeVisionMode}
|
||||
onChange={(event) =>
|
||||
void update({ modalityBridgeVisionMode: event.target.value as VisionBridgeMode })
|
||||
}
|
||||
>
|
||||
<option value="auto">{t("modalityBridgeModeAuto")}</option>
|
||||
<option value="describe">{t("modalityBridgeModeDescribe")}</option>
|
||||
<option value="reroute">{t("modalityBridgeModeReroute")}</option>
|
||||
</select>
|
||||
<p className="mt-1 text-xs text-text-muted">
|
||||
{settings.modalityBridgeVisionMode === "auto" && t("modalityBridgeModeAutoHint")}
|
||||
{settings.modalityBridgeVisionMode === "describe" &&
|
||||
t("modalityBridgeModeDescribeHint")}
|
||||
{settings.modalityBridgeVisionMode === "reroute" && t("modalityBridgeModeRerouteHint")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ModelSelectField
|
||||
label={t("modalityBridgeVisionModel")}
|
||||
value={settings.modalityBridgeVisionModel}
|
||||
placeholder={t("modalityBridgeVisionModelAuto")}
|
||||
allowEmpty
|
||||
onChange={(value) => void update({ modalityBridgeVisionModel: value })}
|
||||
className="text-sm"
|
||||
/>
|
||||
|
||||
<Toggle
|
||||
checked={settings.modalityBridgeVisionTaskAware}
|
||||
onChange={(checked) => void update({ modalityBridgeVisionTaskAware: checked })}
|
||||
label={t("modalityBridgeTaskAware")}
|
||||
description={t("modalityBridgeTaskAwareDesc")}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium" htmlFor="modality-bridge-prompt">
|
||||
{t("modalityBridgePrompt")}
|
||||
</label>
|
||||
<textarea
|
||||
id="modality-bridge-prompt"
|
||||
className="mt-1 min-h-[100px] w-full rounded-control border border-border bg-surface px-3 py-2 text-sm"
|
||||
value={settings.modalityBridgeVisionPrompt}
|
||||
onChange={(event) =>
|
||||
setLocal({ modalityBridgeVisionPrompt: event.currentTarget.value })
|
||||
}
|
||||
onBlur={(event) => {
|
||||
const value = event.currentTarget.value.trim();
|
||||
setLocal({ modalityBridgeVisionPrompt: value });
|
||||
void update({ modalityBridgeVisionPrompt: value });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<details className="rounded-control border border-border p-3">
|
||||
<summary className="cursor-pointer text-sm font-medium">
|
||||
{t("modalityBridgeAdvanced")}
|
||||
</summary>
|
||||
<div className="mt-4 grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<NumberField
|
||||
testId="modality-bridge-timeout"
|
||||
label={t("modalityBridgeTimeoutMs")}
|
||||
min={1000}
|
||||
max={300000}
|
||||
value={settings.modalityBridgeVisionTimeout}
|
||||
onChange={(value) => setLocal({ modalityBridgeVisionTimeout: value })}
|
||||
onBlur={(raw) =>
|
||||
commitNumber(
|
||||
"modalityBridgeVisionTimeout",
|
||||
raw,
|
||||
1000,
|
||||
300000,
|
||||
VISION_BRIDGE_DEFAULTS.timeoutMs
|
||||
)
|
||||
}
|
||||
/>
|
||||
<NumberField
|
||||
testId="modality-bridge-max-images"
|
||||
label={t("modalityBridgeMaxImages")}
|
||||
min={1}
|
||||
max={20}
|
||||
value={settings.modalityBridgeVisionMaxImages}
|
||||
onChange={(value) => setLocal({ modalityBridgeVisionMaxImages: value })}
|
||||
onBlur={(raw) =>
|
||||
commitNumber(
|
||||
"modalityBridgeVisionMaxImages",
|
||||
raw,
|
||||
1,
|
||||
20,
|
||||
VISION_BRIDGE_DEFAULTS.maxImagesPerRequest
|
||||
)
|
||||
}
|
||||
/>
|
||||
<div className="md:col-span-2">
|
||||
<Toggle
|
||||
checked={settings.modalityBridgeCacheEnabled}
|
||||
onChange={(checked) => void update({ modalityBridgeCacheEnabled: checked })}
|
||||
label={t("modalityBridgeCacheEnabled")}
|
||||
description={t("modalityBridgeCacheEnabledDesc")}
|
||||
/>
|
||||
</div>
|
||||
<NumberField
|
||||
testId="modality-bridge-cache-ttl"
|
||||
label={t("modalityBridgeCacheTtlMinutes")}
|
||||
min={1}
|
||||
max={1440}
|
||||
value={settings.modalityBridgeCacheTtlMinutes}
|
||||
onChange={(value) => setLocal({ modalityBridgeCacheTtlMinutes: value })}
|
||||
onBlur={(raw) =>
|
||||
commitNumber(
|
||||
"modalityBridgeCacheTtlMinutes",
|
||||
raw,
|
||||
1,
|
||||
1440,
|
||||
MODALITY_BRIDGE_DEFAULTS.cacheTtlMinutes
|
||||
)
|
||||
}
|
||||
/>
|
||||
<NumberField
|
||||
testId="modality-bridge-cache-max-entries"
|
||||
label={t("modalityBridgeCacheMaxEntries")}
|
||||
min={10}
|
||||
max={5000}
|
||||
value={settings.modalityBridgeCacheMaxEntries}
|
||||
onChange={(value) => setLocal({ modalityBridgeCacheMaxEntries: value })}
|
||||
onBlur={(raw) =>
|
||||
commitNumber(
|
||||
"modalityBridgeCacheMaxEntries",
|
||||
raw,
|
||||
10,
|
||||
5000,
|
||||
MODALITY_BRIDGE_DEFAULTS.cacheMaxEntries
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<ModalityBridgeStatsRow kind="vision" />
|
||||
<ModalityBridgeTestButton />
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function asSettingsRecord(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" ? (value as Record<string, unknown>) : {};
|
||||
}
|
||||
|
||||
interface NumberFieldProps {
|
||||
testId: string;
|
||||
label: string;
|
||||
min: number;
|
||||
max: number;
|
||||
value: number;
|
||||
onChange: (value: number) => void;
|
||||
onBlur: (raw: string) => void;
|
||||
}
|
||||
|
||||
function NumberField({ testId, label, min, max, value, onChange, onBlur }: NumberFieldProps) {
|
||||
return (
|
||||
<label className="block text-sm font-medium">
|
||||
{label}
|
||||
<input
|
||||
type="number"
|
||||
data-testid={testId}
|
||||
min={min}
|
||||
max={max}
|
||||
value={value}
|
||||
onChange={(event) => onChange(Number.parseInt(event.currentTarget.value, 10) || 0)}
|
||||
onBlur={(event) => onBlur(event.currentTarget.value)}
|
||||
className="mt-1 w-full rounded-control border border-border bg-surface px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -19,6 +19,8 @@ export interface ModelSelectFieldProps {
|
||||
ariaLabel?: string;
|
||||
/** Render a plain text fallback (custom option / off-catalog) — default true. */
|
||||
allowCustom?: boolean;
|
||||
/** Let operators select the empty-value placeholder (for Auto/default semantics). */
|
||||
allowEmpty?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
@@ -43,6 +45,7 @@ export default function ModelSelectField({
|
||||
placeholder,
|
||||
ariaLabel,
|
||||
allowCustom = true,
|
||||
allowEmpty = false,
|
||||
className,
|
||||
}: ModelSelectFieldProps) {
|
||||
const [state, setState] = useState<FetchState>({ status: "loading", options: [] });
|
||||
@@ -95,6 +98,7 @@ export default function ModelSelectField({
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
options={options}
|
||||
placeholder={state.status === "loading" ? "Loading models…" : placeholder || "Select a model"}
|
||||
placeholderDisabled={!allowEmpty}
|
||||
disabled={disabled || state.status === "loading"}
|
||||
aria-label={ariaLabel}
|
||||
className={className}
|
||||
|
||||
@@ -16,6 +16,8 @@ interface SelectProps extends Omit<React.SelectHTMLAttributes<HTMLSelectElement>
|
||||
error?: React.ReactNode;
|
||||
hint?: React.ReactNode;
|
||||
selectClassName?: string;
|
||||
/** Keep the placeholder selectable after a real value is chosen. */
|
||||
placeholderDisabled?: boolean;
|
||||
}
|
||||
|
||||
export default function Select({
|
||||
@@ -30,6 +32,7 @@ export default function Select({
|
||||
required = false,
|
||||
className,
|
||||
selectClassName,
|
||||
placeholderDisabled = true,
|
||||
id: externalId,
|
||||
children,
|
||||
...props
|
||||
@@ -75,7 +78,7 @@ export default function Select({
|
||||
{...props}
|
||||
>
|
||||
{!children && (placeholder ?? t("selectOption")) && (
|
||||
<option value="" disabled className="bg-surface text-text-muted">
|
||||
<option value="" disabled={placeholderDisabled} className="bg-surface text-text-muted">
|
||||
{placeholder ?? t("selectOption")}
|
||||
</option>
|
||||
)}
|
||||
|
||||
218
tests/unit/ui/modality-bridge-vision-tab.test.tsx
Normal file
218
tests/unit/ui/modality-bridge-vision-tab.test.tsx
Normal file
@@ -0,0 +1,218 @@
|
||||
// @vitest-environment jsdom
|
||||
import React, { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import ModalityBridgeVisionTab from "@/app/(dashboard)/dashboard/settings/components/modalityBridge/ModalityBridgeVisionTab";
|
||||
|
||||
vi.mock("next-intl", () => ({
|
||||
useTranslations: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
type MountedRoot = { root: Root; el: HTMLDivElement };
|
||||
|
||||
const roots: MountedRoot[] = [];
|
||||
|
||||
async function waitFor(predicate: () => boolean, label: string): Promise<void> {
|
||||
const startedAt = Date.now();
|
||||
while (!predicate()) {
|
||||
if (Date.now() - startedAt > 2000) {
|
||||
throw new Error(`Timed out waiting for: ${label}`);
|
||||
}
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
describe("ModalityBridgeVisionTab", () => {
|
||||
let fetchMock: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = String(input);
|
||||
if (url.includes("/api/models")) {
|
||||
return new Response(
|
||||
JSON.stringify({ models: [{ provider: "openai", model: "gpt-4o-mini" }] }),
|
||||
{ status: 200 }
|
||||
);
|
||||
}
|
||||
if (url.includes("/api/modality-bridge/stats")) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
vision: { bridged: 3, cacheHits: 1, failures: 0, lastUsedAt: null },
|
||||
audio: { bridged: 0, cacheHits: 0, failures: 0, lastUsedAt: null },
|
||||
}),
|
||||
{ status: 200 }
|
||||
);
|
||||
}
|
||||
if (url.includes("/api/guardrails/test")) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
blocked: false,
|
||||
results: [
|
||||
{
|
||||
guardrail: "vision-bridge",
|
||||
meta: { imagesProcessed: 1, visionModel: "openai/gpt-4o-mini" },
|
||||
},
|
||||
],
|
||||
payload: {},
|
||||
}),
|
||||
{ status: 200 }
|
||||
);
|
||||
}
|
||||
if (url.includes("/api/settings")) {
|
||||
if (init?.method === "PATCH") return new Response("{}", { status: 200 });
|
||||
return new Response(
|
||||
JSON.stringify({ visionBridgeModel: "legacy/model", visionBridgeEnabled: true }),
|
||||
{ status: 200 }
|
||||
);
|
||||
}
|
||||
return new Response("{}", { status: 200 });
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const { root, el } of roots.splice(0)) {
|
||||
act(() => root.unmount());
|
||||
el.remove();
|
||||
}
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
async function render(): Promise<HTMLDivElement> {
|
||||
const el = document.createElement("div");
|
||||
document.body.appendChild(el);
|
||||
const root = createRoot(el);
|
||||
await act(async () => {
|
||||
root.render(<ModalityBridgeVisionTab />);
|
||||
});
|
||||
roots.push({ root, el });
|
||||
await waitFor(
|
||||
() => el.querySelector('[data-testid="modality-bridge-mode"]') !== null,
|
||||
"vision settings to load"
|
||||
);
|
||||
return el;
|
||||
}
|
||||
|
||||
it("PATCHes only the new modalityBridge* key when toggling", async () => {
|
||||
const el = await render();
|
||||
const toggle = el.querySelector('[role="switch"]') as HTMLButtonElement | null;
|
||||
expect(toggle).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
toggle?.click();
|
||||
});
|
||||
|
||||
await waitFor(
|
||||
() =>
|
||||
fetchMock.mock.calls.some(
|
||||
([url, init]) =>
|
||||
String(url).includes("/api/settings") &&
|
||||
(init as RequestInit | undefined)?.method === "PATCH"
|
||||
),
|
||||
"settings PATCH"
|
||||
);
|
||||
const patch = fetchMock.mock.calls.find(
|
||||
([url, init]) =>
|
||||
String(url).includes("/api/settings") &&
|
||||
(init as RequestInit | undefined)?.method === "PATCH"
|
||||
);
|
||||
const body = JSON.parse(String((patch?.[1] as RequestInit | undefined)?.body)) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(body).toHaveProperty("modalityBridgeVisionEnabled", false);
|
||||
expect(body).not.toHaveProperty("visionBridgeEnabled");
|
||||
});
|
||||
|
||||
it("reads the legacy model as fallback when the new key is absent", async () => {
|
||||
const el = await render();
|
||||
expect(el.innerHTML).toContain("legacy/model");
|
||||
const modelLabel = Array.from(el.querySelectorAll("label")).find((label) =>
|
||||
label.textContent?.includes("modalityBridgeVisionModel")
|
||||
);
|
||||
const modelSelect = modelLabel?.parentElement?.querySelector("select") ?? null;
|
||||
const autoOption = modelSelect?.querySelector('option[value=""]') as HTMLOptionElement | null;
|
||||
expect(autoOption).toBeTruthy();
|
||||
expect(autoOption?.disabled).toBe(false);
|
||||
});
|
||||
|
||||
it("renders the mode selector, live stats, and all three mode options", async () => {
|
||||
const el = await render();
|
||||
const select = el.querySelector(
|
||||
'select[data-testid="modality-bridge-mode"]'
|
||||
) as HTMLSelectElement | null;
|
||||
expect(select).toBeTruthy();
|
||||
expect(Array.from(select?.options ?? []).map((option) => option.value)).toEqual([
|
||||
"auto",
|
||||
"describe",
|
||||
"reroute",
|
||||
]);
|
||||
|
||||
await waitFor(() => el.textContent?.includes("3 modalityBridgeStatsBridged") ?? false, "stats");
|
||||
expect(el.textContent).toContain("1 modalityBridgeStatsCacheHits");
|
||||
});
|
||||
|
||||
it("clamps advanced numeric settings to the schema bounds before PATCHing", async () => {
|
||||
const el = await render();
|
||||
const timeout = el.querySelector(
|
||||
'[data-testid="modality-bridge-timeout"]'
|
||||
) as HTMLInputElement | null;
|
||||
expect(timeout).toBeTruthy();
|
||||
|
||||
act(() => {
|
||||
const setter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLInputElement.prototype,
|
||||
"value"
|
||||
)?.set;
|
||||
setter?.call(timeout, "500");
|
||||
timeout?.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
await act(async () => {
|
||||
timeout?.dispatchEvent(new FocusEvent("focusout", { bubbles: true }));
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
|
||||
await waitFor(
|
||||
() =>
|
||||
fetchMock.mock.calls.some(([, init]) => {
|
||||
if ((init as RequestInit | undefined)?.method !== "PATCH") return false;
|
||||
const body = JSON.parse(String((init as RequestInit).body)) as Record<string, unknown>;
|
||||
return body.modalityBridgeVisionTimeout === 1000;
|
||||
}),
|
||||
"clamped timeout PATCH"
|
||||
);
|
||||
});
|
||||
|
||||
it("runs the guardrail self-test with an image and renders the bridge result", async () => {
|
||||
const el = await render();
|
||||
const button = Array.from(el.querySelectorAll("button")).find((candidate) =>
|
||||
candidate.textContent?.includes("modalityBridgeTestButton")
|
||||
);
|
||||
expect(button).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
button?.click();
|
||||
});
|
||||
|
||||
await waitFor(
|
||||
() => el.textContent?.includes("modalityBridgeTestOk") ?? false,
|
||||
"bridge self-test result"
|
||||
);
|
||||
const call = fetchMock.mock.calls.find(([url]) => String(url).includes("/api/guardrails/test"));
|
||||
expect(call).toBeTruthy();
|
||||
const body = JSON.parse(String((call?.[1] as RequestInit | undefined)?.body)) as {
|
||||
input?: { messages?: unknown[] };
|
||||
disabledGuardrails?: string[];
|
||||
};
|
||||
expect(body.input?.messages).toHaveLength(1);
|
||||
expect(body.disabledGuardrails).toEqual([
|
||||
"pii-masker",
|
||||
"prompt-injection",
|
||||
"credential-masker",
|
||||
]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user