mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-02 21:32:10 +03:00
fix(dashboard): v3.8.8 screen fixes — agent-bridge SSR + audit/logs/memory/playground (#2944)
Integrated into release/v3.8.8
This commit is contained in:
committed by
GitHub
parent
7a0e803c01
commit
2fb5979118
@@ -182,7 +182,7 @@ export default function A2aAuditTab() {
|
||||
<span
|
||||
className={`rounded-full border px-2 py-1 text-xs font-medium ${STATE_STYLES[task.state]}`}
|
||||
>
|
||||
{task.state}
|
||||
{t(`a2aState${task.state.charAt(0).toUpperCase()}${task.state.slice(1)}`)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-text-muted">{taskDuration(task)}</td>
|
||||
|
||||
@@ -330,7 +330,7 @@ export default function ComplianceTab() {
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="rounded-md border border-border bg-surface px-2 py-1 font-mono text-xs text-text-main">
|
||||
{entry.action}
|
||||
{t.has(`eventTypes.${entry.action}`) ? t(`eventTypes.${entry.action}`) : entry.action}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
|
||||
@@ -24,8 +24,9 @@ export interface PoolCardProps {
|
||||
}
|
||||
|
||||
function computeStatus(usage: PoolUsageSnapshot | null): "green" | "amber" | "red" {
|
||||
if (!usage || usage.dimensions.length === 0) return "green";
|
||||
const utilizations = usage.dimensions.map((d) =>
|
||||
const dims = usage?.dimensions ?? [];
|
||||
if (dims.length === 0) return "green";
|
||||
const utilizations = dims.map((d) =>
|
||||
d.limit > 0 ? (d.consumedTotal / d.limit) * 100 : 0
|
||||
);
|
||||
const avg = utilizations.reduce((s, u) => s + u, 0) / utilizations.length;
|
||||
@@ -54,7 +55,7 @@ export default function PoolCard({
|
||||
const { icon: statusIcon, cls: statusCls } = STATUS_ICONS[status];
|
||||
|
||||
// Check for plan dimensions from usage
|
||||
const hasDimensions = usage && usage.dimensions.length > 0;
|
||||
const hasDimensions = !!usage?.dimensions?.length;
|
||||
|
||||
return (
|
||||
<Card padding="md">
|
||||
@@ -103,10 +104,10 @@ export default function PoolCard({
|
||||
<div
|
||||
className="grid gap-3 mb-3"
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(${Math.min(usage.dimensions.length, 3)}, 1fr)`,
|
||||
gridTemplateColumns: `repeat(${Math.min(usage?.dimensions?.length ?? 0, 3)}, 1fr)`,
|
||||
}}
|
||||
>
|
||||
{usage.dimensions.map((dim, i) => (
|
||||
{(usage?.dimensions ?? []).map((dim, i) => (
|
||||
<DimensionBar
|
||||
key={`${dim.unit}-${dim.window}-${i}`}
|
||||
dimension={{ unit: dim.unit, window: dim.window, limit: dim.limit }}
|
||||
|
||||
@@ -40,12 +40,12 @@ export function usePoolsUsageAggregate(pools: QuotaPool[]): PoolsUsageAggregate
|
||||
let utilCount = 0;
|
||||
let borrowing = 0;
|
||||
for (const { usage } of valid) {
|
||||
for (const dim of usage.dimensions) {
|
||||
for (const dim of usage.dimensions ?? []) {
|
||||
if (dim.limit > 0) {
|
||||
totalUtil += (dim.consumedTotal / dim.limit) * 100;
|
||||
utilCount += 1;
|
||||
}
|
||||
for (const key of dim.perKey) {
|
||||
for (const key of dim.perKey ?? []) {
|
||||
if (key.borrowing) borrowing += 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { ConfirmModal, RequestLoggerV2, ProxyLogger, SegmentedControl } from "@/shared/components";
|
||||
import ConsoleLogViewer from "@/shared/components/ConsoleLogViewer";
|
||||
import { ConfirmModal, RequestLoggerV2 } from "@/shared/components";
|
||||
import EmailPrivacyToggle from "@/shared/components/EmailPrivacyToggle";
|
||||
import ActiveRequestsPanel from "@/shared/components/ActiveRequestsPanel";
|
||||
import { useTranslations } from "next-intl";
|
||||
@@ -15,18 +13,7 @@ const TIME_RANGES = [
|
||||
{ label: "24h", hours: 24 },
|
||||
];
|
||||
|
||||
const TAB_TO_LOG_TYPE: Record<string, string> = {
|
||||
"request-logs": "request-logs",
|
||||
"proxy-logs": "proxy-logs",
|
||||
console: "call-logs",
|
||||
};
|
||||
|
||||
export default function LogsPage() {
|
||||
const searchParams = useSearchParams();
|
||||
const requestedTab = searchParams.get("tab");
|
||||
const [activeTab, setActiveTab] = useState(
|
||||
requestedTab && TAB_TO_LOG_TYPE[requestedTab] ? requestedTab : "request-logs"
|
||||
);
|
||||
const [showExport, setShowExport] = useState(false);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [showCleanHistory, setShowCleanHistory] = useState(false);
|
||||
@@ -36,12 +23,6 @@ export default function LogsPage() {
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const t = useTranslations("logs");
|
||||
|
||||
useEffect(() => {
|
||||
if (requestedTab && TAB_TO_LOG_TYPE[requestedTab] && requestedTab !== activeTab) {
|
||||
setActiveTab(requestedTab);
|
||||
}
|
||||
}, [activeTab, requestedTab]);
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
|
||||
@@ -56,7 +37,7 @@ export default function LogsPage() {
|
||||
setExporting(true);
|
||||
setShowExport(false);
|
||||
try {
|
||||
const logType = TAB_TO_LOG_TYPE[activeTab] || "call-logs";
|
||||
const logType = "request-logs";
|
||||
const res = await fetch(`/api/logs/export?hours=${hours}&type=${logType}`);
|
||||
if (!res.ok) throw new Error(t("exportFailed"));
|
||||
const blob = await res.blob();
|
||||
@@ -108,15 +89,7 @@ export default function LogsPage() {
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center justify-between gap-4 flex-wrap">
|
||||
<SegmentedControl
|
||||
options={[
|
||||
{ value: "request-logs", label: t("requestLogs") },
|
||||
{ value: "proxy-logs", label: t("proxyLogs") },
|
||||
{ value: "console", label: t("console") },
|
||||
]}
|
||||
value={activeTab}
|
||||
onChange={setActiveTab}
|
||||
/>
|
||||
<h2 className="text-lg font-semibold text-text-main">{t("requestLogs")}</h2>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<EmailPrivacyToggle size="md" />
|
||||
@@ -211,14 +184,10 @@ export default function LogsPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "request-logs" && (
|
||||
<div className="flex flex-col gap-6">
|
||||
<ActiveRequestsPanel />
|
||||
<RequestLoggerV2 key={requestLogKey} />
|
||||
</div>
|
||||
)}
|
||||
{activeTab === "proxy-logs" && <ProxyLogger />}
|
||||
{activeTab === "console" && <ConsoleLogViewer />}
|
||||
<div className="flex flex-col gap-6">
|
||||
<ActiveRequestsPanel />
|
||||
<RequestLoggerV2 key={requestLogKey} />
|
||||
</div>
|
||||
|
||||
<ConfirmModal
|
||||
isOpen={showCleanHistory}
|
||||
|
||||
@@ -85,7 +85,12 @@ export default function MemoryEngineStatus({ status, onConfigure }: Props) {
|
||||
: "red",
|
||||
reason: status.vectorStore.reason,
|
||||
cta:
|
||||
status.vectorStore.needsReindex > 0 ? (
|
||||
status.vectorStore.backend === "none" ? (
|
||||
<span className="text-xs text-amber-400 flex items-center gap-1">
|
||||
<span className="material-symbols-outlined text-[12px]">terminal</span>
|
||||
{t("engine.vectorStoreInstallHint")}
|
||||
</span>
|
||||
) : status.vectorStore.needsReindex > 0 ? (
|
||||
<span className="text-xs text-amber-400 flex items-center gap-1">
|
||||
<span className="material-symbols-outlined text-[12px]">warning</span>
|
||||
{t("engine.needsReindex", { count: status.vectorStore.needsReindex })}
|
||||
|
||||
@@ -236,6 +236,16 @@ export default function MemoriesTab() {
|
||||
}
|
||||
};
|
||||
|
||||
// Auto-run health check on mount + poll every 30s, so the indicator reflects
|
||||
// engine health without requiring a manual click.
|
||||
useEffect(() => {
|
||||
void checkHealth();
|
||||
const id = setInterval(() => {
|
||||
void checkHealth();
|
||||
}, 30_000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
const openEdit = (m: Memory) => {
|
||||
setEditTarget(m);
|
||||
setEditOpen(true);
|
||||
|
||||
@@ -7,15 +7,18 @@ import MemoryConceptCard from "./components/MemoryConceptCard";
|
||||
import MemoriesTab from "./components/tabs/MemoriesTab";
|
||||
import PlaygroundTab from "./components/tabs/PlaygroundTab";
|
||||
import EngineTab from "./components/tabs/EngineTab";
|
||||
import { useMemorySettings } from "./hooks/useMemorySettings";
|
||||
|
||||
type TabId = "memories" | "playground" | "engine";
|
||||
|
||||
const TABS: TabId[] = ["memories", "playground", "engine"];
|
||||
const TABS: TabId[] = ["memories", "engine", "playground"];
|
||||
|
||||
function MemoryPageContent() {
|
||||
const t = useTranslations("memory");
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const { settings, save } = useMemorySettings();
|
||||
const memoryEnabled = settings?.enabled ?? true;
|
||||
|
||||
const rawTab = searchParams.get("tab") ?? "";
|
||||
const activeTab: TabId = TABS.includes(rawTab as TabId) ? (rawTab as TabId) : "memories";
|
||||
@@ -31,23 +34,44 @@ function MemoryPageContent() {
|
||||
{/* Concept card */}
|
||||
<MemoryConceptCard />
|
||||
|
||||
{/* Tab navigation */}
|
||||
<div className="flex gap-1 p-1 rounded-lg bg-surface/50 border border-border/60 w-fit">
|
||||
{TABS.map((tab) => (
|
||||
{/* Tab navigation + memory enable toggle */}
|
||||
<div className="flex items-center justify-between gap-4 flex-wrap">
|
||||
<div className="flex gap-1 p-1 rounded-lg bg-surface/50 border border-border/60 w-fit">
|
||||
{TABS.map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
type="button"
|
||||
data-testid={`tab-${tab}`}
|
||||
onClick={() => setTab(tab)}
|
||||
className={`px-4 py-2 rounded-md text-sm font-medium transition-all ${
|
||||
activeTab === tab
|
||||
? "bg-bg text-text-main shadow-sm"
|
||||
: "text-text-muted hover:text-text-main"
|
||||
}`}
|
||||
>
|
||||
{t(`tabs.${tab}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<label className="flex items-center gap-2 cursor-pointer select-none">
|
||||
<span className="text-sm font-medium text-text-muted">{t("memoryEnabled")}</span>
|
||||
<button
|
||||
key={tab}
|
||||
type="button"
|
||||
data-testid={`tab-${tab}`}
|
||||
onClick={() => setTab(tab)}
|
||||
className={`px-4 py-2 rounded-md text-sm font-medium transition-all ${
|
||||
activeTab === tab
|
||||
? "bg-bg text-text-main shadow-sm"
|
||||
: "text-text-muted hover:text-text-main"
|
||||
role="switch"
|
||||
aria-checked={memoryEnabled}
|
||||
data-testid="memory-enabled-toggle"
|
||||
onClick={() => void save({ enabled: !memoryEnabled })}
|
||||
className={`relative w-11 h-6 rounded-full transition-colors ${
|
||||
memoryEnabled ? "bg-violet-500" : "bg-border"
|
||||
}`}
|
||||
>
|
||||
{t(`tabs.${tab}`)}
|
||||
<span
|
||||
className={`absolute top-1 left-1 w-4 h-4 bg-white rounded-full transition-transform ${
|
||||
memoryEnabled ? "translate-x-5" : "translate-x-0"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Tab content */}
|
||||
|
||||
@@ -8,11 +8,14 @@ import type { PlaygroundEndpoint } from "@/lib/playground/codeExport";
|
||||
import { endpointToPath } from "@/lib/playground/codeExport";
|
||||
import PresetPicker from "./PresetPicker";
|
||||
import ImprovePromptButton from "./ImprovePromptButton";
|
||||
import { useProviderOptions } from "@/app/(dashboard)/dashboard/translator/hooks/useProviderOptions";
|
||||
import { useAvailableModels } from "@/app/(dashboard)/dashboard/translator/hooks/useAvailableModels";
|
||||
|
||||
export interface ConfigState {
|
||||
endpoint: PlaygroundEndpoint;
|
||||
baseUrl: string;
|
||||
model: string;
|
||||
provider?: string;
|
||||
systemPrompt: string;
|
||||
params: PlaygroundParams;
|
||||
}
|
||||
@@ -46,6 +49,10 @@ const ENDPOINT_OPTIONS: Array<{ value: PlaygroundEndpoint; label: string }> = [
|
||||
*/
|
||||
export default function StudioConfigPane({ configState, setConfigState }: StudioConfigPaneProps) {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const { provider, setProvider, providerOptions, loading: loadingProviders } = useProviderOptions(
|
||||
configState.provider ?? ""
|
||||
);
|
||||
const { availableModels, loading: loadingModels } = useAvailableModels();
|
||||
|
||||
function update<K extends keyof ConfigState>(key: K, value: ConfigState[K]) {
|
||||
setConfigState({ ...configState, [key]: value });
|
||||
@@ -108,18 +115,56 @@ export default function StudioConfigPane({ configState, setConfigState }: Studio
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Provider */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-medium text-text-muted uppercase tracking-wider">
|
||||
Provider
|
||||
</label>
|
||||
<select
|
||||
value={provider}
|
||||
onChange={(e) => {
|
||||
setProvider(e.target.value);
|
||||
update("provider", e.target.value);
|
||||
}}
|
||||
disabled={loadingProviders}
|
||||
className="w-full text-xs bg-surface border border-border rounded px-2 py-1.5 focus:outline-none focus:ring-1 focus:ring-primary text-text-main"
|
||||
>
|
||||
<option value="">Auto</option>
|
||||
{providerOptions.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Model */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-medium text-text-muted uppercase tracking-wider">
|
||||
Model
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={configState.model}
|
||||
onChange={(e) => update("model", e.target.value)}
|
||||
placeholder="e.g. openai/gpt-4o"
|
||||
className="w-full text-xs bg-surface border border-border rounded px-2 py-1.5 focus:outline-none focus:ring-1 focus:ring-primary text-text-main"
|
||||
/>
|
||||
{availableModels.length > 0 ? (
|
||||
<select
|
||||
value={configState.model}
|
||||
onChange={(e) => update("model", e.target.value)}
|
||||
disabled={loadingModels}
|
||||
className="w-full text-xs bg-surface border border-border rounded px-2 py-1.5 focus:outline-none focus:ring-1 focus:ring-primary text-text-main"
|
||||
>
|
||||
{availableModels.map((m) => (
|
||||
<option key={m} value={m}>
|
||||
{m}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
type="text"
|
||||
value={configState.model}
|
||||
onChange={(e) => update("model", e.target.value)}
|
||||
placeholder="e.g. openai/gpt-4o"
|
||||
className="w-full text-xs bg-surface border border-border rounded px-2 py-1.5 focus:outline-none focus:ring-1 focus:ring-primary text-text-main"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* System prompt */}
|
||||
|
||||
@@ -6,9 +6,8 @@ import { useRef, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useToolsBuilder } from "../../hooks/useToolsBuilder";
|
||||
import { useStructuredOutput } from "../../hooks/useStructuredOutput";
|
||||
import ToolsBuilder from "../ToolsBuilder";
|
||||
import StructuredOutputEditor from "../StructuredOutputEditor";
|
||||
import MarkdownMessage from "../MarkdownMessage";
|
||||
import BuildWizard from "./build/BuildWizard";
|
||||
import type { ConfigState } from "../StudioConfigPane";
|
||||
|
||||
interface BuildTabProps {
|
||||
@@ -225,177 +224,104 @@ export default function BuildTab({ configState }: BuildTabProps) {
|
||||
await runRequest(newMessages);
|
||||
}
|
||||
|
||||
function clearConversation() {
|
||||
setMessages([]);
|
||||
setToolCalls([]);
|
||||
setToolResultDrafts([]);
|
||||
setValidationResult(null);
|
||||
setPrompt("");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full overflow-hidden">
|
||||
{/* Left panel: conversation + run */}
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center gap-2 px-4 py-2 border-b border-border bg-bg-alt shrink-0">
|
||||
<button
|
||||
onClick={() => void handleRun()}
|
||||
disabled={running || (!prompt.trim() && messages.length === 0)}
|
||||
className="flex items-center gap-1.5 text-xs px-3 py-1.5 rounded bg-primary text-white hover:bg-primary/90 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
// Result area: conversation history + tool-call UI + validation badge
|
||||
const resultArea = (
|
||||
<div className="space-y-3">
|
||||
{messages.map((msg, idx) => (
|
||||
<div key={idx} className={`flex ${msg.role === "user" ? "justify-end" : "justify-start"}`}>
|
||||
<div
|
||||
className={`max-w-[80%] rounded-xl px-3 py-2 text-sm ${
|
||||
msg.role === "user"
|
||||
? "bg-primary text-white"
|
||||
: msg.role === "tool"
|
||||
? "bg-yellow-500/10 border border-yellow-500/30 text-text-main"
|
||||
: "bg-bg-alt border border-border text-text-main"
|
||||
}`}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">play_arrow</span>
|
||||
{running ? t("running") : t("runLabel")}
|
||||
</button>
|
||||
|
||||
{messages.length > 0 && (
|
||||
<button
|
||||
onClick={clearConversation}
|
||||
className="text-xs px-2.5 py-1.5 rounded border border-border text-text-muted hover:text-text-main hover:bg-black/5 dark:hover:bg-white/5 transition-colors"
|
||||
>
|
||||
{t("clearAll")}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="ml-auto flex items-center gap-2 text-[11px] text-text-muted">
|
||||
{toolsBuilder.tools.length > 0 && (
|
||||
<span className="px-1.5 py-0.5 rounded bg-primary/10 text-primary">
|
||||
{toolsBuilder.tools.length} tool{toolsBuilder.tools.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
)}
|
||||
{structuredOutput.enabled && (
|
||||
<span className="px-1.5 py-0.5 rounded bg-green-500/10 text-green-600 dark:text-green-400">
|
||||
JSON mode
|
||||
</span>
|
||||
{msg.role === "user" ? (
|
||||
<span className="whitespace-pre-wrap">{msg.content}</span>
|
||||
) : (
|
||||
<MarkdownMessage content={msg.content} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Conversation history */}
|
||||
<div className="flex-1 overflow-y-auto px-4 py-3 space-y-3">
|
||||
{messages.map((msg, idx) => (
|
||||
<div key={idx} className={`flex ${msg.role === "user" ? "justify-end" : "justify-start"}`}>
|
||||
{/* Tool call UI */}
|
||||
{toolCalls.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{toolCalls.map((tc) => {
|
||||
const draft = toolResultDrafts.find((d) => d.toolCallId === tc.id);
|
||||
return (
|
||||
<div
|
||||
className={`max-w-[80%] rounded-xl px-3 py-2 text-sm ${
|
||||
msg.role === "user"
|
||||
? "bg-primary text-white"
|
||||
: msg.role === "tool"
|
||||
? "bg-yellow-500/10 border border-yellow-500/30 text-text-main"
|
||||
: "bg-bg-alt border border-border text-text-main"
|
||||
}`}
|
||||
key={tc.id}
|
||||
className="border border-amber-500/40 rounded-lg p-3 bg-amber-500/5"
|
||||
>
|
||||
{msg.role === "user" ? (
|
||||
<span className="whitespace-pre-wrap">{msg.content}</span>
|
||||
) : (
|
||||
<MarkdownMessage content={msg.content} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Tool call UI */}
|
||||
{toolCalls.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{toolCalls.map((tc) => {
|
||||
const draft = toolResultDrafts.find((d) => d.toolCallId === tc.id);
|
||||
return (
|
||||
<div
|
||||
key={tc.id}
|
||||
className="border border-amber-500/40 rounded-lg p-3 bg-amber-500/5"
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="material-symbols-outlined text-[14px] text-amber-500">
|
||||
function
|
||||
</span>
|
||||
<code className="text-xs font-mono text-text-main">
|
||||
{tc.function.name}
|
||||
</code>
|
||||
</div>
|
||||
<pre className="text-[11px] font-mono text-text-muted bg-bg-alt rounded p-2 overflow-x-auto mb-2 whitespace-pre-wrap break-all">
|
||||
{tc.function.arguments}
|
||||
</pre>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-[10px] text-text-muted uppercase tracking-wider">
|
||||
Tool result
|
||||
</label>
|
||||
<textarea
|
||||
value={draft?.draft ?? ""}
|
||||
onChange={(e) =>
|
||||
setToolResultDrafts((prev) =>
|
||||
prev.map((d) =>
|
||||
d.toolCallId === tc.id ? { ...d, draft: e.target.value } : d,
|
||||
),
|
||||
)
|
||||
}
|
||||
rows={3}
|
||||
placeholder={t("enterToolResult")}
|
||||
className="text-xs font-mono bg-bg-alt border border-border rounded px-2 py-1.5 focus:outline-none focus:ring-1 focus:ring-primary text-text-main resize-y"
|
||||
/>
|
||||
<button
|
||||
onClick={() => void sendToolResult(tc.id)}
|
||||
className="text-xs px-2.5 py-1 rounded bg-primary text-white hover:bg-primary/90 transition-colors self-start"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="material-symbols-outlined text-[14px] text-amber-500">
|
||||
function
|
||||
</span>
|
||||
<code className="text-xs font-mono text-text-main">
|
||||
{tc.function.name}
|
||||
</code>
|
||||
</div>
|
||||
<pre className="text-[11px] font-mono text-text-muted bg-bg-alt rounded p-2 overflow-x-auto mb-2 whitespace-pre-wrap break-all">
|
||||
{tc.function.arguments}
|
||||
</pre>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-[10px] text-text-muted uppercase tracking-wider">
|
||||
Tool result
|
||||
</label>
|
||||
<textarea
|
||||
value={draft?.draft ?? ""}
|
||||
onChange={(e) =>
|
||||
setToolResultDrafts((prev) =>
|
||||
prev.map((d) =>
|
||||
d.toolCallId === tc.id ? { ...d, draft: e.target.value } : d,
|
||||
),
|
||||
)
|
||||
}
|
||||
rows={3}
|
||||
placeholder="Enter tool result…"
|
||||
className="text-xs font-mono bg-bg-alt border border-border rounded px-2 py-1.5 focus:outline-none focus:ring-1 focus:ring-primary text-text-main resize-y"
|
||||
/>
|
||||
<button
|
||||
onClick={() => void sendToolResult(tc.id)}
|
||||
className="text-xs px-2.5 py-1 rounded bg-primary text-white hover:bg-primary/90 transition-colors self-start"
|
||||
>
|
||||
Send tool result
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Structured output validation */}
|
||||
{validationResult != null && (
|
||||
<div
|
||||
className={`text-xs rounded-lg px-3 py-2 border ${
|
||||
validationResult.valid
|
||||
? "border-green-500/40 bg-green-500/5 text-green-600 dark:text-green-400"
|
||||
: "border-destructive/40 bg-destructive/5 text-destructive"
|
||||
}`}
|
||||
>
|
||||
{validationResult.valid ? "✅ Valid JSON schema response" : `❌ ${validationResult.error}`}
|
||||
</div>
|
||||
)}
|
||||
Send tool result
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Prompt input */}
|
||||
<div className="px-4 py-3 border-t border-border shrink-0">
|
||||
<div className="flex items-end gap-2">
|
||||
<textarea
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
void handleRun();
|
||||
}
|
||||
}}
|
||||
placeholder="Enter your message… (Enter to send, Shift+Enter for newline)"
|
||||
rows={2}
|
||||
className="flex-1 text-sm bg-surface border border-border rounded-lg px-3 py-2 focus:outline-none focus:ring-1 focus:ring-primary text-text-main resize-none"
|
||||
/>
|
||||
</div>
|
||||
{/* Structured output validation */}
|
||||
{validationResult != null && (
|
||||
<div
|
||||
className={`text-xs rounded-lg px-3 py-2 border ${
|
||||
validationResult.valid
|
||||
? "border-green-500/40 bg-green-500/5 text-green-600 dark:text-green-400"
|
||||
: "border-destructive/40 bg-destructive/5 text-destructive"
|
||||
}`}
|
||||
>
|
||||
{validationResult.valid ? "✅ Valid JSON schema response" : `❌ ${validationResult.error}`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right panel: tools + structured output config */}
|
||||
<div className="w-72 shrink-0 border-l border-border bg-bg-alt overflow-y-auto p-4 flex flex-col gap-6">
|
||||
{/* Tools section */}
|
||||
<div>
|
||||
<h3 className="text-xs font-semibold text-text-muted uppercase tracking-wider mb-3">
|
||||
Function calling
|
||||
</h3>
|
||||
<ToolsBuilder toolsBuilder={toolsBuilder} />
|
||||
</div>
|
||||
|
||||
{/* Structured output section */}
|
||||
<div>
|
||||
<h3 className="text-xs font-semibold text-text-muted uppercase tracking-wider mb-3">
|
||||
Structured output
|
||||
</h3>
|
||||
<StructuredOutputEditor structuredOutput={structuredOutput} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<BuildWizard
|
||||
toolsBuilder={toolsBuilder}
|
||||
structuredOutput={structuredOutput}
|
||||
running={running}
|
||||
onRun={() => void handleRun()}
|
||||
prompt={prompt}
|
||||
setPrompt={setPrompt}
|
||||
result={resultArea}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -104,6 +104,13 @@ export default function CompareTab({ configState }: CompareTabProps) {
|
||||
// Metrics trackers per column id (plain class instances, no React hooks)
|
||||
const metricsTrackersRef = useRef<Map<string, ColumnMetricsTracker>>(new Map());
|
||||
|
||||
// User prompt input
|
||||
const [prompt, setPrompt] = useState("");
|
||||
|
||||
// RAF throttle: pending chunk accumulator and scheduled frame id
|
||||
const pendingRef = useRef<Record<string, string>>({});
|
||||
const rafRef = useRef<number | null>(null);
|
||||
|
||||
// Input for model name when adding a column
|
||||
const [newModel, setNewModel] = useState("");
|
||||
const addInputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -127,6 +134,30 @@ export default function CompareTab({ configState }: CompareTabProps) {
|
||||
return metricsTrackersRef.current.get(id)!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Throttle per-column chunk updates via requestAnimationFrame.
|
||||
* Accumulates all deltas that arrive within a single frame and flushes
|
||||
* them with a single setColumns call, preventing hundreds of re-renders/s.
|
||||
*/
|
||||
function pushChunk(colId: string, delta: string) {
|
||||
pendingRef.current[colId] = (pendingRef.current[colId] ?? "") + delta;
|
||||
|
||||
if (rafRef.current == null) {
|
||||
rafRef.current = requestAnimationFrame(() => {
|
||||
const snapshot = pendingRef.current;
|
||||
pendingRef.current = {};
|
||||
rafRef.current = null;
|
||||
|
||||
setColumns((prev) =>
|
||||
prev.map((c) => {
|
||||
const extra = snapshot[c.id];
|
||||
return extra != null ? { ...c, response: c.response + extra } : c;
|
||||
})
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function addColumn() {
|
||||
if (columns.length >= MAX_COLUMNS) return;
|
||||
const model = newModel.trim() || configState.model;
|
||||
@@ -179,6 +210,7 @@ export default function CompareTab({ configState }: CompareTabProps) {
|
||||
...(configState.systemPrompt
|
||||
? [{ role: "system", content: configState.systemPrompt }]
|
||||
: []),
|
||||
{ role: "user", content: prompt },
|
||||
],
|
||||
};
|
||||
|
||||
@@ -247,10 +279,8 @@ export default function CompareTab({ configState }: CompareTabProps) {
|
||||
accumulated += content;
|
||||
tracker.onChunk(1);
|
||||
|
||||
updateColumn(col.id, {
|
||||
response: accumulated,
|
||||
metrics: tracker.getMetrics(),
|
||||
});
|
||||
// Throttled: accumulate delta and flush once per animation frame
|
||||
pushChunk(col.id, content);
|
||||
}
|
||||
|
||||
const usage = parsed["usage"] as
|
||||
@@ -269,6 +299,12 @@ export default function CompareTab({ configState }: CompareTabProps) {
|
||||
});
|
||||
} catch (err) {
|
||||
if (controller.signal.aborted) {
|
||||
// Cancel any pending RAF for this column and clean up its pending delta
|
||||
if (rafRef.current != null) {
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
rafRef.current = null;
|
||||
}
|
||||
delete pendingRef.current[col.id];
|
||||
updateColumn(col.id, { status: "idle" });
|
||||
return;
|
||||
}
|
||||
@@ -303,6 +339,18 @@ export default function CompareTab({ configState }: CompareTabProps) {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Prompt input area */}
|
||||
<div className="px-4 pt-3 pb-2 border-b border-border bg-bg-alt shrink-0">
|
||||
<textarea
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
placeholder="Enter your prompt here…"
|
||||
rows={3}
|
||||
className="w-full text-sm bg-surface border border-border rounded px-3 py-2 focus:outline-none focus:ring-1 focus:ring-primary text-text-main resize-y"
|
||||
aria-label="User prompt"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Compare toolbar */}
|
||||
<div className="flex items-center gap-2 px-4 py-2 border-b border-border bg-bg-alt shrink-0">
|
||||
{/* Run / Cancel all */}
|
||||
@@ -318,7 +366,7 @@ export default function CompareTab({ configState }: CompareTabProps) {
|
||||
) : (
|
||||
<button
|
||||
onClick={() => void runAll()}
|
||||
disabled={columns.length === 0}
|
||||
disabled={columns.length === 0 || !prompt.trim()}
|
||||
className="flex items-center gap-1.5 text-xs px-3 py-1.5 rounded bg-primary text-white hover:bg-primary/90 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
aria-label="Run all columns"
|
||||
>
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
"use client";
|
||||
|
||||
// src/app/(dashboard)/dashboard/playground/components/tabs/build/BuildWizard.tsx
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import ToolsBuilder from "../../ToolsBuilder";
|
||||
import StructuredOutputEditor from "../../StructuredOutputEditor";
|
||||
import { useToolsBuilder } from "../../../hooks/useToolsBuilder";
|
||||
import { useStructuredOutput } from "../../../hooks/useStructuredOutput";
|
||||
|
||||
type BuildMode = "tools" | "json" | "both";
|
||||
|
||||
interface BuildWizardProps {
|
||||
toolsBuilder: ReturnType<typeof useToolsBuilder>;
|
||||
structuredOutput: ReturnType<typeof useStructuredOutput>;
|
||||
running: boolean;
|
||||
onRun: () => void;
|
||||
prompt: string;
|
||||
setPrompt: (value: string) => void;
|
||||
result: React.ReactNode;
|
||||
}
|
||||
|
||||
interface StepperProps {
|
||||
currentStep: 1 | 2 | 3;
|
||||
}
|
||||
|
||||
function Stepper({ currentStep }: StepperProps) {
|
||||
const t = useTranslations("playground.build");
|
||||
|
||||
const steps: Array<{ num: 1 | 2 | 3; label: string }> = [
|
||||
{ num: 1, label: t("step1Label") },
|
||||
{ num: 2, label: t("step2Label") },
|
||||
{ num: 3, label: t("step3Label") },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-0 px-4 py-3 border-b border-border bg-bg-alt shrink-0">
|
||||
{steps.map((step, idx) => (
|
||||
<div key={step.num} className="flex items-center">
|
||||
{idx > 0 && (
|
||||
<div
|
||||
className={`h-px w-8 mx-2 transition-colors ${
|
||||
currentStep > step.num ? "bg-primary" : "bg-border"
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span
|
||||
className={`flex items-center justify-center w-5 h-5 rounded-full text-[11px] font-semibold transition-colors ${
|
||||
currentStep === step.num
|
||||
? "bg-primary text-white"
|
||||
: currentStep > step.num
|
||||
? "bg-primary/20 text-primary"
|
||||
: "bg-border text-text-muted"
|
||||
}`}
|
||||
>
|
||||
{currentStep > step.num ? (
|
||||
<span className="material-symbols-outlined text-[12px]">check</span>
|
||||
) : (
|
||||
step.num
|
||||
)}
|
||||
</span>
|
||||
<span
|
||||
className={`text-xs font-medium transition-colors ${
|
||||
currentStep === step.num ? "text-text-main" : "text-text-muted"
|
||||
}`}
|
||||
>
|
||||
{step.label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ModeCardProps {
|
||||
icon: string;
|
||||
title: string;
|
||||
description: string;
|
||||
selected: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
function ModeCard({ icon, title, description, selected, onClick }: ModeCardProps) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`flex flex-col gap-2 p-4 rounded-xl border-2 text-left transition-all hover:shadow-sm ${
|
||||
selected
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border bg-surface hover:border-primary/40"
|
||||
}`}
|
||||
>
|
||||
<span className="text-2xl">{icon}</span>
|
||||
<span className={`text-sm font-semibold ${selected ? "text-primary" : "text-text-main"}`}>
|
||||
{title}
|
||||
</span>
|
||||
<span className="text-xs text-text-muted leading-relaxed">{description}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default function BuildWizard({
|
||||
toolsBuilder,
|
||||
structuredOutput,
|
||||
running,
|
||||
onRun,
|
||||
prompt,
|
||||
setPrompt,
|
||||
result,
|
||||
}: BuildWizardProps) {
|
||||
const t = useTranslations("playground");
|
||||
const tb = useTranslations("playground.build");
|
||||
|
||||
const [step, setStep] = useState<1 | 2 | 3>(1);
|
||||
const [mode, setMode] = useState<BuildMode>("tools");
|
||||
|
||||
const includesTools = mode === "tools" || mode === "both";
|
||||
const includesJson = mode === "json" || mode === "both";
|
||||
|
||||
function goToStep2() {
|
||||
setStep(2);
|
||||
}
|
||||
|
||||
function goToStep3() {
|
||||
setStep(3);
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
if (step === 2) setStep(1);
|
||||
else if (step === 3) setStep(2);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full overflow-hidden">
|
||||
<Stepper currentStep={step} />
|
||||
|
||||
{/* Step 1: Mode picker */}
|
||||
{step === 1 && (
|
||||
<div className="flex-1 overflow-y-auto p-6 flex flex-col gap-6">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-text-main mb-1">{tb("step1Title")}</h2>
|
||||
<p className="text-xs text-text-muted">{tb("step1Subtitle")}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<ModeCard
|
||||
icon="🔧"
|
||||
title={tb("modeToolsTitle")}
|
||||
description={tb("modeToolsDesc")}
|
||||
selected={mode === "tools"}
|
||||
onClick={() => setMode("tools")}
|
||||
/>
|
||||
<ModeCard
|
||||
icon="📋"
|
||||
title={tb("modeJsonTitle")}
|
||||
description={tb("modeJsonDesc")}
|
||||
selected={mode === "json"}
|
||||
onClick={() => setMode("json")}
|
||||
/>
|
||||
<ModeCard
|
||||
icon="🔧"
|
||||
title={tb("modeBothTitle")}
|
||||
description={tb("modeBothDesc")}
|
||||
selected={mode === "both"}
|
||||
onClick={() => setMode("both")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end pt-2">
|
||||
<button
|
||||
onClick={goToStep2}
|
||||
className="flex items-center gap-1.5 text-sm px-4 py-2 rounded-lg bg-primary text-white hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
{tb("nextButton")}
|
||||
<span className="material-symbols-outlined text-[16px]">arrow_forward</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 2: Configure */}
|
||||
{step === 2 && (
|
||||
<div className="flex-1 overflow-y-auto p-6 flex flex-col gap-6">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-text-main mb-1">{tb("step2Title")}</h2>
|
||||
<p className="text-xs text-text-muted">{tb("step2Subtitle")}</p>
|
||||
</div>
|
||||
|
||||
{includesTools && (
|
||||
<div>
|
||||
<h3 className="text-xs font-semibold text-text-muted uppercase tracking-wider mb-3">
|
||||
{t("toolsLabel")}
|
||||
</h3>
|
||||
<ToolsBuilder toolsBuilder={toolsBuilder} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{includesJson && (
|
||||
<div>
|
||||
<h3 className="text-xs font-semibold text-text-muted uppercase tracking-wider mb-3">
|
||||
{t("structuredOutputLabel")}
|
||||
</h3>
|
||||
<StructuredOutputEditor structuredOutput={structuredOutput} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<button
|
||||
onClick={goBack}
|
||||
className="flex items-center gap-1.5 text-sm px-4 py-2 rounded-lg border border-border text-text-muted hover:text-text-main hover:bg-black/5 dark:hover:bg-white/5 transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">arrow_back</span>
|
||||
{tb("backButton")}
|
||||
</button>
|
||||
<button
|
||||
onClick={goToStep3}
|
||||
className="flex items-center gap-1.5 text-sm px-4 py-2 rounded-lg bg-primary text-white hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
{tb("nextButton")}
|
||||
<span className="material-symbols-outlined text-[16px]">arrow_forward</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 3: Run */}
|
||||
{step === 3 && (
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center gap-2 px-4 py-2 border-b border-border bg-bg-alt shrink-0">
|
||||
<button
|
||||
onClick={goBack}
|
||||
className="flex items-center gap-1 text-xs px-2.5 py-1.5 rounded border border-border text-text-muted hover:text-text-main hover:bg-black/5 dark:hover:bg-white/5 transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">arrow_back</span>
|
||||
{tb("backButton")}
|
||||
</button>
|
||||
|
||||
<div className="w-px h-4 bg-border mx-1" />
|
||||
|
||||
<button
|
||||
onClick={onRun}
|
||||
disabled={running || (!prompt.trim())}
|
||||
className="flex items-center gap-1.5 text-xs px-3 py-1.5 rounded bg-primary text-white hover:bg-primary/90 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">play_arrow</span>
|
||||
{running ? t("running") : tb("runButton")}
|
||||
</button>
|
||||
|
||||
<div className="ml-auto flex items-center gap-2 text-[11px] text-text-muted">
|
||||
{includesTools && toolsBuilder.tools.length > 0 && (
|
||||
<span className="px-1.5 py-0.5 rounded bg-primary/10 text-primary">
|
||||
{toolsBuilder.tools.length} tool{toolsBuilder.tools.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
)}
|
||||
{includesJson && structuredOutput.enabled && (
|
||||
<span className="px-1.5 py-0.5 rounded bg-green-500/10 text-green-600 dark:text-green-400">
|
||||
JSON mode
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Result area (conversation + tool-call UI + validation badge) */}
|
||||
<div className="flex-1 overflow-y-auto px-4 py-3">
|
||||
{result}
|
||||
</div>
|
||||
|
||||
{/* Prompt input */}
|
||||
<div className="px-4 py-3 border-t border-border shrink-0">
|
||||
<div className="flex items-end gap-2">
|
||||
<textarea
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
onRun();
|
||||
}
|
||||
}}
|
||||
placeholder={tb("promptPlaceholder")}
|
||||
rows={2}
|
||||
className="flex-1 text-sm bg-surface border border-border rounded-lg px-3 py-2 focus:outline-none focus:ring-1 focus:ring-primary text-text-main resize-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -86,12 +86,8 @@ export default function SearchToolsTopBar({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{exportState && (
|
||||
<ExportCodeModal
|
||||
isOpen={exportOpen}
|
||||
onClose={() => setExportOpen(false)}
|
||||
state={exportState}
|
||||
/>
|
||||
{exportOpen && exportState != null && (
|
||||
<ExportCodeModal onClose={() => setExportOpen(false)} state={exportState} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -5,9 +5,6 @@ import { useTranslations } from "next-intl";
|
||||
import Link from "next/link";
|
||||
import type { SearchProviderCatalogItem } from "@/shared/schemas/searchTools";
|
||||
|
||||
/** D22 — max 4 providers in parallel */
|
||||
const MAX_PROVIDERS = 4;
|
||||
|
||||
export interface CompareResult {
|
||||
provider: string;
|
||||
latency: number;
|
||||
@@ -15,6 +12,7 @@ export interface CompareResult {
|
||||
resultCount: number;
|
||||
responseSize: number;
|
||||
urls: string[];
|
||||
results: { title: string; url: string; snippet: string }[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
@@ -49,6 +47,17 @@ function getWorstIndex(values: number[], higherIsBetter = false): number {
|
||||
: values.indexOf(Math.max(...values));
|
||||
}
|
||||
|
||||
/** Build a map of url → count across all compare results to find overlaps */
|
||||
function buildUrlCountMap(allResults: CompareResult[]): Map<string, number> {
|
||||
const counts = new Map<string, number>();
|
||||
for (const cr of allResults) {
|
||||
for (const r of cr.results) {
|
||||
if (r.url) counts.set(r.url, (counts.get(r.url) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
export default function CompareTab({ providers, onMetrics }: CompareTabProps) {
|
||||
const t = useTranslations("search");
|
||||
const activeSearchProviders = providers.filter(
|
||||
@@ -64,11 +73,18 @@ export default function CompareTab({ providers, onMetrics }: CompareTabProps) {
|
||||
const toggleProvider = useCallback((id: string) => {
|
||||
setSelectedProviderIds((prev) => {
|
||||
if (prev.includes(id)) return prev.filter((p) => p !== id);
|
||||
if (prev.length >= MAX_PROVIDERS) return prev; // cap 4 (D22)
|
||||
return [...prev, id];
|
||||
});
|
||||
}, []);
|
||||
|
||||
const selectAll = useCallback(() => {
|
||||
setSelectedProviderIds(activeSearchProviders.map((p) => p.id));
|
||||
}, [activeSearchProviders]);
|
||||
|
||||
const clearAll = useCallback(() => {
|
||||
setSelectedProviderIds([]);
|
||||
}, []);
|
||||
|
||||
const handleRun = useCallback(async () => {
|
||||
if (!query.trim() || selectedProviderIds.length === 0) return;
|
||||
setLoading(true);
|
||||
@@ -82,7 +98,7 @@ export default function CompareTab({ providers, onMetrics }: CompareTabProps) {
|
||||
const res = await fetch("/api/v1/search", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ query, provider: providerId, max_results: 5 }),
|
||||
body: JSON.stringify({ query, provider: providerId, max_results: 10 }),
|
||||
});
|
||||
const data = await res.json();
|
||||
const latency = Date.now() - start;
|
||||
@@ -95,18 +111,25 @@ export default function CompareTab({ providers, onMetrics }: CompareTabProps) {
|
||||
resultCount: 0,
|
||||
responseSize: 0,
|
||||
urls: [],
|
||||
results: [],
|
||||
error: data?.error?.message ?? `Error ${res.status}`,
|
||||
} as CompareResult;
|
||||
}
|
||||
|
||||
const respJson = JSON.stringify(data);
|
||||
const rawResults = Array.isArray(data.results) ? data.results : [];
|
||||
return {
|
||||
provider: providerId,
|
||||
latency: data.metrics?.response_time_ms ?? latency,
|
||||
cost: data.usage?.search_cost_usd ?? 0,
|
||||
resultCount: Array.isArray(data.results) ? data.results.length : 0,
|
||||
resultCount: rawResults.length,
|
||||
responseSize: respJson.length,
|
||||
urls: (data.results ?? []).map((r: { url: string }) => r.url),
|
||||
urls: rawResults.map((r: { url: string }) => r.url),
|
||||
results: rawResults.map((r: any) => ({
|
||||
title: r.title ?? r.url ?? "",
|
||||
url: r.url ?? "",
|
||||
snippet: r.snippet ?? r.description ?? "",
|
||||
})),
|
||||
} as CompareResult;
|
||||
} catch (err: unknown) {
|
||||
return {
|
||||
@@ -116,6 +139,7 @@ export default function CompareTab({ providers, onMetrics }: CompareTabProps) {
|
||||
resultCount: 0,
|
||||
responseSize: 0,
|
||||
urls: [],
|
||||
results: [],
|
||||
error: err instanceof Error ? err.message : "Failed",
|
||||
} as CompareResult;
|
||||
}
|
||||
@@ -132,6 +156,7 @@ export default function CompareTab({ providers, onMetrics }: CompareTabProps) {
|
||||
resultCount: 0,
|
||||
responseSize: 0,
|
||||
urls: [],
|
||||
results: [],
|
||||
error: "Request failed",
|
||||
} as CompareResult),
|
||||
);
|
||||
@@ -146,20 +171,16 @@ export default function CompareTab({ providers, onMetrics }: CompareTabProps) {
|
||||
}
|
||||
}, [query, selectedProviderIds, onMetrics]);
|
||||
|
||||
// Compute best/worst indices for coloring
|
||||
// Compute best/worst indices for column header coloring
|
||||
const validResults = results.filter((r) => !r.error);
|
||||
const latencyValues = validResults.map((r) => r.latency);
|
||||
const costValues = validResults.map((r) => r.cost);
|
||||
const sizeValues = validResults.map((r) => r.responseSize);
|
||||
const countValues = validResults.map((r) => r.resultCount);
|
||||
|
||||
function getCellClass(resultIndex: number, values: number[], higherIsBetter = false): string {
|
||||
const validIndex = validResults.findIndex((r) => r === results[resultIndex]);
|
||||
if (validIndex < 0) return "text-error";
|
||||
if (validIndex === getBestIndex(values, higherIsBetter)) return "text-success font-medium";
|
||||
if (validIndex === getWorstIndex(values, higherIsBetter)) return "text-warning";
|
||||
return "text-text-main";
|
||||
}
|
||||
const bestLatencyProvider = validResults[getBestIndex(latencyValues, false)]?.provider;
|
||||
const bestCostProvider = validResults[getBestIndex(costValues, false)]?.provider;
|
||||
|
||||
// URL overlap map: url → number of providers that returned it
|
||||
const urlCountMap = buildUrlCountMap(results);
|
||||
|
||||
if (activeSearchProviders.length === 0) {
|
||||
return (
|
||||
@@ -183,7 +204,7 @@ export default function CompareTab({ providers, onMetrics }: CompareTabProps) {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full p-4 space-y-4" data-testid="compare-tab">
|
||||
{/* Query input */}
|
||||
{/* Query + provider picker */}
|
||||
<div className="bg-surface border border-border rounded-lg p-4 space-y-3">
|
||||
<label
|
||||
htmlFor="compare-query"
|
||||
@@ -214,15 +235,32 @@ export default function CompareTab({ providers, onMetrics }: CompareTabProps) {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Provider picker — max 4 (D22) */}
|
||||
{/* Provider picker — no cap */}
|
||||
<div>
|
||||
<p className="text-[10px] text-text-muted mb-2">
|
||||
Providers ({selectedProviderIds.length}/{MAX_PROVIDERS}):
|
||||
</p>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<p className="text-[10px] text-text-muted">
|
||||
Providers ({selectedProviderIds.length} selected):
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
className="text-[10px] px-2 py-0.5 rounded border border-border text-text-muted hover:text-text-main hover:border-primary/30 transition-colors"
|
||||
onClick={selectAll}
|
||||
data-testid="select-all-providers"
|
||||
>
|
||||
Select all
|
||||
</button>
|
||||
<button
|
||||
className="text-[10px] px-2 py-0.5 rounded border border-border text-text-muted hover:text-text-main hover:border-primary/30 transition-colors"
|
||||
onClick={clearAll}
|
||||
data-testid="clear-providers"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{activeSearchProviders.map((p) => {
|
||||
const selected = selectedProviderIds.includes(p.id);
|
||||
const atCap = selectedProviderIds.length >= MAX_PROVIDERS && !selected;
|
||||
return (
|
||||
<button
|
||||
key={p.id}
|
||||
@@ -230,12 +268,9 @@ export default function CompareTab({ providers, onMetrics }: CompareTabProps) {
|
||||
"px-2.5 py-1 rounded-md text-xs font-medium transition-colors border",
|
||||
selected
|
||||
? "bg-primary/15 text-primary border-primary/30"
|
||||
: atCap
|
||||
? "text-text-muted border-border opacity-50 cursor-not-allowed"
|
||||
: "text-text-muted border-border hover:text-text-main hover:border-primary/30",
|
||||
: "text-text-muted border-border hover:text-text-main hover:border-primary/30",
|
||||
].join(" ")}
|
||||
onClick={() => !atCap && toggleProvider(p.id)}
|
||||
disabled={atCap}
|
||||
onClick={() => toggleProvider(p.id)}
|
||||
data-testid={`provider-toggle-${p.id}`}
|
||||
aria-pressed={selected}
|
||||
>
|
||||
@@ -244,11 +279,6 @@ export default function CompareTab({ providers, onMetrics }: CompareTabProps) {
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{selectedProviderIds.length >= MAX_PROVIDERS && (
|
||||
<p className="text-[10px] text-warning mt-1">
|
||||
Máximo de {MAX_PROVIDERS} providers atingido (D22)
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -264,7 +294,7 @@ export default function CompareTab({ providers, onMetrics }: CompareTabProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Results table */}
|
||||
{/* Layout A — side-by-side columns */}
|
||||
{hasRun && !loading && results.length > 0 && (
|
||||
<div
|
||||
className="bg-surface border border-border rounded-lg overflow-hidden"
|
||||
@@ -272,87 +302,117 @@ export default function CompareTab({ providers, onMetrics }: CompareTabProps) {
|
||||
>
|
||||
<div className="px-4 py-2.5 bg-bg-alt border-b border-border">
|
||||
<span className="text-xs font-semibold text-text-muted uppercase tracking-wider">
|
||||
Resultados — “{query}”
|
||||
Results — “{query}”
|
||||
</span>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="border-b border-border">
|
||||
<th className="text-left p-2 text-text-muted font-semibold w-24" />
|
||||
{results.map((r) => (
|
||||
<th
|
||||
key={r.provider}
|
||||
className="text-center p-2 font-semibold text-text-muted"
|
||||
>
|
||||
{r.provider.replace("-search", "")}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr className="border-b border-border/50">
|
||||
<td className="p-2 text-text-muted">Latência</td>
|
||||
{results.map((r, i) => (
|
||||
<td
|
||||
key={r.provider}
|
||||
className={`text-center p-2 ${r.error ? "text-error" : getCellClass(i, latencyValues, false)}`}
|
||||
>
|
||||
{r.error ? "Erro" : `${r.latency}ms`}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
<tr className="border-b border-border/50">
|
||||
<td className="p-2 text-text-muted">Custo</td>
|
||||
{results.map((r, i) => (
|
||||
<td
|
||||
key={r.provider}
|
||||
className={`text-center p-2 ${r.error ? "text-error" : getCellClass(i, costValues, false)}`}
|
||||
>
|
||||
{r.error ? "Erro" : `$${r.cost.toFixed(4)}`}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
<tr className="border-b border-border/50">
|
||||
<td className="p-2 text-text-muted">Resultados</td>
|
||||
{results.map((r, i) => (
|
||||
<td
|
||||
key={r.provider}
|
||||
className={`text-center p-2 ${r.error ? "text-error" : getCellClass(i, countValues, true)}`}
|
||||
>
|
||||
{r.error ? "Erro" : r.resultCount}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
<tr className="border-b border-border/50">
|
||||
<td className="p-2 text-text-muted">{t("size")}</td>
|
||||
{results.map((r, i) => (
|
||||
<td
|
||||
key={r.provider}
|
||||
className={`text-center p-2 ${r.error ? "text-error" : getCellClass(i, sizeValues, false)}`}
|
||||
>
|
||||
{r.error ? "Erro" : formatBytes(r.responseSize)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="p-2 text-text-muted">URL overlap</td>
|
||||
{results.map((r, idx) => {
|
||||
const baseUrls = results[0]?.urls ?? [];
|
||||
return (
|
||||
<td key={r.provider} className="text-center p-2 text-text-main">
|
||||
{r.error
|
||||
? "Erro"
|
||||
: idx === 0
|
||||
? "—"
|
||||
: computeOverlap(baseUrls, r.urls)}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="flex gap-3 p-3" style={{ minWidth: `${results.length * 296}px` }}>
|
||||
{results.map((cr) => {
|
||||
const isBestLatency = !cr.error && cr.provider === bestLatencyProvider;
|
||||
const isBestCost = !cr.error && cr.provider === bestCostProvider;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={cr.provider}
|
||||
className="min-w-[280px] w-[280px] shrink-0 flex flex-col rounded-lg border border-border bg-surface overflow-hidden"
|
||||
data-testid={`compare-col-${cr.provider}`}
|
||||
>
|
||||
{/* Column header */}
|
||||
<div className="px-3 py-2 bg-bg-alt border-b border-border">
|
||||
<p className="text-xs font-semibold text-text-main truncate mb-1">
|
||||
{cr.provider.replace("-search", "")}
|
||||
</p>
|
||||
{cr.error ? (
|
||||
<p className="text-[10px] text-red-400 truncate">{cr.error}</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-x-2 gap-y-0.5 text-[10px] text-text-muted">
|
||||
<span className={isBestLatency ? "text-emerald-400 font-medium" : ""}>
|
||||
{cr.latency}ms
|
||||
</span>
|
||||
<span className={isBestCost ? "text-emerald-400 font-medium" : ""}>
|
||||
${cr.cost.toFixed(4)}
|
||||
</span>
|
||||
<span>{cr.resultCount} results</span>
|
||||
<span>{formatBytes(cr.responseSize)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Results list */}
|
||||
<div className="flex flex-col divide-y divide-border overflow-y-auto max-h-[600px]">
|
||||
{cr.error ? (
|
||||
<div className="p-3">
|
||||
<p className="text-xs text-red-400">{cr.error}</p>
|
||||
</div>
|
||||
) : cr.results.length === 0 ? (
|
||||
<div className="p-3">
|
||||
<p className="text-xs text-text-muted">No results</p>
|
||||
</div>
|
||||
) : (
|
||||
cr.results.map((r, idx) => {
|
||||
const isShared = (urlCountMap.get(r.url) ?? 0) > 1;
|
||||
return (
|
||||
<div key={idx} className="p-3 space-y-0.5">
|
||||
<div className="flex items-start gap-1">
|
||||
{isShared && (
|
||||
<span
|
||||
className="text-emerald-400 text-[11px] mt-0.5 shrink-0"
|
||||
title="in common with another provider"
|
||||
aria-label="in common"
|
||||
>
|
||||
⭐
|
||||
</span>
|
||||
)}
|
||||
<a
|
||||
href={r.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium text-sm text-text-main hover:text-primary leading-snug"
|
||||
>
|
||||
{r.title || r.url}
|
||||
</a>
|
||||
</div>
|
||||
{r.snippet && (
|
||||
<p className="text-xs text-text-muted line-clamp-2 leading-relaxed">
|
||||
{r.snippet}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-[10px] text-text-muted truncate">{r.url}</p>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Overlap summary footer */}
|
||||
{results.length >= 2 && (
|
||||
<div className="px-4 py-2 bg-bg-alt border-t border-border">
|
||||
<div className="flex flex-wrap gap-3 text-[10px] text-text-muted">
|
||||
{results.slice(1).map((cr) => {
|
||||
const baseUrls = results[0]?.urls ?? [];
|
||||
return (
|
||||
<span key={cr.provider}>
|
||||
<span className="font-medium text-text-main">
|
||||
{results[0]?.provider.replace("-search", "")}
|
||||
</span>
|
||||
{" vs "}
|
||||
<span className="font-medium text-text-main">
|
||||
{cr.provider.replace("-search", "")}
|
||||
</span>
|
||||
{": "}
|
||||
{cr.error ? "—" : computeOverlap(baseUrls, cr.urls)}{" "}
|
||||
in common
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -364,10 +424,10 @@ export default function CompareTab({ providers, onMetrics }: CompareTabProps) {
|
||||
>
|
||||
<span className="text-3xl mb-3" aria-hidden="true">⚖</span>
|
||||
<p className="text-sm text-text-muted mb-1">
|
||||
Selecione até {MAX_PROVIDERS} providers e insira uma query
|
||||
Select providers and enter a query to compare
|
||||
</p>
|
||||
<p className="text-xs text-text-muted">
|
||||
Os resultados serão comparados lado a lado com latência, custo e overlap de URLs
|
||||
Results will be shown side by side with latency, cost, and URL overlap
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -8,7 +8,7 @@ import { AgentBridgeServerCard } from "./components/AgentBridgeServerCard";
|
||||
import { AgentList } from "./components/AgentList";
|
||||
import { EmptyStateNoProviders } from "./components/EmptyStateNoProviders";
|
||||
import { useAgentBridgeState } from "./hooks/useAgentBridgeState";
|
||||
import type { MitmTarget } from "@/mitm/types";
|
||||
import type { MitmTargetView } from "@/mitm/types";
|
||||
import type { MappingRow } from "./components/ModelMappingTable";
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────────────
|
||||
@@ -43,7 +43,7 @@ export interface AgentBridgePageData {
|
||||
|
||||
interface AgentBridgePageClientProps {
|
||||
initialData: AgentBridgePageData;
|
||||
targets: MitmTarget[];
|
||||
targets: MitmTargetView[];
|
||||
hasProviders: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import { DnsStatusBadge } from "./shared/DnsStatusBadge";
|
||||
import { ModelMappingTable } from "./ModelMappingTable";
|
||||
import { SetupWizard } from "./SetupWizard";
|
||||
import { RiskNoticeModal } from "@/shared/components/RiskNoticeModal";
|
||||
import type { MitmTarget } from "@/mitm/types";
|
||||
import type { MitmTargetView } from "@/mitm/types";
|
||||
import type { AgentStateEntry } from "../AgentBridgePageClient";
|
||||
import type { MappingRow } from "./ModelMappingTable";
|
||||
|
||||
@@ -23,7 +23,7 @@ function hasAcceptedRisk(agentId: string): boolean {
|
||||
|
||||
|
||||
interface AgentCardProps {
|
||||
target: MitmTarget;
|
||||
target: MitmTargetView;
|
||||
agentState: AgentStateEntry | undefined;
|
||||
serverRunning: boolean;
|
||||
mappings: MappingRow[];
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { AgentCard } from "./AgentCard";
|
||||
import type { MitmTarget } from "@/mitm/types";
|
||||
import type { MitmTargetView } from "@/mitm/types";
|
||||
import type { AgentStateEntry, AgentMappingsMap } from "../AgentBridgePageClient";
|
||||
import type { MappingRow } from "./ModelMappingTable";
|
||||
|
||||
interface AgentListProps {
|
||||
targets: MitmTarget[];
|
||||
targets: MitmTargetView[];
|
||||
agentStates: AgentStateEntry[];
|
||||
serverRunning: boolean;
|
||||
mappingsMap: AgentMappingsMap;
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import type { AgentStateEntry } from "../AgentBridgePageClient";
|
||||
import type { MitmTarget } from "@/mitm/types";
|
||||
import type { MitmTargetView } from "@/mitm/types";
|
||||
|
||||
interface SetupWizardProps {
|
||||
target: MitmTarget;
|
||||
target: MitmTargetView;
|
||||
agentState: AgentStateEntry | undefined;
|
||||
serverRunning: boolean;
|
||||
onClose: () => void;
|
||||
|
||||
@@ -54,7 +54,7 @@ export default async function AgentBridgePage() {
|
||||
return (
|
||||
<AgentBridgePageClient
|
||||
initialData={initialData}
|
||||
targets={ALL_TARGETS}
|
||||
targets={ALL_TARGETS.map(({ handler, ...rest }) => rest)}
|
||||
hasProviders={hasProviders}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1283,7 +1283,45 @@
|
||||
"a2aNoTasks": "No A2A tasks recorded.",
|
||||
"a2aLoadingTasks": "Loading A2A tasks...",
|
||||
"actor": "Actor",
|
||||
"actorPlaceholder": "Filter by actor"
|
||||
"actorPlaceholder": "Filter by actor",
|
||||
"eventTypes": {
|
||||
"apiKey.activate": "API Key Activated",
|
||||
"apiKey.ban": "API Key Banned",
|
||||
"apiKey.deactivate": "API Key Deactivated",
|
||||
"apiKey.regenerate": "API Key Regenerated",
|
||||
"apiKey.scopes.grant": "API Key Scopes Granted",
|
||||
"apiKey.scopes.revoke": "API Key Scopes Revoked",
|
||||
"apiKey.scopes.update": "API Key Scopes Updated",
|
||||
"apiKey.unban": "API Key Unbanned",
|
||||
"auth.login.error": "Login Error",
|
||||
"auth.login.failed": "Login Failed",
|
||||
"auth.login.locked": "Login Locked",
|
||||
"auth.login.misconfigured": "Login Misconfigured",
|
||||
"auth.login.setup_required": "Login Setup Required",
|
||||
"auth.login.success": "Login Success",
|
||||
"auth.logout.success": "Logout Success",
|
||||
"compliance.cleanup": "Compliance Cleanup",
|
||||
"provider.credentials.applied": "Provider Credentials Applied",
|
||||
"provider.credentials.batch_revoked": "Provider Credentials Batch Revoked",
|
||||
"provider.credentials.bulk_created": "Provider Credentials Bulk Created",
|
||||
"provider.credentials.bulk_imported": "Provider Credentials Bulk Imported",
|
||||
"provider.credentials.created": "Provider Credentials Created",
|
||||
"provider.credentials.imported": "Provider Credentials Imported",
|
||||
"provider.credentials.revoked": "Provider Credentials Revoked",
|
||||
"provider.credentials.updated": "Provider Credentials Updated",
|
||||
"provider.validation.ssrf_blocked": "Provider SSRF Blocked",
|
||||
"quota.plan.updated": "Quota Plan Updated",
|
||||
"quota.pool.created": "Quota Pool Created",
|
||||
"quota.pool.deleted": "Quota Pool Deleted",
|
||||
"quota.pool.updated": "Quota Pool Updated",
|
||||
"quota.store.driver_changed": "Quota Store Driver Changed",
|
||||
"server.start": "Server Start",
|
||||
"service.reveal_api_key": "Service API Key Revealed",
|
||||
"settings.update": "Settings Updated",
|
||||
"settings.update_failed": "Settings Update Failed",
|
||||
"sync.token.created": "Sync Token Created",
|
||||
"sync.token.revoked": "Sync Token Revoked"
|
||||
}
|
||||
},
|
||||
"themesPage": {
|
||||
"title": "Themes",
|
||||
@@ -3254,7 +3292,8 @@
|
||||
"qdrantOk": "Healthy ({latencyMs}ms)",
|
||||
"qdrantError": "Connection error",
|
||||
"needsReindex": "{count} memory(ies) need reindexing",
|
||||
"configureCta": "Configure"
|
||||
"configureCta": "Configure",
|
||||
"vectorStoreInstallHint": "To enable vector search: npm install sqlite-vec (needs native Node.js, not WASM), then restart."
|
||||
},
|
||||
"episodic": "Episodic",
|
||||
"export": "Export",
|
||||
@@ -3365,7 +3404,8 @@
|
||||
"semantic": "Concepts, preferences, and domain knowledge"
|
||||
},
|
||||
"totalEntries": "Total Entries",
|
||||
"type": "Type"
|
||||
"type": "Type",
|
||||
"memoryEnabled": "Memory enabled"
|
||||
},
|
||||
"skills": {
|
||||
"title": "Skills",
|
||||
@@ -5643,7 +5683,11 @@
|
||||
"vercelRelayProjectNameLabel": "Vercel Project Name",
|
||||
"vercelRelayFreeTierNote": "Relays are lightweight proxy endpoints deployed on Vercel's free tier to bypass local network/region limitations.",
|
||||
"vercelRelayDeploying": "Deploying...",
|
||||
"vercelRelayDeploy": "Deploy"
|
||||
"vercelRelayDeploy": "Deploy",
|
||||
"proxyGlobalConfigTab": "Global Config",
|
||||
"proxyPoolTab": "Proxy Pool",
|
||||
"freePoolTab": "Free Pool",
|
||||
"proxyDocumentationTab": "Documentation"
|
||||
},
|
||||
"contextRtk": {
|
||||
"title": "RTK Engine",
|
||||
@@ -7500,7 +7544,27 @@
|
||||
"invalidJson": "Invalid JSON",
|
||||
"running": "Running…",
|
||||
"runLabel": "Run",
|
||||
"enterToolResult": "Enter tool result…"
|
||||
"enterToolResult": "Enter tool result…",
|
||||
"build": {
|
||||
"step1Label": "What to test?",
|
||||
"step2Label": "Configure",
|
||||
"step3Label": "Run",
|
||||
"step1Title": "What do you want to test?",
|
||||
"step1Subtitle": "Choose the capability you want to explore in this session.",
|
||||
"step2Title": "Configure",
|
||||
"step2Subtitle": "Set up the tools or JSON schema that will be used in the request.",
|
||||
"step3Title": "Run",
|
||||
"modeToolsTitle": "Tools",
|
||||
"modeToolsDesc": "Test function calling — define tools and see how the model invokes them.",
|
||||
"modeJsonTitle": "JSON",
|
||||
"modeJsonDesc": "Test structured output — constrain the response to a JSON schema.",
|
||||
"modeBothTitle": "Tools + JSON",
|
||||
"modeBothDesc": "Combine function calling and structured output in a single request.",
|
||||
"backButton": "Back",
|
||||
"nextButton": "Next",
|
||||
"runButton": "Run",
|
||||
"promptPlaceholder": "Enter your message… (Enter to send, Shift+Enter for newline)"
|
||||
}
|
||||
},
|
||||
"miniPlayground": {
|
||||
"endpoint": "Endpoint",
|
||||
|
||||
@@ -2465,7 +2465,45 @@
|
||||
"a2aNoTasks": "Nenhuma tarefa A2A registrada.",
|
||||
"a2aLoadingTasks": "Carregando tarefas A2A...",
|
||||
"actor": "Autor",
|
||||
"actorPlaceholder": "Filtrar por autor"
|
||||
"actorPlaceholder": "Filtrar por autor",
|
||||
"eventTypes": {
|
||||
"apiKey.activate": "API Key Ativada",
|
||||
"apiKey.ban": "API Key Banida",
|
||||
"apiKey.deactivate": "API Key Desativada",
|
||||
"apiKey.regenerate": "API Key Regenerada",
|
||||
"apiKey.scopes.grant": "Escopos de API Key Concedidos",
|
||||
"apiKey.scopes.revoke": "Escopos de API Key Revogados",
|
||||
"apiKey.scopes.update": "Escopos de API Key Atualizados",
|
||||
"apiKey.unban": "API Key Desbanida",
|
||||
"auth.login.error": "Erro de Login",
|
||||
"auth.login.failed": "Login Falhou",
|
||||
"auth.login.locked": "Login Bloqueado",
|
||||
"auth.login.misconfigured": "Login Mal Configurado",
|
||||
"auth.login.setup_required": "Configuração de Login Necessária",
|
||||
"auth.login.success": "Login com Sucesso",
|
||||
"auth.logout.success": "Logout com Sucesso",
|
||||
"compliance.cleanup": "Limpeza de Conformidade",
|
||||
"provider.credentials.applied": "Credenciais do Provedor Aplicadas",
|
||||
"provider.credentials.batch_revoked": "Credenciais do Provedor Revogadas em Lote",
|
||||
"provider.credentials.bulk_created": "Credenciais do Provedor Criadas em Massa",
|
||||
"provider.credentials.bulk_imported": "Credenciais do Provedor Importadas em Massa",
|
||||
"provider.credentials.created": "Credenciais do Provedor Criadas",
|
||||
"provider.credentials.imported": "Credenciais do Provedor Importadas",
|
||||
"provider.credentials.revoked": "Credenciais do Provedor Revogadas",
|
||||
"provider.credentials.updated": "Credenciais do Provedor Atualizadas",
|
||||
"provider.validation.ssrf_blocked": "SSRF do Provedor Bloqueado",
|
||||
"quota.plan.updated": "Plano de Cota Atualizado",
|
||||
"quota.pool.created": "Pool de Cota Criado",
|
||||
"quota.pool.deleted": "Pool de Cota Excluído",
|
||||
"quota.pool.updated": "Pool de Cota Atualizado",
|
||||
"quota.store.driver_changed": "Driver do Store de Cota Alterado",
|
||||
"server.start": "Servidor Iniciado",
|
||||
"service.reveal_api_key": "API Key do Serviço Revelada",
|
||||
"settings.update": "Configurações Atualizadas",
|
||||
"settings.update_failed": "Falha ao Atualizar Configurações",
|
||||
"sync.token.created": "Token de Sincronização Criado",
|
||||
"sync.token.revoked": "Token de Sincronização Revogado"
|
||||
}
|
||||
},
|
||||
"contextCaveman": {
|
||||
"title": "Motor Caveman",
|
||||
@@ -3841,7 +3879,8 @@
|
||||
"qdrantOk": "Saudável ({latencyMs}ms)",
|
||||
"qdrantError": "Erro de conexão",
|
||||
"needsReindex": "{count} memória(s) precisam de reindexação",
|
||||
"configureCta": "Configurar"
|
||||
"configureCta": "Configurar",
|
||||
"vectorStoreInstallHint": "Para ativar a busca vetorial: npm install sqlite-vec (requer Node.js nativo, não WASM) e reinicie."
|
||||
},
|
||||
"episodic": "Episódica",
|
||||
"export": "Exportar",
|
||||
@@ -3952,7 +3991,8 @@
|
||||
"semantic": "Conceitos, preferências e conhecimento de domínio"
|
||||
},
|
||||
"totalEntries": "Total de Entradas",
|
||||
"type": "Tipo"
|
||||
"type": "Tipo",
|
||||
"memoryEnabled": "Memória ativada"
|
||||
},
|
||||
"miniPlayground": {
|
||||
"endpoint": "Endpoint",
|
||||
@@ -4261,7 +4301,27 @@
|
||||
"invalidJson": "JSON inválido",
|
||||
"running": "Executando…",
|
||||
"runLabel": "Executar",
|
||||
"enterToolResult": "Insira o resultado da ferramenta…"
|
||||
"enterToolResult": "Insira o resultado da ferramenta…",
|
||||
"build": {
|
||||
"step1Label": "O que testar?",
|
||||
"step2Label": "Configurar",
|
||||
"step3Label": "Rodar",
|
||||
"step1Title": "O que você quer testar?",
|
||||
"step1Subtitle": "Escolha a funcionalidade que deseja explorar nesta sessão.",
|
||||
"step2Title": "Configurar",
|
||||
"step2Subtitle": "Configure as ferramentas ou o esquema JSON que serão usados na requisição.",
|
||||
"step3Title": "Rodar",
|
||||
"modeToolsTitle": "Ferramentas",
|
||||
"modeToolsDesc": "Teste function calling — defina ferramentas e veja como o modelo as invoca.",
|
||||
"modeJsonTitle": "JSON",
|
||||
"modeJsonDesc": "Teste saída estruturada — restrinja a resposta a um esquema JSON.",
|
||||
"modeBothTitle": "Ferramentas + JSON",
|
||||
"modeBothDesc": "Combine function calling e saída estruturada em uma única requisição.",
|
||||
"backButton": "Voltar",
|
||||
"nextButton": "Próximo",
|
||||
"runButton": "Executar",
|
||||
"promptPlaceholder": "Digite sua mensagem… (Enter para enviar, Shift+Enter para nova linha)"
|
||||
}
|
||||
},
|
||||
"pricingModal": {
|
||||
"title": "Configuração de preços",
|
||||
@@ -6628,7 +6688,11 @@
|
||||
"homeQuickStart": "Quick Start",
|
||||
"homeQuickStartDesc": "Show the Quick Start panel on the Home page.",
|
||||
"homeProviderTopology": "Provider Topology",
|
||||
"homeProviderTopologyDesc": "Show the Provider Topology on the Home page."
|
||||
"homeProviderTopologyDesc": "Show the Provider Topology on the Home page.",
|
||||
"proxyGlobalConfigTab": "Configuração Global",
|
||||
"proxyPoolTab": "Pool de Proxy",
|
||||
"freePoolTab": "Pool Gratuito",
|
||||
"proxyDocumentationTab": "Documentação"
|
||||
},
|
||||
"sidebar": {
|
||||
"home": "Início",
|
||||
|
||||
@@ -38,6 +38,13 @@ export interface MitmTarget {
|
||||
viability?: "investigating" | "supported" | "deprecated"; // Trae = "investigating"
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializable view of a MitmTarget for Server→Client Component props.
|
||||
* Omits `handler` (a function): Next.js forbids passing functions across the
|
||||
* Server/Client boundary, and the UI never invokes it. See agent-bridge/page.tsx.
|
||||
*/
|
||||
export type MitmTargetView = Omit<MitmTarget, "handler">;
|
||||
|
||||
export const MitmTargetSchema = z.object({
|
||||
id: z.enum([
|
||||
"antigravity", "kiro", "copilot", "codex", "cursor", "zed",
|
||||
|
||||
@@ -13,12 +13,22 @@ import {
|
||||
isLocalOnlyBypassableByManageScope,
|
||||
isLocalOnlyPath,
|
||||
isLoopbackHost,
|
||||
isPrivateLanHost,
|
||||
} from "../routeGuard";
|
||||
|
||||
const MODEL_SYNC_MANAGEMENT_PATH = /^\/api\/providers\/[^/]+\/(sync-models|models)$/;
|
||||
|
||||
function requestPeerAddress(ctx: PolicyContext): string | null {
|
||||
return ctx.request.ip || ctx.request.socket?.remoteAddress || null;
|
||||
// In the Next middleware runtime (proxy.ts → runAuthzPipeline), ctx.request is
|
||||
// a NextRequest with no socket/.ip, so the only locality signal is the Host
|
||||
// header — which is exactly what isLoopbackHost/isPrivateLanHost parse (they
|
||||
// strip :port). This both fixes the loopback gate (previously the null socket
|
||||
// made every LOCAL_ONLY request 403, even from localhost) and enables the
|
||||
// owner-authorized private-LAN carve-out. Fall back to .ip/.socket for any
|
||||
// non-middleware caller that provides them. Spawn-capable endpoints still
|
||||
// require manage-scope auth after this gate.
|
||||
const hostHeader = ctx.request.headers?.get?.("host") ?? null;
|
||||
return hostHeader || ctx.request.ip || ctx.request.socket?.remoteAddress || null;
|
||||
}
|
||||
|
||||
function isLoopbackRequest(ctx: PolicyContext): boolean {
|
||||
@@ -26,6 +36,14 @@ function isLoopbackRequest(ctx: PolicyContext): boolean {
|
||||
return peerAddress ? isLoopbackHost(peerAddress) : false;
|
||||
}
|
||||
|
||||
// Owner-authorized (2026-05-30): allow LOCAL_ONLY *paths* from a trusted private
|
||||
// LAN, based on the real socket peer IP (not spoofable). Does NOT relax the
|
||||
// CLI-token gate, which stays strictly loopback.
|
||||
function isPrivateLanRequest(ctx: PolicyContext): boolean {
|
||||
const peerAddress = requestPeerAddress(ctx);
|
||||
return peerAddress ? isPrivateLanHost(peerAddress) : false;
|
||||
}
|
||||
|
||||
function hasValidCliToken(ctx: PolicyContext): boolean {
|
||||
if (!isLoopbackRequest(ctx)) return false;
|
||||
const headers = ctx.request.headers;
|
||||
@@ -70,7 +88,7 @@ export const managementPolicy: RoutePolicy = {
|
||||
//
|
||||
// Anonymous (no Bearer / invalid key / wrong scope / no session) requests
|
||||
// still hit the same 403 LOCAL_ONLY they did before.
|
||||
if (isLocalOnlyPath(path) && !isLoopbackRequest(ctx)) {
|
||||
if (isLocalOnlyPath(path) && !isLoopbackRequest(ctx) && !isPrivateLanRequest(ctx)) {
|
||||
if (isLocalOnlyBypassableByManageScope(path)) {
|
||||
const apiKey = extractApiKey(ctx.request as unknown as Request);
|
||||
if (apiKey) {
|
||||
|
||||
@@ -89,6 +89,39 @@ export function isLoopbackHost(hostHeader: string | null): boolean {
|
||||
return LOOPBACK_HOSTS.has(host.toLowerCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* Private-LAN ranges (RFC 1918 IPv4 + IPv6 ULA/link-local). Matched against the
|
||||
* real socket peer address (NOT the spoofable Host header), so a public-internet
|
||||
* client — which presents a public source IP — never matches.
|
||||
*/
|
||||
const PRIVATE_LAN_PATTERNS: ReadonlyArray<RegExp> = [
|
||||
/^10\.\d{1,3}\.\d{1,3}\.\d{1,3}$/,
|
||||
/^192\.168\.\d{1,3}\.\d{1,3}$/,
|
||||
/^172\.(1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}$/,
|
||||
/^f[cd][0-9a-f]{2}:/i, // IPv6 ULA fc00::/7
|
||||
/^fe80:/i, // IPv6 link-local
|
||||
];
|
||||
|
||||
/**
|
||||
* True when the peer address is a private-LAN address. Used to widen the
|
||||
* LOCAL_ONLY tier to a trusted private network (owner-authorized 2026-05-30 for
|
||||
* a LAN-deployed instance). Loopback-only surfaces that do NOT use this (e.g.
|
||||
* the CLI-token path) remain strictly loopback.
|
||||
*/
|
||||
export function isPrivateLanHost(hostHeader: string | null): boolean {
|
||||
if (!hostHeader) return false;
|
||||
let host = hostHeader.trim();
|
||||
if (host.startsWith("[")) {
|
||||
const bracketEnd = host.indexOf("]");
|
||||
host = bracketEnd >= 0 ? host.slice(1, bracketEnd) : host.slice(1);
|
||||
}
|
||||
host = host.replace(/^::ffff:/i, "");
|
||||
// Strip :port only for IPv4 / hostname (a lone colon); leave IPv6 intact.
|
||||
if ((host.match(/:/g) || []).length === 1) host = host.split(":")[0];
|
||||
host = host.toLowerCase();
|
||||
return PRIVATE_LAN_PATTERNS.some((re) => re.test(host));
|
||||
}
|
||||
|
||||
export function isLocalOnlyPath(path: string): boolean {
|
||||
return LOCAL_ONLY_API_PREFIXES.some((p) => path === p || path.startsWith(p));
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ export default function Select({
|
||||
className,
|
||||
selectClassName,
|
||||
id: externalId,
|
||||
children,
|
||||
...props
|
||||
}: SelectProps) {
|
||||
const generatedId = useId();
|
||||
@@ -71,14 +72,18 @@ export default function Select({
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<option value="" disabled className="bg-surface text-text-muted">
|
||||
{placeholder}
|
||||
</option>
|
||||
{options.map((option) => (
|
||||
<option key={option.value} value={option.value} className="bg-surface text-text-main">
|
||||
{option.label}
|
||||
{!children && placeholder && (
|
||||
<option value="" disabled className="bg-surface text-text-muted">
|
||||
{placeholder}
|
||||
</option>
|
||||
))}
|
||||
)}
|
||||
{!children &&
|
||||
options.map((option) => (
|
||||
<option key={option.value} value={option.value} className="bg-surface text-text-main">
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
{children}
|
||||
</select>
|
||||
<div
|
||||
className="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none text-text-muted"
|
||||
|
||||
33
tests/unit/agent-bridge-targets-serializable.test.ts
Normal file
33
tests/unit/agent-bridge-targets-serializable.test.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { ALL_TARGETS } from "../../src/mitm/targets/index.ts";
|
||||
|
||||
// Regression guard for the agent-bridge "erro ao carregar" bug.
|
||||
//
|
||||
// `agent-bridge/page.tsx` is a Server Component that passes `targets` to the
|
||||
// `AgentBridgePageClient` Client Component. Each MitmTarget carries a
|
||||
// `handler: () => Promise<...>` function. Next.js forbids passing functions
|
||||
// across the Server/Client boundary, raising at runtime:
|
||||
// "Functions cannot be passed directly to Client Components ..."
|
||||
// which broke SSR for the whole page. The fix sanitizes the array via
|
||||
// ALL_TARGETS.map(({ handler, ...rest }) => rest)
|
||||
// (a MitmTargetView). These tests pin both halves of that contract.
|
||||
|
||||
test("agent-bridge: sanitized targets (no handler) are fully serializable for Client Components", () => {
|
||||
const views = ALL_TARGETS.map(({ handler, ...rest }) => rest);
|
||||
assert.ok(views.length > 0, "expected at least one MITM target");
|
||||
for (const v of views) {
|
||||
const id = (v as { id?: string }).id ?? "<unknown>";
|
||||
assert.equal("handler" in v, false, `${id}: handler must be stripped before crossing to a Client Component`);
|
||||
for (const [key, value] of Object.entries(v)) {
|
||||
assert.notEqual(typeof value, "function", `${id}.${key} must not be a function (non-serializable)`);
|
||||
}
|
||||
assert.doesNotThrow(() => JSON.parse(JSON.stringify(v)), `${id} must be JSON-serializable`);
|
||||
}
|
||||
});
|
||||
|
||||
test("agent-bridge: raw ALL_TARGETS still carry a handler function (so the sanitization is required)", () => {
|
||||
for (const t of ALL_TARGETS) {
|
||||
assert.equal(typeof t.handler, "function", `${t.id} should expose a lazy handler function`);
|
||||
}
|
||||
});
|
||||
27
tests/unit/audit-eventtype-i18n.test.ts
Normal file
27
tests/unit/audit-eventtype-i18n.test.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const root = join(import.meta.dirname, "../..");
|
||||
const read = (p: string) => readFileSync(join(root, p), "utf8");
|
||||
const en = JSON.parse(read("src/i18n/messages/en.json"));
|
||||
const pt = JSON.parse(read("src/i18n/messages/pt-BR.json"));
|
||||
|
||||
test("audit: compliance.eventTypes exists with en/pt-BR parity and key coverage", () => {
|
||||
const enKeys = Object.keys(en.compliance?.eventTypes ?? {});
|
||||
const ptKeys = Object.keys(pt.compliance?.eventTypes ?? {});
|
||||
assert.ok(enKeys.length >= 30, `expected >=30 event-type labels, got ${enKeys.length}`);
|
||||
assert.deepEqual(enKeys.sort(), ptKeys.sort(), "en/pt-BR eventTypes keys must match");
|
||||
for (const k of ["provider.credentials.created", "auth.login.success", "quota.pool.created", "sync.token.revoked"]) {
|
||||
assert.ok(en.compliance.eventTypes[k], `en missing eventTypes.${k}`);
|
||||
assert.ok(pt.compliance.eventTypes[k], `pt-BR missing eventTypes.${k}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("audit: ComplianceTab translates action and A2aAuditTab translates task state", () => {
|
||||
const ct = read("src/app/(dashboard)/dashboard/audit/ComplianceTab.tsx");
|
||||
const a2a = read("src/app/(dashboard)/dashboard/audit/A2aAuditTab.tsx");
|
||||
assert.ok(ct.includes("eventTypes.${entry.action}"), "ComplianceTab uses eventTypes i18n lookup");
|
||||
assert.ok(a2a.includes("a2aState${task.state"), "A2aAuditTab uses a2aState i18n lookup");
|
||||
});
|
||||
57
tests/unit/route-guard-private-lan.test.ts
Normal file
57
tests/unit/route-guard-private-lan.test.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { isPrivateLanHost, isLoopbackHost, isLocalOnlyPath } from "../../src/server/authz/routeGuard.ts";
|
||||
|
||||
test("isPrivateLanHost: accepts RFC1918 IPv4 (incl. :port and ::ffff: mapped)", () => {
|
||||
for (const h of [
|
||||
"192.168.0.15",
|
||||
"192.168.0.15:54321",
|
||||
"10.0.0.5",
|
||||
"172.16.0.9",
|
||||
"172.31.255.254",
|
||||
"::ffff:192.168.1.20",
|
||||
]) {
|
||||
assert.equal(isPrivateLanHost(h), true, `expected private-LAN: ${h}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("isPrivateLanHost: accepts IPv6 ULA / link-local", () => {
|
||||
assert.equal(isPrivateLanHost("fd12:3456::1"), true);
|
||||
assert.equal(isPrivateLanHost("fe80::1"), true);
|
||||
});
|
||||
|
||||
test("isPrivateLanHost: rejects public IPs, loopback and junk", () => {
|
||||
for (const h of [
|
||||
"8.8.8.8",
|
||||
"69.164.221.35", // public VPS
|
||||
"172.32.0.1", // just outside 172.16/12
|
||||
"127.0.0.1",
|
||||
"::1",
|
||||
"example.com",
|
||||
"",
|
||||
null,
|
||||
]) {
|
||||
assert.equal(isPrivateLanHost(h), false, `expected NOT private-LAN: ${h}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("isLoopbackHost stays loopback-only (unchanged)", () => {
|
||||
assert.equal(isLoopbackHost("127.0.0.1"), true);
|
||||
assert.equal(isLoopbackHost("localhost:20128"), true);
|
||||
assert.equal(isLoopbackHost("192.168.0.15"), false);
|
||||
});
|
||||
|
||||
test("services + traffic-inspector remain LOCAL_ONLY paths", () => {
|
||||
assert.equal(isLocalOnlyPath("/api/services/9router/status"), true);
|
||||
assert.equal(isLocalOnlyPath("/api/tools/traffic-inspector/sessions"), true);
|
||||
});
|
||||
|
||||
test("management policy derives locality from the Host header (middleware socket is null)", () => {
|
||||
const src = readFileSync(
|
||||
join(import.meta.dirname, "../../src/server/authz/policies/management.ts"),
|
||||
"utf8"
|
||||
);
|
||||
assert.ok(src.includes('headers?.get?.("host")'), "requestPeerAddress must read the Host header");
|
||||
});
|
||||
35
tests/unit/v388-phase1-screen-fixes.test.ts
Normal file
35
tests/unit/v388-phase1-screen-fixes.test.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
// Regression guards for v3.8.8 screen-fix Phase 1 (quick wins).
|
||||
const root = join(import.meta.dirname, "../..");
|
||||
const read = (p: string) => readFileSync(join(root, p), "utf8");
|
||||
|
||||
test("search-tools: export modal mounts on exportOpen (not by default) without invalid isOpen prop", () => {
|
||||
const src = read("src/app/(dashboard)/dashboard/search-tools/components/SearchToolsTopBar.tsx");
|
||||
assert.ok(src.includes("{exportOpen && exportState != null && ("), "modal guarded by exportOpen");
|
||||
assert.equal(/<ExportCodeModal[^>]*\bisOpen=/.test(src), false, "no invalid isOpen prop on ExportCodeModal");
|
||||
});
|
||||
|
||||
test("memory: tabs ordered memories -> engine -> playground", () => {
|
||||
const src = read("src/app/(dashboard)/dashboard/memory/page.tsx");
|
||||
assert.ok(src.includes('["memories", "engine", "playground"]'), "TABS order is memories, engine, playground");
|
||||
});
|
||||
|
||||
test("shared Select: renders children and guards placeholder/options when children present", () => {
|
||||
const src = read("src/shared/components/Select.tsx");
|
||||
assert.ok(src.includes("{children}"), "renders children passed by callers");
|
||||
assert.ok(src.includes("!children && placeholder"), "placeholder guarded by !children");
|
||||
assert.ok(src.includes("!children &&\n options.map") || src.includes("!children &&"), "options guarded by !children");
|
||||
});
|
||||
|
||||
test("logs: proxy/console tabs removed (dedicated menu pages exist)", () => {
|
||||
const src = read("src/app/(dashboard)/dashboard/logs/page.tsx");
|
||||
assert.equal(/value:\s*"proxy-logs"/.test(src), false, "proxy-logs tab removed");
|
||||
assert.equal(/value:\s*"console"/.test(src), false, "console tab removed");
|
||||
assert.equal(src.includes("<ProxyLogger"), false, "ProxyLogger not rendered here");
|
||||
assert.equal(src.includes("<ConsoleLogViewer"), false, "ConsoleLogViewer not rendered here");
|
||||
assert.equal(src.includes("SegmentedControl"), false, "SegmentedControl removed (single tab left)");
|
||||
});
|
||||
37
tests/unit/v388-phase3-memory.test.ts
Normal file
37
tests/unit/v388-phase3-memory.test.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
// Regression guards for v3.8.8 Memory screen fixes (Phase 3).
|
||||
const root = join(import.meta.dirname, "../..");
|
||||
const read = (p: string) => readFileSync(join(root, p), "utf8");
|
||||
const en = JSON.parse(read("src/i18n/messages/en.json"));
|
||||
const pt = JSON.parse(read("src/i18n/messages/pt-BR.json"));
|
||||
|
||||
test("memory: health auto-checks on mount + 30s polling", () => {
|
||||
const src = read("src/app/(dashboard)/dashboard/memory/components/tabs/MemoriesTab.tsx");
|
||||
assert.ok(src.includes("void checkHealth();"), "calls checkHealth from an effect");
|
||||
assert.ok(src.includes("setInterval"), "polls health periodically");
|
||||
});
|
||||
|
||||
test("memory: page wires enable/disable toggle via useMemorySettings", () => {
|
||||
const src = read("src/app/(dashboard)/dashboard/memory/page.tsx");
|
||||
assert.ok(src.includes("useMemorySettings"), "uses the settings hook");
|
||||
assert.ok(/role="switch"/.test(src), "renders a switch control");
|
||||
assert.ok(src.includes("save({ enabled:"), "persists enabled via save()");
|
||||
});
|
||||
|
||||
test("memory: vector store shows install hint when backend is none", () => {
|
||||
const src = read("src/app/(dashboard)/dashboard/memory/components/MemoryEngineStatus.tsx");
|
||||
assert.ok(src.includes('status.vectorStore.backend === "none"'), "branches on backend none");
|
||||
assert.ok(src.includes("engine.vectorStoreInstallHint"), "renders the install-hint key");
|
||||
});
|
||||
|
||||
test("memory i18n: memoryEnabled + engine.vectorStoreInstallHint present in en + pt-BR", () => {
|
||||
assert.ok(en.memory?.memoryEnabled && pt.memory?.memoryEnabled, "memoryEnabled in both locales");
|
||||
assert.ok(
|
||||
en.memory?.engine?.vectorStoreInstallHint && pt.memory?.engine?.vectorStoreInstallHint,
|
||||
"vectorStoreInstallHint in both locales"
|
||||
);
|
||||
});
|
||||
41
tests/unit/v388-phase4-playground.test.ts
Normal file
41
tests/unit/v388-phase4-playground.test.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
// Regression guards for v3.8.8 Playground screen fixes (Phase 4).
|
||||
const root = join(import.meta.dirname, "../..");
|
||||
const read = (p: string) => readFileSync(join(root, p), "utf8");
|
||||
|
||||
test("playground config: adds Provider + Model selects reusing translator hooks", () => {
|
||||
const src = read("src/app/(dashboard)/dashboard/playground/components/StudioConfigPane.tsx");
|
||||
assert.ok(src.includes("useProviderOptions"), "reuses useProviderOptions");
|
||||
assert.ok(src.includes("useAvailableModels"), "reuses useAvailableModels");
|
||||
assert.ok(src.includes('update("provider"'), "writes provider into ConfigState");
|
||||
assert.ok(src.includes("provider?: string"), "ConfigState gains provider");
|
||||
});
|
||||
|
||||
test("playground compare: prompt input + rAF throttle + user message in request", () => {
|
||||
const src = read("src/app/(dashboard)/dashboard/playground/components/tabs/CompareTab.tsx");
|
||||
assert.ok(src.includes("requestAnimationFrame"), "throttles stream updates via rAF");
|
||||
assert.ok(/role:\s*"user"/.test(src), "request body includes a user message");
|
||||
assert.ok(src.includes("setPrompt"), "has a prompt input control");
|
||||
});
|
||||
|
||||
test("playground build: wizard with 3 modes reusing editors; BuildTab keeps handlers", () => {
|
||||
const wiz = read("src/app/(dashboard)/dashboard/playground/components/tabs/build/BuildWizard.tsx");
|
||||
assert.ok(wiz.includes('"tools"') && wiz.includes('"json"') && wiz.includes('"both"'), "three modes");
|
||||
assert.ok(wiz.includes("ToolsBuilder") && wiz.includes("StructuredOutputEditor"), "reuses both editors");
|
||||
const tab = read("src/app/(dashboard)/dashboard/playground/components/tabs/BuildTab.tsx");
|
||||
assert.ok(tab.includes("<BuildWizard"), "BuildTab mounts BuildWizard");
|
||||
assert.ok(tab.includes("runRequest") && tab.includes("sendToolResult"), "BuildTab preserves run/tool handlers");
|
||||
});
|
||||
|
||||
test("playground build i18n: playground.build keys present with en/pt parity", () => {
|
||||
const en = JSON.parse(read("src/i18n/messages/en.json"));
|
||||
const pt = JSON.parse(read("src/i18n/messages/pt-BR.json"));
|
||||
const ek = Object.keys(en.playground?.build ?? {});
|
||||
const pk = Object.keys(pt.playground?.build ?? {});
|
||||
assert.ok(ek.length >= 10, `expected >=10 build keys, got ${ek.length}`);
|
||||
assert.deepEqual(ek.sort(), pk.sort(), "en/pt-BR playground.build keys must match");
|
||||
});
|
||||
23
tests/unit/v388-quota-share-usage-guard.test.ts
Normal file
23
tests/unit/v388-quota-share-usage-guard.test.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
// Regression guard: quota-share crashed with "Cannot read properties of undefined
|
||||
// (reading 'length')" because PoolCard/aggregate read usage.dimensions without a
|
||||
// guard when the usage snapshot came back without a dimensions array.
|
||||
const root = join(import.meta.dirname, "../..");
|
||||
const read = (p: string) => readFileSync(join(root, p), "utf8");
|
||||
|
||||
test("quota-share PoolCard guards usage.dimensions", () => {
|
||||
const pc = read("src/app/(dashboard)/dashboard/costs/quota-share/components/PoolCard.tsx");
|
||||
assert.ok(pc.includes("usage?.dimensions ?? []"), "computeStatus normalizes dimensions to []");
|
||||
assert.ok(pc.includes("!!usage?.dimensions?.length"), "hasDimensions guards dimensions");
|
||||
assert.equal(/[^?.]usage\.dimensions\.length/.test(pc), false, "no unguarded usage.dimensions.length");
|
||||
});
|
||||
|
||||
test("quota-share aggregate hook guards dimensions/perKey", () => {
|
||||
const agg = read("src/app/(dashboard)/dashboard/costs/quota-share/hooks/usePoolsUsageAggregate.ts");
|
||||
assert.ok(agg.includes("usage.dimensions ?? []"), "iterates dimensions with ?? []");
|
||||
assert.ok(agg.includes("dim.perKey ?? []"), "iterates perKey with ?? []");
|
||||
});
|
||||
Reference in New Issue
Block a user