mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
feat(playground): scaffold PlaygroundStudio + StudioTopBar + StudioConfigPane
Adds the PlaygroundStudio orchestrator (tab routing via ?tab= deep-link, shared configState), StudioTopBar (4 tabs + export placeholder modal), and StudioConfigPane (collapsible, 10-endpoint select, model, system prompt, ParamSliders). F7 slots (SLOT_PRESETS / SLOT_IMPROVE) left as JSX comments.
This commit is contained in:
120
src/app/(dashboard)/dashboard/playground/PlaygroundStudio.tsx
Normal file
120
src/app/(dashboard)/dashboard/playground/PlaygroundStudio.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
"use client";
|
||||
|
||||
// src/app/(dashboard)/dashboard/playground/PlaygroundStudio.tsx
|
||||
|
||||
import { useState } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import StudioTopBar, { type StudioTab } from "./components/StudioTopBar";
|
||||
import StudioConfigPane, { type ConfigState } from "./components/StudioConfigPane";
|
||||
import { DEFAULT_PARAMS } from "./components/ParamSliders";
|
||||
import dynamic from "next/dynamic";
|
||||
import type { StreamMetrics } from "@/shared/schemas/playground";
|
||||
|
||||
// Lazy-load tabs to reduce initial bundle size
|
||||
const ChatTab = dynamic(() => import("./components/tabs/ChatTab"), { ssr: false });
|
||||
const ApiTab = dynamic(() => import("./components/tabs/ApiTab"), { ssr: false });
|
||||
|
||||
const INITIAL_METRICS: StreamMetrics = {
|
||||
ttftMs: null,
|
||||
totalMs: null,
|
||||
tokensIn: 0,
|
||||
tokensOut: 0,
|
||||
tps: null,
|
||||
costUsd: null,
|
||||
};
|
||||
|
||||
const INITIAL_CONFIG: ConfigState = {
|
||||
endpoint: "chat.completions",
|
||||
baseUrl: typeof window !== "undefined" ? window.location.origin : "http://localhost:20128",
|
||||
model: "",
|
||||
systemPrompt: "You are a helpful assistant.",
|
||||
params: DEFAULT_PARAMS,
|
||||
};
|
||||
|
||||
function resolveTab(value: string | null): StudioTab {
|
||||
if (value === "chat" || value === "compare" || value === "api" || value === "build") {
|
||||
return value;
|
||||
}
|
||||
return "chat";
|
||||
}
|
||||
|
||||
/**
|
||||
* PlaygroundStudio — orchestrator component for the Playground Studio.
|
||||
* - Manages active tab state (supports ?tab=chat|compare|api|build deep-link)
|
||||
* - Manages shared configState for all tabs
|
||||
* - Renders StudioTopBar + content + StudioConfigPane
|
||||
*
|
||||
* F7 SLOTS:
|
||||
* - SLOT_PRESETS: F7 will replace the comment in StudioConfigPane with PresetPicker
|
||||
* - SLOT_IMPROVE: F7 will replace the comment in StudioConfigPane with ImprovePromptButton
|
||||
*/
|
||||
export function PlaygroundStudio() {
|
||||
const searchParams = useSearchParams();
|
||||
// Derive active tab from URL — no effect needed, avoids setState-in-effect lint violation.
|
||||
// When URL changes (client nav), searchParams updates, component re-renders with new tab.
|
||||
const activeTab: StudioTab = resolveTab(searchParams.get("tab"));
|
||||
const [manualTab, setManualTab] = useState<StudioTab | null>(null);
|
||||
const effectiveTab = manualTab ?? activeTab;
|
||||
|
||||
const [configState, setConfigState] = useState<ConfigState>(INITIAL_CONFIG);
|
||||
const [metrics, setMetrics] = useState<StreamMetrics>(INITIAL_METRICS);
|
||||
|
||||
function handleTabChange(tab: StudioTab) {
|
||||
setManualTab(tab);
|
||||
}
|
||||
|
||||
function handleMetricsUpdate(m: StreamMetrics) {
|
||||
setMetrics(m);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-[calc(100vh-4rem)] overflow-hidden">
|
||||
{/* Top bar with tabs + token/cost counter + export button */}
|
||||
<StudioTopBar
|
||||
activeTab={effectiveTab}
|
||||
onTabChange={handleTabChange}
|
||||
metrics={metrics}
|
||||
/>
|
||||
|
||||
{/* Main content area: tab content + config pane */}
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* Tab content */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{effectiveTab === "chat" && (
|
||||
<ChatTab configState={configState} onMetricsUpdate={handleMetricsUpdate} />
|
||||
)}
|
||||
{effectiveTab === "compare" && (
|
||||
<div className="flex items-center justify-center h-full text-text-muted">
|
||||
<div className="text-center space-y-2">
|
||||
<span className="material-symbols-outlined text-[48px] text-text-muted/30">
|
||||
compare
|
||||
</span>
|
||||
<p className="text-sm">Compare tab — F7 implementation pending</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{effectiveTab === "api" && (
|
||||
<ApiTab configState={configState} />
|
||||
)}
|
||||
{effectiveTab === "build" && (
|
||||
<div className="flex items-center justify-center h-full text-text-muted">
|
||||
<div className="text-center space-y-2">
|
||||
<span className="material-symbols-outlined text-[48px] text-text-muted/30">
|
||||
build
|
||||
</span>
|
||||
<p className="text-sm">Build tab — F7 implementation pending</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Config pane — always visible, collapsible */}
|
||||
{/* SLOT_PRESETS and SLOT_IMPROVE are inside StudioConfigPane */}
|
||||
<StudioConfigPane
|
||||
configState={configState}
|
||||
setConfigState={setConfigState}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
"use client";
|
||||
|
||||
// src/app/(dashboard)/dashboard/playground/components/StudioConfigPane.tsx
|
||||
|
||||
import { useState } from "react";
|
||||
import ParamSliders, { type PlaygroundParams } from "./ParamSliders";
|
||||
import type { PlaygroundEndpoint } from "@/lib/playground/codeExport";
|
||||
import { endpointToPath } from "@/lib/playground/codeExport";
|
||||
|
||||
export interface ConfigState {
|
||||
endpoint: PlaygroundEndpoint;
|
||||
baseUrl: string;
|
||||
model: string;
|
||||
systemPrompt: string;
|
||||
params: PlaygroundParams;
|
||||
}
|
||||
|
||||
interface StudioConfigPaneProps {
|
||||
configState: ConfigState;
|
||||
setConfigState: (s: ConfigState) => void;
|
||||
}
|
||||
|
||||
const ENDPOINT_OPTIONS: Array<{ value: PlaygroundEndpoint; label: string }> = [
|
||||
{ value: "chat.completions", label: "Chat completions" },
|
||||
{ value: "completions", label: "Completions" },
|
||||
{ value: "embeddings", label: "Embeddings" },
|
||||
{ value: "images", label: "Images" },
|
||||
{ value: "audio.transcriptions", label: "Audio transcriptions" },
|
||||
{ value: "audio.speech", label: "Audio speech" },
|
||||
{ value: "moderations", label: "Moderations" },
|
||||
{ value: "rerank", label: "Rerank" },
|
||||
{ value: "search", label: "Search" },
|
||||
{ value: "web.fetch", label: "Web fetch" },
|
||||
];
|
||||
|
||||
/**
|
||||
* Right-side collapsible config pane for PlaygroundStudio.
|
||||
* Slots for F7:
|
||||
* - SLOT_PRESETS: PresetPicker will be injected here
|
||||
* - SLOT_IMPROVE: ImprovePromptButton will be injected here
|
||||
*/
|
||||
export default function StudioConfigPane({ configState, setConfigState }: StudioConfigPaneProps) {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
|
||||
function update<K extends keyof ConfigState>(key: K, value: ConfigState[K]) {
|
||||
setConfigState({ ...configState, [key]: value });
|
||||
}
|
||||
|
||||
if (collapsed) {
|
||||
return (
|
||||
<div className="flex flex-col items-center w-8 shrink-0">
|
||||
<button
|
||||
onClick={() => setCollapsed(false)}
|
||||
className="mt-2 p-1.5 rounded hover:bg-black/5 dark:hover:bg-white/5 text-text-muted hover:text-text-main transition-colors"
|
||||
title="Expand config pane"
|
||||
aria-label="Expand config pane"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">settings</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<aside
|
||||
className="w-72 shrink-0 border-l border-border bg-bg-alt flex flex-col overflow-y-auto"
|
||||
aria-label="Config pane"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
|
||||
<span className="text-xs font-semibold text-text-muted uppercase tracking-wider">
|
||||
Config
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setCollapsed(true)}
|
||||
className="p-1 rounded hover:bg-black/5 dark:hover:bg-white/5 text-text-muted hover:text-text-main transition-colors"
|
||||
title="Collapse config pane"
|
||||
aria-label="Collapse config pane"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">chevron_right</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-5 p-4">
|
||||
{/* SLOT_PRESETS: F7 substituirá com PresetPicker */}
|
||||
{/* SLOT_PRESETS */}
|
||||
|
||||
{/* Endpoint */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-medium text-text-muted uppercase tracking-wider">
|
||||
Endpoint
|
||||
</label>
|
||||
<select
|
||||
value={configState.endpoint}
|
||||
onChange={(e) => update("endpoint", e.target.value as PlaygroundEndpoint)}
|
||||
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"
|
||||
>
|
||||
{ENDPOINT_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label} — {endpointToPath(opt.value)}
|
||||
</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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* System prompt */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-medium text-text-muted uppercase tracking-wider">
|
||||
System prompt
|
||||
</label>
|
||||
<textarea
|
||||
value={configState.systemPrompt}
|
||||
onChange={(e) => update("systemPrompt", e.target.value)}
|
||||
placeholder="You are a helpful assistant."
|
||||
rows={4}
|
||||
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 resize-y"
|
||||
/>
|
||||
{/* SLOT_IMPROVE: F7 substituirá com ImprovePromptButton */}
|
||||
{/* SLOT_IMPROVE */}
|
||||
</div>
|
||||
|
||||
{/* Param sliders */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="text-xs font-medium text-text-muted uppercase tracking-wider">
|
||||
Parameters
|
||||
</span>
|
||||
<ParamSliders
|
||||
params={configState.params}
|
||||
setParams={(p) => update("params", p)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
"use client";
|
||||
|
||||
// src/app/(dashboard)/dashboard/playground/components/StudioTopBar.tsx
|
||||
|
||||
import { useState } from "react";
|
||||
import TokenCostCounter from "./TokenCostCounter";
|
||||
import type { StreamMetrics } from "@/shared/schemas/playground";
|
||||
|
||||
export type StudioTab = "chat" | "compare" | "api" | "build";
|
||||
|
||||
interface StudioTopBarProps {
|
||||
activeTab: StudioTab;
|
||||
onTabChange: (tab: StudioTab) => void;
|
||||
metrics: StreamMetrics;
|
||||
}
|
||||
|
||||
interface TabConfig {
|
||||
id: StudioTab;
|
||||
label: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
const TABS: TabConfig[] = [
|
||||
{ id: "chat", label: "Chat", icon: "chat" },
|
||||
{ id: "compare", label: "Compare", icon: "compare" },
|
||||
{ id: "api", label: "API", icon: "api" },
|
||||
{ id: "build", label: "Build", icon: "build" },
|
||||
];
|
||||
|
||||
/**
|
||||
* Top bar with tab switcher, token/cost counter, and export code button.
|
||||
* Export code modal is a placeholder in F6; F7 replaces it with ExportCodeModal.
|
||||
*/
|
||||
export default function StudioTopBar({ activeTab, onTabChange, metrics }: StudioTopBarProps) {
|
||||
const [exportOpen, setExportOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b border-border bg-bg-alt shrink-0">
|
||||
{/* Tabs */}
|
||||
<div className="flex items-center gap-1" role="tablist">
|
||||
{TABS.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
role="tab"
|
||||
aria-selected={activeTab === tab.id}
|
||||
onClick={() => onTabChange(tab.id)}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
|
||||
activeTab === tab.id
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-text-muted hover:text-text-main hover:bg-black/5 dark:hover:bg-white/5"
|
||||
}`}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">{tab.icon}</span>
|
||||
<span>{tab.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Right side: token counter + export button */}
|
||||
<div className="flex items-center gap-3">
|
||||
<TokenCostCounter
|
||||
tokensIn={metrics.tokensIn}
|
||||
tokensOut={metrics.tokensOut}
|
||||
costUsd={metrics.costUsd}
|
||||
/>
|
||||
|
||||
<button
|
||||
onClick={() => setExportOpen(true)}
|
||||
className="flex items-center gap-1 text-xs px-2.5 py-1.5 rounded border border-border hover:bg-black/5 dark:hover:bg-white/5 text-text-muted hover:text-text-main transition-colors"
|
||||
title="Export code"
|
||||
aria-label="Export code"
|
||||
>
|
||||
<span className="font-mono text-[11px]"></></span>
|
||||
<span>Export</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Export code placeholder modal — F7 replaces with ExportCodeModal */}
|
||||
{exportOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40"
|
||||
onClick={() => setExportOpen(false)}
|
||||
>
|
||||
<div
|
||||
className="bg-surface border border-border rounded-xl p-6 w-[480px] max-w-full shadow-xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-sm font-semibold text-text-main">Export code</h2>
|
||||
<button
|
||||
onClick={() => setExportOpen(false)}
|
||||
className="text-text-muted hover:text-text-main"
|
||||
aria-label="Close export modal"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">close</span>
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-sm text-text-muted">
|
||||
Export code modal — F7 implementation pending.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user