Merge pull request #345 from unslothai/feature/fixes-client
feat(studio): fix chat code block actions and some training view changes
This commit is contained in:
commit
971ef40d85
60 changed files with 2954 additions and 1606 deletions
|
|
@ -184,6 +184,7 @@ def build_mcp_providers(
|
|||
MCPProvider(
|
||||
name=str(provider.get("name", "")),
|
||||
endpoint=str(provider.get("endpoint", "")),
|
||||
provider_type=str(provider_type),
|
||||
api_key=str(api_key) if api_key else None,
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -61,3 +61,19 @@ class SeedInspectResponse(BaseModel):
|
|||
preview_rows: list[dict[str, Any]] = Field(default_factory=list)
|
||||
split: str | None = None
|
||||
subset: str | None = None
|
||||
|
||||
|
||||
class McpToolsListRequest(BaseModel):
|
||||
mcp_providers: list[dict[str, Any]] = Field(default_factory=list)
|
||||
timeout_sec: float | None = Field(default=None, gt=0)
|
||||
|
||||
|
||||
class McpToolsProviderResult(BaseModel):
|
||||
name: str
|
||||
tools: list[str] = Field(default_factory=list)
|
||||
error: str | None = None
|
||||
|
||||
|
||||
class McpToolsListResponse(BaseModel):
|
||||
providers: list[McpToolsProviderResult] = Field(default_factory=list)
|
||||
duplicate_tools: dict[str, list[str]] = Field(default_factory=dict)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
# Data Designer runtime deps installed explicitly (single-env mode).
|
||||
# DuckDB 1.5 removed Relation.record_batch(); keep <1.5 until upstream ships the fix.
|
||||
anyascii<1,>=0.3.3
|
||||
duckdb<2,>=1.1.3
|
||||
duckdb<1.5,>=1.1.3
|
||||
faker<21,>=20.1.0
|
||||
httpx<1,>=0.27.2
|
||||
httpx-retries<1,>=0.4.2
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
# Install Data Designer in same env as Unsloth.
|
||||
data-designer==0.5.1
|
||||
data-designer-config==0.5.1
|
||||
data-designer-engine==0.5.1
|
||||
data-designer==0.5.2
|
||||
data-designer-config==0.5.2
|
||||
data-designer-engine==0.5.2
|
||||
prompt-toolkit>=3,<4
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ if str(backend_path) not in sys.path:
|
|||
sys.path.insert(0, str(backend_path))
|
||||
|
||||
from .jobs import router as jobs_router
|
||||
from .mcp import router as mcp_router
|
||||
from .seed import router as seed_router
|
||||
from .validate import router as validate_router
|
||||
|
||||
|
|
@ -21,5 +22,6 @@ router = APIRouter(dependencies=[Depends(get_current_subject)])
|
|||
router.include_router(seed_router)
|
||||
router.include_router(validate_router)
|
||||
router.include_router(jobs_router)
|
||||
router.include_router(mcp_router)
|
||||
|
||||
__all__ = ["router"]
|
||||
|
|
|
|||
77
studio/backend/routes/data_recipe/mcp.py
Normal file
77
studio/backend/routes/data_recipe/mcp.py
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
"""MCP helper endpoints for data recipe."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from core.data_recipe.service import build_mcp_providers
|
||||
from models.data_recipe import (
|
||||
McpToolsListRequest,
|
||||
McpToolsListResponse,
|
||||
McpToolsProviderResult,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/mcp/tools", response_model=McpToolsListResponse)
|
||||
def list_mcp_tools(payload: McpToolsListRequest) -> McpToolsListResponse:
|
||||
try:
|
||||
from data_designer.engine.mcp import io as mcp_io
|
||||
except ImportError as exc:
|
||||
return McpToolsListResponse(
|
||||
providers=[
|
||||
McpToolsProviderResult(
|
||||
name="",
|
||||
error=f"MCP dependencies unavailable: {exc}",
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
providers: list[McpToolsProviderResult] = []
|
||||
tool_to_providers: dict[str, list[str]] = defaultdict(list)
|
||||
|
||||
for provider_payload in payload.mcp_providers:
|
||||
provider_name = str(provider_payload.get("name", "")).strip()
|
||||
built = build_mcp_providers({"mcp_providers": [provider_payload]})
|
||||
if len(built) != 1:
|
||||
providers.append(
|
||||
McpToolsProviderResult(
|
||||
name=provider_name,
|
||||
error="Unsupported MCP provider config.",
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
provider = built[0]
|
||||
try:
|
||||
tools = mcp_io.list_tools(provider, timeout_sec=payload.timeout_sec)
|
||||
tool_names = sorted({tool.name for tool in tools if getattr(tool, "name", "")})
|
||||
for tool_name in tool_names:
|
||||
tool_to_providers[tool_name].append(provider.name)
|
||||
providers.append(
|
||||
McpToolsProviderResult(
|
||||
name=provider.name,
|
||||
tools=tool_names,
|
||||
)
|
||||
)
|
||||
except Exception as exc:
|
||||
providers.append(
|
||||
McpToolsProviderResult(
|
||||
name=provider.name or provider_name,
|
||||
error=str(exc).strip() or "Failed to load tools.",
|
||||
)
|
||||
)
|
||||
|
||||
duplicate_tools = {
|
||||
tool_name: provider_names
|
||||
for tool_name, provider_names in sorted(tool_to_providers.items())
|
||||
if len(provider_names) > 1
|
||||
}
|
||||
|
||||
return McpToolsListResponse(
|
||||
providers=providers,
|
||||
duplicate_tools=duplicate_tools,
|
||||
)
|
||||
|
|
@ -171,9 +171,9 @@ def _read_preview_rows_from_local_file(path: Path, preview_size: int) -> list[di
|
|||
df = pd.read_json(path, lines=True).head(preview_size)
|
||||
elif ext == ".json":
|
||||
try:
|
||||
df = pd.read_json(path, lines=True).head(preview_size)
|
||||
except ValueError:
|
||||
df = pd.read_json(path).head(preview_size)
|
||||
except ValueError:
|
||||
df = pd.read_json(path, lines=True).head(preview_size)
|
||||
else:
|
||||
raise HTTPException(status_code=422, detail=f"unsupported file type: {ext}")
|
||||
except HTTPException:
|
||||
|
|
|
|||
|
|
@ -1,27 +1,91 @@
|
|||
"use client";
|
||||
|
||||
import { INTERNAL, useMessagePartText } from "@assistant-ui/react";
|
||||
import { copyToClipboard } from "@/lib/copy-to-clipboard";
|
||||
import { INTERNAL, useMessagePartText } from "@assistant-ui/react";
|
||||
import { Copy02Icon, Tick02Icon } from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { code } from "@streamdown/code";
|
||||
import { math } from "@streamdown/math";
|
||||
import { mermaid } from "@streamdown/mermaid";
|
||||
import { Block, type BlockProps, Streamdown } from "streamdown";
|
||||
import { DownloadIcon } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Block, type BlockProps, Streamdown } from "streamdown";
|
||||
import "katex/dist/katex.min.css";
|
||||
import { AudioPlayer } from "./audio-player";
|
||||
|
||||
const { withSmoothContextProvider, useSmoothStatus } = INTERNAL;
|
||||
const { withSmoothContextProvider } = INTERNAL;
|
||||
const COPY_RESET_MS = 2000;
|
||||
const MERMAID_SOURCE_RE = /```mermaid\s*([\s\S]*?)```/i;
|
||||
const CODE_FENCE_RE = /^```([^\r\n`]*)\r?\n([\s\S]*?)\r?\n?```$/;
|
||||
const ACTION_PANEL_CLASS =
|
||||
"pointer-events-auto flex shrink-0 items-center gap-2 rounded-md border border-sidebar bg-sidebar/80 px-1.5 py-1 supports-[backdrop-filter]:bg-sidebar/70 supports-[backdrop-filter]:backdrop-blur";
|
||||
const ACTION_BUTTON_CLASS =
|
||||
"cursor-pointer p-1 text-muted-foreground transition-all hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50";
|
||||
|
||||
type CodeFence = {
|
||||
language: string | null;
|
||||
source: string;
|
||||
};
|
||||
|
||||
function getMermaidSource(blockContent: string): string | null {
|
||||
const source = blockContent.match(/```mermaid\s*([\s\S]*?)```/i)?.[1]?.trim();
|
||||
const source = blockContent.match(MERMAID_SOURCE_RE)?.[1]?.trim();
|
||||
return source && source.length > 0 ? source : null;
|
||||
}
|
||||
|
||||
const COPY_RESET_MS = 2000;
|
||||
function getCodeFence(blockContent: string): CodeFence | null {
|
||||
const match = blockContent.trimEnd().match(CODE_FENCE_RE);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function MermaidCopyButton({ source }: { source: string }) {
|
||||
return {
|
||||
language: match[1]?.trim() || null,
|
||||
source: match[2],
|
||||
};
|
||||
}
|
||||
|
||||
function getCodeFilename(language: string | null) {
|
||||
const extByLanguage: Record<string, string> = {
|
||||
bash: "sh",
|
||||
javascript: "js",
|
||||
js: "js",
|
||||
json: "json",
|
||||
jsx: "jsx",
|
||||
markdown: "md",
|
||||
md: "md",
|
||||
python: "py",
|
||||
py: "py",
|
||||
shell: "sh",
|
||||
sh: "sh",
|
||||
sql: "sql",
|
||||
ts: "ts",
|
||||
tsx: "tsx",
|
||||
typescript: "ts",
|
||||
yaml: "yml",
|
||||
yml: "yml",
|
||||
};
|
||||
|
||||
const normalized = language?.toLowerCase();
|
||||
const fallbackExt = normalized?.replace(/[^a-z0-9]+/g, "-");
|
||||
const ext = normalized
|
||||
? extByLanguage[normalized] || fallbackExt || "txt"
|
||||
: "txt";
|
||||
return `snippet.${ext}`;
|
||||
}
|
||||
|
||||
function downloadTextFile(filename: string, text: string): void {
|
||||
const blob = new Blob([text], { type: "text/plain;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = filename;
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
document.body.removeChild(anchor);
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 0);
|
||||
}
|
||||
|
||||
function useCopiedState() {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const resetTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
|
|
@ -33,31 +97,94 @@ function MermaidCopyButton({ source }: { source: string }) {
|
|||
};
|
||||
}, []);
|
||||
|
||||
const showCopied = () => {
|
||||
setCopied(true);
|
||||
if (resetTimeoutRef.current) {
|
||||
clearTimeout(resetTimeoutRef.current);
|
||||
}
|
||||
resetTimeoutRef.current = setTimeout(() => {
|
||||
setCopied(false);
|
||||
resetTimeoutRef.current = null;
|
||||
}, COPY_RESET_MS);
|
||||
};
|
||||
|
||||
return { copied, showCopied };
|
||||
}
|
||||
|
||||
function MermaidCopyButton({ source }: { source: string }) {
|
||||
const { copied, showCopied } = useCopiedState();
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="absolute top-3.5 right-20 z-20 cursor-pointer text-muted-foreground transition-all hover:text-foreground"
|
||||
title="Copy Mermaid source"
|
||||
onClick={() => {
|
||||
if (!copyToClipboard(source)) return;
|
||||
setCopied(true);
|
||||
if (resetTimeoutRef.current) {
|
||||
clearTimeout(resetTimeoutRef.current);
|
||||
if (!copyToClipboard(source)) {
|
||||
return;
|
||||
}
|
||||
resetTimeoutRef.current = setTimeout(() => {
|
||||
setCopied(false);
|
||||
resetTimeoutRef.current = null;
|
||||
}, COPY_RESET_MS);
|
||||
showCopied();
|
||||
}}
|
||||
>
|
||||
<HugeiconsIcon icon={copied ? Tick02Icon : Copy02Icon} className="size-5" />
|
||||
<HugeiconsIcon
|
||||
icon={copied ? Tick02Icon : Copy02Icon}
|
||||
className="size-5"
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function CodeBlockActions({
|
||||
disabled,
|
||||
language,
|
||||
source,
|
||||
}: {
|
||||
disabled: boolean;
|
||||
language: string | null;
|
||||
source: string;
|
||||
}) {
|
||||
const { copied, showCopied } = useCopiedState();
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none absolute top-3.5 right-3 z-20 flex items-center justify-end">
|
||||
<div className={ACTION_PANEL_CLASS}>
|
||||
<button
|
||||
type="button"
|
||||
className={ACTION_BUTTON_CLASS}
|
||||
title="Copy code"
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
if (!copyToClipboard(source)) {
|
||||
return;
|
||||
}
|
||||
showCopied();
|
||||
}}
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={copied ? Tick02Icon : Copy02Icon}
|
||||
className="size-3.5"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={ACTION_BUTTON_CLASS}
|
||||
title="Download file"
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
downloadTextFile(getCodeFilename(language), source);
|
||||
}}
|
||||
>
|
||||
<DownloadIcon className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StreamdownBlock(props: BlockProps) {
|
||||
const hasMermaidFence = props.content.includes("```mermaid");
|
||||
const mermaidSource = getMermaidSource(props.content);
|
||||
const codeFence = getCodeFence(props.content);
|
||||
|
||||
if (props.isIncomplete && hasMermaidFence) {
|
||||
return (
|
||||
|
|
@ -69,20 +196,32 @@ function StreamdownBlock(props: BlockProps) {
|
|||
|
||||
if (mermaidSource) {
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="relative isolate">
|
||||
<Block {...props} />
|
||||
<MermaidCopyButton source={mermaidSource} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (codeFence) {
|
||||
return (
|
||||
<div className="relative isolate">
|
||||
<Block {...props} />
|
||||
<CodeBlockActions
|
||||
disabled={props.isIncomplete}
|
||||
language={codeFence.language}
|
||||
source={codeFence.source}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <Block {...props} />;
|
||||
}
|
||||
const AUDIO_PLAYER_RE = /<audio-player\s+src="([^"]+)"\s*\/>/;
|
||||
|
||||
const MarkdownTextImpl = () => {
|
||||
const { text } = useMessagePartText();
|
||||
const status = useSmoothStatus();
|
||||
const { text, status } = useMessagePartText();
|
||||
|
||||
const audioMatch = text.match(AUDIO_PLAYER_RE);
|
||||
if (audioMatch) {
|
||||
|
|
@ -96,6 +235,7 @@ const MarkdownTextImpl = () => {
|
|||
isAnimating={status.type === "running"}
|
||||
plugins={{ code, math, mermaid }}
|
||||
controls={{
|
||||
code: false,
|
||||
mermaid: {
|
||||
fullscreen: true,
|
||||
download: true,
|
||||
|
|
|
|||
|
|
@ -249,73 +249,92 @@ function ChartTooltipContent({
|
|||
>
|
||||
{!nestLabel ? tooltipLabel : null}
|
||||
<div className="grid gap-1.5">
|
||||
{payload
|
||||
.filter((item) => item.type !== "none")
|
||||
.map((item, index) => {
|
||||
const key = `${nameKey || item.name || item.dataKey || "value"}`;
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||
const indicatorColor = color || item.payload.fill || item.color;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.dataKey}
|
||||
className={cn(
|
||||
"[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5",
|
||||
indicator === "dot" && "items-center",
|
||||
)}
|
||||
>
|
||||
{formatter && item?.value !== undefined && item.name ? (
|
||||
formatter(item.value, item.name, item, index, item.payload)
|
||||
) : (
|
||||
<>
|
||||
{itemConfig?.icon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
!hideIndicator && (
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
|
||||
{
|
||||
"h-2.5 w-2.5": indicator === "dot",
|
||||
"w-1": indicator === "line",
|
||||
"w-0 border-[1.5px] border-dashed bg-transparent":
|
||||
indicator === "dashed",
|
||||
"my-0.5": nestLabel && indicator === "dashed",
|
||||
},
|
||||
)}
|
||||
style={
|
||||
{
|
||||
"--color-bg": indicatorColor,
|
||||
"--color-border": indicatorColor,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-1 justify-between leading-none",
|
||||
nestLabel ? "items-end" : "items-center",
|
||||
)}
|
||||
>
|
||||
<div className="grid gap-1.5">
|
||||
{nestLabel ? tooltipLabel : null}
|
||||
<span className="text-muted-foreground">
|
||||
{itemConfig?.label || item.name}
|
||||
</span>
|
||||
</div>
|
||||
{item.value && (
|
||||
<span className="text-foreground font-mono font-medium tabular-nums">
|
||||
{item.value.toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{payload
|
||||
.filter((item) => item.type !== "none")
|
||||
.map((item, index) => {
|
||||
const key = `${nameKey || item.name || item.dataKey || "value"}`;
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||
const indicatorColor = color || item.payload.fill || item.color;
|
||||
let customContent: React.ReactNode = null;
|
||||
let formattedValue: React.ReactNode =
|
||||
item.value != null && typeof item.value !== "object"
|
||||
? String(item.value)
|
||||
: item.value;
|
||||
let formattedLabel: React.ReactNode = itemConfig?.label || item.name;
|
||||
|
||||
if (formatter && item?.value !== undefined && item.name) {
|
||||
const result = formatter(
|
||||
item.value,
|
||||
item.name,
|
||||
item,
|
||||
index,
|
||||
item.payload,
|
||||
);
|
||||
|
||||
if (Array.isArray(result)) {
|
||||
formattedValue = result[0];
|
||||
formattedLabel = result[1];
|
||||
} else {
|
||||
customContent = result;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.dataKey}
|
||||
className={cn(
|
||||
"[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5",
|
||||
indicator === "dot" && "items-center",
|
||||
)}
|
||||
>
|
||||
{customContent ?? (
|
||||
<>
|
||||
{itemConfig?.icon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
!hideIndicator && (
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
|
||||
{
|
||||
"h-2.5 w-2.5": indicator === "dot",
|
||||
"w-1": indicator === "line",
|
||||
"w-0 border-[1.5px] border-dashed bg-transparent":
|
||||
indicator === "dashed",
|
||||
"my-0.5": nestLabel && indicator === "dashed",
|
||||
},
|
||||
)}
|
||||
style={
|
||||
{
|
||||
"--color-bg": indicatorColor,
|
||||
"--color-border": indicatorColor,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-1 justify-between gap-3 leading-none",
|
||||
nestLabel ? "items-end" : "items-center",
|
||||
)}
|
||||
>
|
||||
<div className="grid gap-1.5">
|
||||
{nestLabel ? tooltipLabel : null}
|
||||
<span className="text-muted-foreground">{formattedLabel}</span>
|
||||
</div>
|
||||
{formattedValue != null && (
|
||||
<span className="text-foreground font-mono font-medium tabular-nums">
|
||||
{formattedValue}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -124,6 +124,25 @@ export type ValidateResponse = {
|
|||
raw_detail?: string | null;
|
||||
};
|
||||
|
||||
export type McpToolsListRequest = {
|
||||
// biome-ignore lint/style/useNamingConvention: api schema
|
||||
mcp_providers: Record<string, unknown>[];
|
||||
// biome-ignore lint/style/useNamingConvention: api schema
|
||||
timeout_sec?: number;
|
||||
};
|
||||
|
||||
export type McpToolsProviderResult = {
|
||||
name: string;
|
||||
tools: string[];
|
||||
error?: string | null;
|
||||
};
|
||||
|
||||
export type McpToolsListResponse = {
|
||||
providers: McpToolsProviderResult[];
|
||||
// biome-ignore lint/style/useNamingConvention: api schema
|
||||
duplicate_tools: Record<string, string[]>;
|
||||
};
|
||||
|
||||
async function parseErrorResponse(response: Response): Promise<string> {
|
||||
const text = (await response.text()).trim();
|
||||
if (!text) {
|
||||
|
|
@ -261,6 +280,12 @@ export async function inspectSeedUpload(
|
|||
return postJson<SeedInspectResponse>("/seed/inspect-upload", payload);
|
||||
}
|
||||
|
||||
export async function listMcpTools(
|
||||
payload: McpToolsListRequest,
|
||||
): Promise<McpToolsListResponse> {
|
||||
return postJson<McpToolsListResponse>("/mcp/tools", payload);
|
||||
}
|
||||
|
||||
export async function streamRecipeJobEvents(options: {
|
||||
jobId: string;
|
||||
signal: AbortSignal;
|
||||
|
|
@ -322,4 +347,4 @@ export async function streamRecipeJobEvents(options: {
|
|||
}
|
||||
}
|
||||
|
||||
// NOTE: tools + seed inspect/preview endpoints removed from harness.
|
||||
// NOTE: preview endpoints removed from harness.
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
EqualSignIcon,
|
||||
FingerPrintIcon,
|
||||
FunctionIcon,
|
||||
Plug01Icon,
|
||||
Parabola02Icon,
|
||||
PencilEdit02Icon,
|
||||
Plant01Icon,
|
||||
|
|
@ -29,6 +30,7 @@ import {
|
|||
makeMarkdownNoteConfig,
|
||||
makeModelConfig,
|
||||
makeModelProviderConfig,
|
||||
makeToolProfileConfig,
|
||||
makeSamplerConfig,
|
||||
makeSeedConfig,
|
||||
makeValidatorConfig,
|
||||
|
|
@ -54,7 +56,8 @@ export type BlockType =
|
|||
| "seed_local"
|
||||
| "seed_unstructured"
|
||||
| "model_provider"
|
||||
| "model_config";
|
||||
| "model_config"
|
||||
| "tool_config";
|
||||
|
||||
export type SeedBlockType = "seed_hf" | "seed_local" | "seed_unstructured";
|
||||
|
||||
|
|
@ -83,6 +86,7 @@ export type BlockDialogKey =
|
|||
| "validator"
|
||||
| "model_provider"
|
||||
| "model_config"
|
||||
| "tool_config"
|
||||
| "expression";
|
||||
|
||||
export type BlockDefinition = {
|
||||
|
|
@ -111,7 +115,7 @@ export const BLOCK_GROUPS: BlockGroup[] = [
|
|||
{
|
||||
kind: "llm",
|
||||
title: "LLM + Models",
|
||||
description: "Generation, providers, and model aliases.",
|
||||
description: "Generation, model aliases, and shared tool profiles.",
|
||||
icon: PencilEdit02Icon,
|
||||
},
|
||||
{
|
||||
|
|
@ -297,6 +301,15 @@ const BLOCK_DEFINITIONS: BlockDefinition[] = [
|
|||
dialogKey: "model_config",
|
||||
createConfig: (id, existing) => makeModelConfig(id, existing),
|
||||
},
|
||||
{
|
||||
kind: "llm",
|
||||
type: "tool_config",
|
||||
title: "Tool Profile",
|
||||
description: "Reusable MCP servers + allowed tools for one or more LLMs.",
|
||||
icon: Plug01Icon,
|
||||
dialogKey: "tool_config",
|
||||
createConfig: (id, existing) => makeToolProfileConfig(id, existing),
|
||||
},
|
||||
{
|
||||
kind: "validator",
|
||||
type: "validator_python",
|
||||
|
|
@ -399,6 +412,9 @@ export function getBlockDefinitionForConfig(
|
|||
if (config.kind === "model_config") {
|
||||
return getBlockDefinition("llm", "model_config");
|
||||
}
|
||||
if (config.kind === "tool_config") {
|
||||
return getBlockDefinition("llm", "tool_config");
|
||||
}
|
||||
if (config.kind === "markdown_note") {
|
||||
return getBlockDefinition("note", "markdown_note");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import { TimedeltaDialog } from "../dialogs/samplers/timedelta-dialog";
|
|||
import { UniformDialog } from "../dialogs/samplers/uniform-dialog";
|
||||
import { UuidDialog } from "../dialogs/samplers/uuid-dialog";
|
||||
import { MarkdownNoteDialog } from "../dialogs/markdown-note/markdown-note-dialog";
|
||||
import { ToolProfileDialog } from "../dialogs/tool-profile/tool-profile-dialog";
|
||||
import { ValidatorDialog } from "../dialogs/validators/validator-dialog";
|
||||
|
||||
export function renderBlockDialog(
|
||||
|
|
@ -24,6 +25,7 @@ export function renderBlockDialog(
|
|||
categoryOptions: SamplerConfig[],
|
||||
modelConfigAliases: string[],
|
||||
modelProviderOptions: string[],
|
||||
toolProfileAliases: string[],
|
||||
datetimeOptions: string[],
|
||||
onUpdate: (id: string, patch: Partial<NodeConfig>) => void,
|
||||
): ReactElement | null {
|
||||
|
|
@ -91,6 +93,7 @@ export function renderBlockDialog(
|
|||
config={config}
|
||||
modelConfigAliases={modelConfigAliases}
|
||||
modelProviderOptions={modelProviderOptions}
|
||||
toolProfileAliases={toolProfileAliases}
|
||||
onUpdate={update}
|
||||
/>
|
||||
) : null;
|
||||
|
|
@ -106,6 +109,10 @@ export function renderBlockDialog(
|
|||
onUpdate={update}
|
||||
/>
|
||||
) : null;
|
||||
case "tool_config":
|
||||
return config.kind === "tool_config" ? (
|
||||
<ToolProfileDialog config={config} onUpdate={update} />
|
||||
) : null;
|
||||
case "expression":
|
||||
return config.kind === "expression" ? (
|
||||
<ExpressionDialog config={config} onUpdate={update} />
|
||||
|
|
|
|||
|
|
@ -71,6 +71,7 @@ type BlockSheetProps = {
|
|||
onAddLlm: (type: LlmType) => void;
|
||||
onAddModelProvider: () => void;
|
||||
onAddModelConfig: () => void;
|
||||
onAddToolProfile: () => void;
|
||||
onAddExpression: () => void;
|
||||
onAddValidator: (
|
||||
type: "validator_python" | "validator_sql" | "validator_oxc",
|
||||
|
|
@ -225,6 +226,7 @@ export function BlockSheet({
|
|||
onAddLlm,
|
||||
onAddModelProvider,
|
||||
onAddModelConfig,
|
||||
onAddToolProfile,
|
||||
onAddExpression,
|
||||
onAddValidator,
|
||||
onAddMarkdownNote,
|
||||
|
|
@ -333,6 +335,10 @@ export function BlockSheet({
|
|||
onAddModelConfig();
|
||||
return;
|
||||
}
|
||||
if (type === "tool_config") {
|
||||
onAddToolProfile();
|
||||
return;
|
||||
}
|
||||
onAddLlm(type as LlmType);
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -52,9 +52,17 @@ export function InlineLlm({ config, onUpdate }: InlineLlmProps): ReactElement {
|
|||
.map((c) => c.name),
|
||||
[configs],
|
||||
);
|
||||
const toolProfileAliases = useMemo(
|
||||
() =>
|
||||
Object.values(configs)
|
||||
.filter((c) => c.kind === "tool_config")
|
||||
.map((c) => c.name),
|
||||
[configs],
|
||||
);
|
||||
const aliasInputRef = useRef(config.model_alias);
|
||||
const lastAliasRef = useRef(config.model_alias);
|
||||
const anchorRef = useRef<HTMLDivElement>(null);
|
||||
const toolAnchorRef = useRef<HTMLDivElement>(null);
|
||||
if (lastAliasRef.current !== config.model_alias) {
|
||||
lastAliasRef.current = config.model_alias;
|
||||
aliasInputRef.current = config.model_alias;
|
||||
|
|
@ -107,6 +115,48 @@ export function InlineLlm({ config, onUpdate }: InlineLlmProps): ReactElement {
|
|||
</Combobox>
|
||||
</div>
|
||||
</InlineField>
|
||||
<InlineField label="Tool profile">
|
||||
<div ref={toolAnchorRef}>
|
||||
<Combobox
|
||||
items={toolProfileAliases}
|
||||
filteredItems={toolProfileAliases}
|
||||
filter={null}
|
||||
value={config.tool_alias || null}
|
||||
onValueChange={(value) =>
|
||||
onUpdate({
|
||||
// biome-ignore lint/style/useNamingConvention: api schema
|
||||
tool_alias: value ?? "",
|
||||
})
|
||||
}
|
||||
itemToStringValue={(value) => value}
|
||||
autoHighlight={true}
|
||||
>
|
||||
<ComboboxInput
|
||||
className="nodrag h-8 w-full text-xs"
|
||||
placeholder="Tool profile"
|
||||
onBlur={(event) => {
|
||||
const next = event.target.value;
|
||||
if (next !== (config.tool_alias ?? "")) {
|
||||
onUpdate({
|
||||
// biome-ignore lint/style/useNamingConvention: api schema
|
||||
tool_alias: next,
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<ComboboxContent anchor={toolAnchorRef}>
|
||||
<ComboboxEmpty>No tool profiles found</ComboboxEmpty>
|
||||
<ComboboxList>
|
||||
{(alias: string) => (
|
||||
<ComboboxItem key={alias} value={alias}>
|
||||
{alias}
|
||||
</ComboboxItem>
|
||||
)}
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
</div>
|
||||
</InlineField>
|
||||
{isCode && (
|
||||
<InlineField label="Code language">
|
||||
<Select
|
||||
|
|
|
|||
|
|
@ -21,6 +21,9 @@ export function getConfigUiMode(
|
|||
if (config.kind === "model_provider" || config.kind === "model_config") {
|
||||
return "inline";
|
||||
}
|
||||
if (config.kind === "tool_config") {
|
||||
return "dialog";
|
||||
}
|
||||
if (config.kind === "llm") {
|
||||
if (config.llm_type === "text" || config.llm_type === "code") {
|
||||
return "inline";
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { MarkdownPreview } from "@/components/markdown/markdown-preview";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
|
@ -10,6 +11,7 @@ import {
|
|||
EqualSignIcon,
|
||||
FingerPrintIcon,
|
||||
FunctionIcon,
|
||||
Plug01Icon,
|
||||
Parabola02Icon,
|
||||
PencilEdit02Icon,
|
||||
Plant01Icon,
|
||||
|
|
@ -99,6 +101,9 @@ const NODE_META = {
|
|||
model_config: {
|
||||
tone: "bg-orange-50 text-orange-600 border-orange-100",
|
||||
},
|
||||
tool_config: {
|
||||
tone: "bg-cyan-50 text-cyan-700 border-cyan-100",
|
||||
},
|
||||
} as const;
|
||||
const USER_NODE_TONE =
|
||||
"bg-amber-50 text-amber-700 border-amber-100 dark:bg-amber-950/30 dark:text-amber-300 dark:border-amber-900/60";
|
||||
|
|
@ -148,6 +153,9 @@ function resolveNodeIcon(
|
|||
if (kind === "model_config") {
|
||||
return Plant01Icon;
|
||||
}
|
||||
if (kind === "tool_config") {
|
||||
return Plug01Icon;
|
||||
}
|
||||
if (kind === "seed") {
|
||||
return Plant01Icon;
|
||||
}
|
||||
|
|
@ -203,9 +211,23 @@ function getConfigSummary(config: NodeConfig | undefined): string {
|
|||
const scoreCount = config.scores?.length ?? 0;
|
||||
return `${scoreCount} scorers`;
|
||||
}
|
||||
if (config.tool_alias?.trim()) {
|
||||
return `Tool profile: ${config.tool_alias.trim()}`;
|
||||
}
|
||||
return "Prompt/system via linked input nodes";
|
||||
}
|
||||
|
||||
if (config.kind === "tool_config") {
|
||||
const providerCount = config.mcp_providers.length;
|
||||
const allowCount = config.allow_tools?.filter((value) => value.trim()).length ?? 0;
|
||||
const providerLabel =
|
||||
providerCount === 1 ? "1 MCP server" : `${providerCount} MCP servers`;
|
||||
if (allowCount === 0) {
|
||||
return `${providerLabel} · all tools allowed`;
|
||||
}
|
||||
return `${providerLabel} · ${allowCount} allowed tools`;
|
||||
}
|
||||
|
||||
if (config.kind === "validator") {
|
||||
const target = config.target_columns[0]?.trim();
|
||||
if (target) {
|
||||
|
|
@ -283,6 +305,30 @@ function renderNodeBody(
|
|||
return <InlineCategoryBadges values={config.values ?? []} />;
|
||||
}
|
||||
|
||||
if (config?.kind === "tool_config") {
|
||||
const providerNames = config.mcp_providers
|
||||
.map((provider) => provider.name.trim())
|
||||
.filter(Boolean);
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-muted-foreground">{summary}</p>
|
||||
{providerNames.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{providerNames.map((providerName) => (
|
||||
<Badge
|
||||
key={providerName}
|
||||
variant="secondary"
|
||||
className="corner-squircle font-mono text-[11px]"
|
||||
>
|
||||
{providerName}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <p className="text-xs text-muted-foreground">{summary}</p>;
|
||||
}
|
||||
|
||||
|
|
@ -355,6 +401,7 @@ function RecipeGraphNodeBase({
|
|||
const showSemanticOut =
|
||||
data.kind === "model_config" ||
|
||||
data.kind === "model_provider" ||
|
||||
data.kind === "tool_config" ||
|
||||
data.kind === "validator";
|
||||
const summary = getConfigSummary(config);
|
||||
const nodeBody = renderNodeBody(config, summary, updateConfig);
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ type ConfigDialogProps = {
|
|||
categoryOptions: SamplerConfig[];
|
||||
modelConfigAliases: string[];
|
||||
modelProviderOptions: string[];
|
||||
toolProfileAliases: string[];
|
||||
datetimeOptions: string[];
|
||||
onUpdate: (id: string, patch: Partial<NodeConfig>) => void;
|
||||
container?: HTMLDivElement | null;
|
||||
|
|
@ -28,6 +29,7 @@ export function ConfigDialog({
|
|||
categoryOptions,
|
||||
modelConfigAliases,
|
||||
modelProviderOptions,
|
||||
toolProfileAliases,
|
||||
datetimeOptions,
|
||||
onUpdate,
|
||||
container,
|
||||
|
|
@ -49,7 +51,7 @@ export function ConfigDialog({
|
|||
position="absolute"
|
||||
overlayPosition="absolute"
|
||||
overlayClassName="bg-transparent"
|
||||
className="corner-squircle max-h-[650px] overflow-auto sm:max-w-2xl shadow-border"
|
||||
className="corner-squircle max-h-[650px] overflow-y-auto overflow-x-hidden sm:max-w-2xl shadow-border"
|
||||
>
|
||||
<DialogShell
|
||||
title={blockDefinition ? `${blockDefinition.title} block` : undefined}
|
||||
|
|
@ -65,14 +67,16 @@ export function ConfigDialog({
|
|||
</div>
|
||||
)}
|
||||
{config && (
|
||||
<div className="space-y-4">
|
||||
<div className="min-w-0 space-y-4">
|
||||
{readOnly && (
|
||||
<div className="rounded-lg border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs text-amber-700 dark:text-amber-300">
|
||||
Recipe locked while execution is active.
|
||||
</div>
|
||||
)}
|
||||
<ValidationBanner config={config} />
|
||||
<div className={readOnly ? "pointer-events-none opacity-75" : undefined}>
|
||||
<div
|
||||
className={readOnly ? "pointer-events-none min-w-0 opacity-75" : "min-w-0"}
|
||||
>
|
||||
{showDropToggle && (
|
||||
<div className="mb-2 flex items-center corner-squircle justify-between gap-3 rounded-2xl border border-border/60 px-3 pt-2 pb-4">
|
||||
<div>
|
||||
|
|
@ -94,6 +98,7 @@ export function ConfigDialog({
|
|||
categoryOptions,
|
||||
modelConfigAliases,
|
||||
modelProviderOptions,
|
||||
toolProfileAliases,
|
||||
datetimeOptions,
|
||||
onUpdate,
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ import {
|
|||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { type ReactElement, type RefObject, useMemo } from "react";
|
||||
import { type ReactElement, type RefObject, useMemo, useRef } from "react";
|
||||
import { useRecipeStudioStore } from "../../stores/recipe-studio";
|
||||
import type { LlmConfig } from "../../types";
|
||||
import { isLikelyImageValue } from "../../utils/image-preview";
|
||||
|
|
@ -62,6 +62,7 @@ type LlmGeneralTabProps = {
|
|||
config: LlmConfig;
|
||||
modelConfigAliases: string[];
|
||||
modelProviderOptions: string[];
|
||||
toolProfileAliases: string[];
|
||||
modelAliasAnchorRef: RefObject<HTMLDivElement | null>;
|
||||
onUpdate: (patch: Partial<LlmConfig>) => void;
|
||||
};
|
||||
|
|
@ -70,17 +71,20 @@ export function LlmGeneralTab({
|
|||
config,
|
||||
modelConfigAliases,
|
||||
modelProviderOptions,
|
||||
toolProfileAliases,
|
||||
modelAliasAnchorRef,
|
||||
onUpdate,
|
||||
}: LlmGeneralTabProps): ReactElement {
|
||||
const configs = useRecipeStudioStore((state) => state.configs);
|
||||
const modelAliasId = `${config.id}-model-alias`;
|
||||
const toolAliasId = `${config.id}-tool-alias`;
|
||||
const codeLangId = `${config.id}-code-lang`;
|
||||
const promptId = `${config.id}-prompt`;
|
||||
const outputFormatId = `${config.id}-output-format`;
|
||||
const systemPromptId = `${config.id}-system-prompt`;
|
||||
const hasModelConfigs = modelConfigAliases.length > 0;
|
||||
const hasModelProviders = modelProviderOptions.length > 0;
|
||||
const hasToolProfiles = toolProfileAliases.length > 0;
|
||||
const validReferences = useMemo(
|
||||
() => getAvailableVariables(configs, config.id),
|
||||
[configs, config.id],
|
||||
|
|
@ -152,6 +156,7 @@ export function LlmGeneralTab({
|
|||
const traceModeId = `${config.id}-trace-mode`;
|
||||
const reasoningToggleId = `${config.id}-reasoning-content`;
|
||||
const advancedOpen = config.advancedOpen === true;
|
||||
const toolAliasAnchorRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
|
|
@ -210,6 +215,53 @@ export function LlmGeneralTab({
|
|||
</Combobox>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<FieldLabel
|
||||
label="Tool profile (optional)"
|
||||
htmlFor={toolAliasId}
|
||||
hint="Pick a shared Tool Profile block. Leave empty for no tools."
|
||||
/>
|
||||
<div ref={toolAliasAnchorRef}>
|
||||
<Combobox
|
||||
items={toolProfileAliases}
|
||||
filteredItems={toolProfileAliases}
|
||||
filter={null}
|
||||
value={config.tool_alias || null}
|
||||
onValueChange={(value) => onUpdate({ tool_alias: value ?? "" })}
|
||||
itemToStringValue={(value) => value}
|
||||
autoHighlight={true}
|
||||
>
|
||||
<ComboboxInput
|
||||
id={toolAliasId}
|
||||
className="nodrag w-full"
|
||||
placeholder={
|
||||
hasToolProfiles ? "Pick tool profile or type" : "No tool profiles yet"
|
||||
}
|
||||
onBlur={(event) => {
|
||||
const inputValue = event.target.value;
|
||||
if (inputValue !== (config.tool_alias ?? "")) {
|
||||
onUpdate({ tool_alias: inputValue });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<ComboboxContent anchor={toolAliasAnchorRef}>
|
||||
<ComboboxEmpty>No tool profiles found</ComboboxEmpty>
|
||||
<ComboboxList>
|
||||
{(alias: string) => (
|
||||
<ComboboxItem key={alias} value={alias}>
|
||||
{alias}
|
||||
</ComboboxItem>
|
||||
)}
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
</div>
|
||||
{!hasToolProfiles && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Add a Tool Profile block to configure MCP servers and allowed tools.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{config.llm_type === "code" && (
|
||||
<div className="grid gap-2">
|
||||
<FieldLabel
|
||||
|
|
|
|||
|
|
@ -1,19 +1,19 @@
|
|||
import { type ReactElement, useRef } from "react";
|
||||
import type { LlmConfig } from "../../types";
|
||||
import { LlmGeneralTab } from "./general-tab";
|
||||
import { LlmScoresTab } from "./scores-tab";
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "@/components/ui/tabs";
|
||||
import { type ReactElement, useRef } from "react";
|
||||
import type { LlmConfig } from "../../types";
|
||||
import { LlmGeneralTab } from "./general-tab";
|
||||
import { LlmMcpToolsTab } from "./mcp-tools-tab";
|
||||
import { LlmScoresTab } from "./scores-tab";
|
||||
|
||||
type LlmDialogProps = {
|
||||
config: LlmConfig;
|
||||
modelConfigAliases: string[];
|
||||
modelProviderOptions: string[];
|
||||
toolProfileAliases: string[];
|
||||
onUpdate: (patch: Partial<LlmConfig>) => void;
|
||||
};
|
||||
|
||||
|
|
@ -21,22 +21,36 @@ export function LlmDialog({
|
|||
config,
|
||||
modelConfigAliases,
|
||||
modelProviderOptions,
|
||||
toolProfileAliases,
|
||||
onUpdate,
|
||||
}: LlmDialogProps): ReactElement {
|
||||
const modelAliasAnchorRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
if (config.llm_type !== "judge") {
|
||||
return (
|
||||
<LlmGeneralTab
|
||||
config={config}
|
||||
modelConfigAliases={modelConfigAliases}
|
||||
modelProviderOptions={modelProviderOptions}
|
||||
toolProfileAliases={toolProfileAliases}
|
||||
modelAliasAnchorRef={modelAliasAnchorRef}
|
||||
onUpdate={onUpdate}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Tabs defaultValue="general" className="w-full">
|
||||
<TabsList className="w-full">
|
||||
<TabsTrigger value="general">General</TabsTrigger>
|
||||
{config.llm_type === "judge" && <TabsTrigger value="scores">Scores</TabsTrigger>}
|
||||
<TabsTrigger value="tools">MCPs / Tools</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="general" className="pt-3">
|
||||
<LlmGeneralTab
|
||||
config={config}
|
||||
modelConfigAliases={modelConfigAliases}
|
||||
modelProviderOptions={modelProviderOptions}
|
||||
toolProfileAliases={toolProfileAliases}
|
||||
modelAliasAnchorRef={modelAliasAnchorRef}
|
||||
onUpdate={onUpdate}
|
||||
/>
|
||||
|
|
@ -46,9 +60,6 @@ export function LlmDialog({
|
|||
<LlmScoresTab config={config} onUpdate={onUpdate} />
|
||||
</TabsContent>
|
||||
)}
|
||||
<TabsContent value="tools" className="pt-3">
|
||||
<LlmMcpToolsTab config={config} onUpdate={onUpdate} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,297 +0,0 @@
|
|||
import { type ReactElement, useMemo, useState } from "react";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import type {
|
||||
LlmConfig,
|
||||
LlmMcpProviderConfig,
|
||||
LlmToolConfig,
|
||||
McpEnvVar,
|
||||
} from "../../types";
|
||||
import { toastError } from "@/shared/toast";
|
||||
import { McpProvidersSection } from "./mcp-tools/mcp-providers-section";
|
||||
import {
|
||||
createMcpProviderId,
|
||||
createToolConfigId,
|
||||
isProviderReadyForToolFetch,
|
||||
resolveLlmToolAlias,
|
||||
toApiProvider,
|
||||
} from "./mcp-tools/helpers";
|
||||
import { ToolConfigsSection } from "./mcp-tools/tool-configs-section";
|
||||
import { FieldLabel } from "../shared/field-label";
|
||||
|
||||
type LlmMcpToolsTabProps = {
|
||||
config: LlmConfig;
|
||||
onUpdate: (patch: Partial<LlmConfig>) => void;
|
||||
};
|
||||
|
||||
const EMPTY_MCP_PROVIDERS: LlmMcpProviderConfig[] = [];
|
||||
const EMPTY_TOOL_CONFIGS: LlmToolConfig[] = [];
|
||||
|
||||
function uniqueTrimmed(values: string[]): string[] {
|
||||
return Array.from(
|
||||
new Set(values.map((value) => value.trim()).filter(Boolean)),
|
||||
);
|
||||
}
|
||||
|
||||
export function LlmMcpToolsTab({
|
||||
config,
|
||||
onUpdate,
|
||||
}: LlmMcpToolsTabProps): ReactElement {
|
||||
const providers = config.mcp_providers ?? EMPTY_MCP_PROVIDERS;
|
||||
const toolConfigs = config.tool_configs ?? EMPTY_TOOL_CONFIGS;
|
||||
const [loadingTools, setLoadingTools] = useState(false);
|
||||
const [toolsByProvider, setToolsByProvider] = useState<Record<string, string[]>>(
|
||||
{},
|
||||
);
|
||||
|
||||
function updateProviders(nextProviders: LlmMcpProviderConfig[]): void {
|
||||
onUpdate({ mcp_providers: nextProviders });
|
||||
}
|
||||
|
||||
function updateToolConfigs(nextToolConfigs: LlmToolConfig[]): void {
|
||||
onUpdate({
|
||||
tool_configs: nextToolConfigs,
|
||||
// biome-ignore lint/style/useNamingConvention: api schema
|
||||
tool_alias: resolveLlmToolAlias(nextToolConfigs, config.tool_alias),
|
||||
});
|
||||
}
|
||||
|
||||
function updateProviderAt(
|
||||
index: number,
|
||||
patch: Partial<LlmMcpProviderConfig>,
|
||||
): void {
|
||||
updateProviders(
|
||||
providers.map((provider, currentIndex) =>
|
||||
currentIndex === index ? { ...provider, ...patch } : provider,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function mutateProviderAt(
|
||||
index: number,
|
||||
mapProvider: (provider: LlmMcpProviderConfig) => Partial<LlmMcpProviderConfig>,
|
||||
): void {
|
||||
const provider = providers[index];
|
||||
if (!provider) {
|
||||
return;
|
||||
}
|
||||
updateProviderAt(index, mapProvider(provider));
|
||||
}
|
||||
|
||||
function removeProvider(index: number): void {
|
||||
updateProviders(
|
||||
providers.filter((_, currentIndex) => currentIndex !== index),
|
||||
);
|
||||
}
|
||||
|
||||
function addProvider(): void {
|
||||
updateProviders([
|
||||
...providers,
|
||||
{
|
||||
id: createMcpProviderId(config.id, providers.length),
|
||||
name: "",
|
||||
// biome-ignore lint/style/useNamingConvention: ui schema
|
||||
provider_type: "stdio",
|
||||
command: "",
|
||||
args: [""],
|
||||
env: [{ key: "", value: "" }],
|
||||
endpoint: "",
|
||||
// biome-ignore lint/style/useNamingConvention: api schema
|
||||
api_key: "",
|
||||
// biome-ignore lint/style/useNamingConvention: api schema
|
||||
api_key_env: "",
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
function addProviderArg(providerIndex: number): void {
|
||||
mutateProviderAt(providerIndex, (provider) => ({
|
||||
args: [...(provider.args ?? []), ""],
|
||||
}));
|
||||
}
|
||||
|
||||
function updateProviderArg(
|
||||
providerIndex: number,
|
||||
argIndex: number,
|
||||
value: string,
|
||||
): void {
|
||||
mutateProviderAt(providerIndex, (provider) => {
|
||||
const nextArgs = [...(provider.args ?? [])];
|
||||
nextArgs[argIndex] = value;
|
||||
return { args: nextArgs };
|
||||
});
|
||||
}
|
||||
|
||||
function removeProviderArg(providerIndex: number, argIndex: number): void {
|
||||
mutateProviderAt(providerIndex, (provider) => ({
|
||||
args: (provider.args ?? []).filter(
|
||||
(_, currentIndex) => currentIndex !== argIndex,
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
function addProviderEnv(providerIndex: number): void {
|
||||
mutateProviderAt(providerIndex, (provider) => ({
|
||||
env: [...(provider.env ?? []), { key: "", value: "" }],
|
||||
}));
|
||||
}
|
||||
|
||||
function updateProviderEnv(
|
||||
providerIndex: number,
|
||||
envIndex: number,
|
||||
patch: Partial<McpEnvVar>,
|
||||
): void {
|
||||
mutateProviderAt(providerIndex, (provider) => ({
|
||||
env: (provider.env ?? []).map((item, currentIndex) =>
|
||||
currentIndex === envIndex ? { ...item, ...patch } : item,
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
function removeProviderEnv(providerIndex: number, envIndex: number): void {
|
||||
mutateProviderAt(providerIndex, (provider) => ({
|
||||
env: (provider.env ?? []).filter(
|
||||
(_, currentIndex) => currentIndex !== envIndex,
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
function updateToolConfigAt(
|
||||
index: number,
|
||||
patch: Partial<LlmToolConfig>,
|
||||
): void {
|
||||
updateToolConfigs(
|
||||
toolConfigs.map((toolConfig, currentIndex) =>
|
||||
currentIndex === index ? { ...toolConfig, ...patch } : toolConfig,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function addToolConfig(): void {
|
||||
updateToolConfigs([
|
||||
...toolConfigs,
|
||||
{
|
||||
id: createToolConfigId(config.id, toolConfigs.length),
|
||||
// biome-ignore lint/style/useNamingConvention: api schema
|
||||
tool_alias: "",
|
||||
providers: [],
|
||||
// biome-ignore lint/style/useNamingConvention: api schema
|
||||
allow_tools: [],
|
||||
// biome-ignore lint/style/useNamingConvention: api schema
|
||||
max_tool_call_turns: "5",
|
||||
// biome-ignore lint/style/useNamingConvention: api schema
|
||||
timeout_sec: "",
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
function removeToolConfig(index: number): void {
|
||||
updateToolConfigs(
|
||||
toolConfigs.filter((_, currentIndex) => currentIndex !== index),
|
||||
);
|
||||
}
|
||||
|
||||
async function loadToolNames(): Promise<void> {
|
||||
// tools endpoint removed from harness; keep UI but disable fetch.
|
||||
const apiProviders = providers
|
||||
.filter(isProviderReadyForToolFetch)
|
||||
.map(toApiProvider)
|
||||
.filter((provider) => Boolean(provider.name));
|
||||
if (apiProviders.length === 0) {
|
||||
toastError(
|
||||
"No MCP servers configured",
|
||||
"Add server name and command/endpoint first.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
setLoadingTools(true);
|
||||
try {
|
||||
toastError("Tool fetch disabled", "Backend /tools endpoint removed.");
|
||||
setToolsByProvider({});
|
||||
} finally {
|
||||
setLoadingTools(false);
|
||||
}
|
||||
}
|
||||
|
||||
const providerNameSuggestions = useMemo(
|
||||
() => uniqueTrimmed(providers.map((provider) => provider.name)),
|
||||
[providers],
|
||||
);
|
||||
const toolAliasOptions = useMemo(
|
||||
() => uniqueTrimmed(toolConfigs.map((item) => item.tool_alias)),
|
||||
[toolConfigs],
|
||||
);
|
||||
const activeToolAlias = useMemo(() => {
|
||||
const currentAlias = config.tool_alias?.trim() ?? "";
|
||||
if (toolAliasOptions.includes(currentAlias)) {
|
||||
return currentAlias;
|
||||
}
|
||||
return toolAliasOptions[0] ?? "";
|
||||
}, [config.tool_alias, toolAliasOptions]);
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="grid gap-2">
|
||||
<FieldLabel
|
||||
label="Active tool alias"
|
||||
hint="Tool alias selected here is used by this LLM column."
|
||||
/>
|
||||
{toolAliasOptions.length > 0 ? (
|
||||
<Select
|
||||
value={activeToolAlias}
|
||||
onValueChange={(value) =>
|
||||
onUpdate({
|
||||
// biome-ignore lint/style/useNamingConvention: api schema
|
||||
tool_alias: value,
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="nodrag w-full">
|
||||
<SelectValue placeholder="Select active tool alias" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{toolAliasOptions.map((alias) => (
|
||||
<SelectItem key={alias} value={alias}>
|
||||
{alias}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Add tool config alias first.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<ToolConfigsSection
|
||||
toolConfigs={toolConfigs}
|
||||
providerNameSuggestions={providerNameSuggestions}
|
||||
toolsByProvider={toolsByProvider}
|
||||
loadingTools={loadingTools}
|
||||
onFetchTools={() => {
|
||||
void loadToolNames();
|
||||
}}
|
||||
onAddToolConfig={addToolConfig}
|
||||
onUpdateToolConfig={updateToolConfigAt}
|
||||
onRemoveToolConfig={removeToolConfig}
|
||||
/>
|
||||
<McpProvidersSection
|
||||
providers={providers}
|
||||
onAddProvider={addProvider}
|
||||
onUpdateProviderAt={updateProviderAt}
|
||||
onRemoveProvider={removeProvider}
|
||||
onAddProviderArg={addProviderArg}
|
||||
onUpdateProviderArg={updateProviderArg}
|
||||
onRemoveProviderArg={removeProviderArg}
|
||||
onAddProviderEnv={addProviderEnv}
|
||||
onUpdateProviderEnv={updateProviderEnv}
|
||||
onRemoveProviderEnv={removeProviderEnv}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,272 +0,0 @@
|
|||
import { Delete02Icon, PlusSignIcon } from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import type { LlmMcpProviderConfig, McpEnvVar } from "../../../types";
|
||||
import { FieldLabel } from "../../shared/field-label";
|
||||
|
||||
type McpProvidersSectionProps = {
|
||||
providers: LlmMcpProviderConfig[];
|
||||
onAddProvider: () => void;
|
||||
onUpdateProviderAt: (
|
||||
index: number,
|
||||
patch: Partial<LlmMcpProviderConfig>,
|
||||
) => void;
|
||||
onRemoveProvider: (index: number) => void;
|
||||
onAddProviderArg: (providerIndex: number) => void;
|
||||
onUpdateProviderArg: (
|
||||
providerIndex: number,
|
||||
argIndex: number,
|
||||
value: string,
|
||||
) => void;
|
||||
onRemoveProviderArg: (providerIndex: number, argIndex: number) => void;
|
||||
onAddProviderEnv: (providerIndex: number) => void;
|
||||
onUpdateProviderEnv: (
|
||||
providerIndex: number,
|
||||
envIndex: number,
|
||||
patch: Partial<McpEnvVar>,
|
||||
) => void;
|
||||
onRemoveProviderEnv: (providerIndex: number, envIndex: number) => void;
|
||||
};
|
||||
|
||||
export function McpProvidersSection({
|
||||
providers,
|
||||
onAddProvider,
|
||||
onUpdateProviderAt,
|
||||
onRemoveProvider,
|
||||
onAddProviderArg,
|
||||
onUpdateProviderArg,
|
||||
onRemoveProviderArg,
|
||||
onAddProviderEnv,
|
||||
onUpdateProviderEnv,
|
||||
onRemoveProviderEnv,
|
||||
}: McpProvidersSectionProps) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<FieldLabel
|
||||
label="MCP servers"
|
||||
hint="Server definitions used by tool configs for tool calls."
|
||||
/>
|
||||
<Button type="button" size="xs" variant="outline" onClick={onAddProvider}>
|
||||
<HugeiconsIcon icon={PlusSignIcon} className="size-3.5" />
|
||||
Add MCP server
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{providers.length === 0 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Add MCP servers to be referenced by tool config providers.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{providers.map((provider, providerIndex) => {
|
||||
const args = provider.args && provider.args.length > 0 ? provider.args : [""];
|
||||
const envVars =
|
||||
provider.env && provider.env.length > 0
|
||||
? provider.env
|
||||
: [{ key: "", value: "" }];
|
||||
|
||||
return (
|
||||
<div
|
||||
key={provider.id}
|
||||
className="space-y-3 border-b border-border/40 pb-4 last:border-b-0"
|
||||
>
|
||||
<div className="grid gap-2">
|
||||
<FieldLabel
|
||||
label="Name"
|
||||
hint="Unique provider name referenced by tool configs."
|
||||
/>
|
||||
<Input
|
||||
value={provider.name}
|
||||
placeholder="MCP server name"
|
||||
onChange={(event) =>
|
||||
onUpdateProviderAt(providerIndex, { name: event.target.value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Tabs
|
||||
value={provider.provider_type}
|
||||
onValueChange={(value) =>
|
||||
onUpdateProviderAt(providerIndex, {
|
||||
// biome-ignore lint/style/useNamingConvention: ui schema
|
||||
provider_type: value === "stdio" ? "stdio" : "streamable_http",
|
||||
})
|
||||
}
|
||||
>
|
||||
<TabsList className="w-full">
|
||||
<TabsTrigger value="stdio">STDIO</TabsTrigger>
|
||||
<TabsTrigger value="streamable_http">Streamable HTTP</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
{provider.provider_type === "stdio" ? (
|
||||
<div className="space-y-3">
|
||||
<div className="grid gap-2">
|
||||
<FieldLabel
|
||||
label="Command to launch"
|
||||
hint="Executable used to start stdio MCP server."
|
||||
/>
|
||||
<Input
|
||||
value={provider.command ?? ""}
|
||||
placeholder="npx"
|
||||
onChange={(event) =>
|
||||
onUpdateProviderAt(providerIndex, {
|
||||
command: event.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<FieldLabel
|
||||
label="Arguments"
|
||||
hint="CLI args passed to MCP command."
|
||||
/>
|
||||
{args.map((arg, argIndex) => (
|
||||
<div key={`${provider.id}-arg-${argIndex}`} className="flex gap-2">
|
||||
<Input
|
||||
value={arg}
|
||||
placeholder={argIndex === 0 ? "-y" : "argument"}
|
||||
onChange={(event) =>
|
||||
onUpdateProviderArg(providerIndex, argIndex, event.target.value)
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
onClick={() => onRemoveProviderArg(providerIndex, argIndex)}
|
||||
>
|
||||
<HugeiconsIcon icon={Delete02Icon} className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => onAddProviderArg(providerIndex)}
|
||||
>
|
||||
<HugeiconsIcon icon={PlusSignIcon} className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<FieldLabel
|
||||
label="Environment variables"
|
||||
hint="Key/value env vars for MCP process."
|
||||
/>
|
||||
{envVars.map((item, envIndex) => (
|
||||
<div
|
||||
key={`${provider.id}-env-${envIndex}`}
|
||||
className="grid grid-cols-[1fr_1fr_auto] gap-2"
|
||||
>
|
||||
<Input
|
||||
value={item.key}
|
||||
placeholder="Key"
|
||||
onChange={(event) =>
|
||||
onUpdateProviderEnv(providerIndex, envIndex, {
|
||||
key: event.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
value={item.value}
|
||||
placeholder="Value"
|
||||
onChange={(event) =>
|
||||
onUpdateProviderEnv(providerIndex, envIndex, {
|
||||
value: event.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
onClick={() => onRemoveProviderEnv(providerIndex, envIndex)}
|
||||
>
|
||||
<HugeiconsIcon icon={Delete02Icon} className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => onAddProviderEnv(providerIndex)}
|
||||
>
|
||||
<HugeiconsIcon icon={PlusSignIcon} className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<div className="grid gap-2">
|
||||
<FieldLabel
|
||||
label="Endpoint"
|
||||
hint="Streamable HTTP MCP server URL."
|
||||
/>
|
||||
<Input
|
||||
value={provider.endpoint ?? ""}
|
||||
placeholder="https://example.com/mcp"
|
||||
onChange={(event) =>
|
||||
onUpdateProviderAt(providerIndex, {
|
||||
endpoint: event.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<FieldLabel
|
||||
label="API key env (optional)"
|
||||
hint="Env var name for endpoint auth token."
|
||||
/>
|
||||
<Input
|
||||
value={provider.api_key_env ?? ""}
|
||||
placeholder="MCP_API_KEY"
|
||||
onChange={(event) =>
|
||||
onUpdateProviderAt(providerIndex, {
|
||||
// biome-ignore lint/style/useNamingConvention: api schema
|
||||
api_key_env: event.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<FieldLabel
|
||||
label="API key (optional)"
|
||||
hint="Inline token for endpoint auth."
|
||||
/>
|
||||
<Input
|
||||
value={provider.api_key ?? ""}
|
||||
placeholder="api key"
|
||||
onChange={(event) =>
|
||||
onUpdateProviderAt(providerIndex, {
|
||||
// biome-ignore lint/style/useNamingConvention: api schema
|
||||
api_key: event.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
onClick={() => onRemoveProvider(providerIndex)}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,182 +0,0 @@
|
|||
import { PlusSignIcon } from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ChipInput } from "../../../components/chip-input";
|
||||
import type { LlmToolConfig } from "../../../types";
|
||||
import { addUnique, collectToolSuggestions } from "./helpers";
|
||||
import { FieldLabel } from "../../shared/field-label";
|
||||
|
||||
type ToolConfigsSectionProps = {
|
||||
toolConfigs: LlmToolConfig[];
|
||||
providerNameSuggestions: string[];
|
||||
toolsByProvider: Record<string, string[]>;
|
||||
loadingTools: boolean;
|
||||
onFetchTools: () => void;
|
||||
onAddToolConfig: () => void;
|
||||
onUpdateToolConfig: (index: number, patch: Partial<LlmToolConfig>) => void;
|
||||
onRemoveToolConfig: (index: number) => void;
|
||||
};
|
||||
|
||||
export function ToolConfigsSection({
|
||||
toolConfigs,
|
||||
providerNameSuggestions,
|
||||
toolsByProvider,
|
||||
loadingTools,
|
||||
onFetchTools,
|
||||
onAddToolConfig,
|
||||
onUpdateToolConfig,
|
||||
onRemoveToolConfig,
|
||||
}: ToolConfigsSectionProps) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<FieldLabel
|
||||
label="Tool configs"
|
||||
hint="Map a tool alias to MCP providers and allowed tools."
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="xs"
|
||||
variant="outline"
|
||||
disabled={loadingTools}
|
||||
onClick={onFetchTools}
|
||||
>
|
||||
{loadingTools ? "Loading..." : "Fetch MCP tools"}
|
||||
</Button>
|
||||
<Button type="button" size="xs" variant="outline" onClick={onAddToolConfig}>
|
||||
<HugeiconsIcon icon={PlusSignIcon} className="size-3.5" />
|
||||
Add tool config
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Define aliases/providers here. Active alias is selected above.
|
||||
</p>
|
||||
{toolConfigs.length === 0 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Add at least one tool config to map alias to providers.
|
||||
</p>
|
||||
)}
|
||||
{toolConfigs.map((toolConfig, index) => (
|
||||
<div
|
||||
key={toolConfig.id}
|
||||
className="space-y-3 border-b border-border/40 pb-4 last:border-b-0"
|
||||
>
|
||||
<div className="grid gap-2">
|
||||
<FieldLabel
|
||||
label="Tool alias"
|
||||
hint="Alias referenced by LLM column tool_alias."
|
||||
/>
|
||||
<Input
|
||||
value={toolConfig.tool_alias}
|
||||
placeholder="context7_tools"
|
||||
onChange={(event) =>
|
||||
onUpdateToolConfig(index, {
|
||||
// biome-ignore lint/style/useNamingConvention: api schema
|
||||
tool_alias: event.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<FieldLabel
|
||||
label="Providers"
|
||||
hint="MCP provider names this alias can call."
|
||||
/>
|
||||
<ChipInput
|
||||
values={toolConfig.providers}
|
||||
suggestions={providerNameSuggestions}
|
||||
onAdd={(value) =>
|
||||
onUpdateToolConfig(index, {
|
||||
providers: addUnique(toolConfig.providers, value),
|
||||
})
|
||||
}
|
||||
onRemove={(providerIndex) =>
|
||||
onUpdateToolConfig(index, {
|
||||
providers: toolConfig.providers.filter(
|
||||
(_, currentIndex) => currentIndex !== providerIndex,
|
||||
),
|
||||
})
|
||||
}
|
||||
placeholder="Type provider name and press Enter"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<FieldLabel
|
||||
label="Allow tools (optional)"
|
||||
hint="Optional allowlist of tool names."
|
||||
/>
|
||||
<ChipInput
|
||||
// biome-ignore lint/style/useNamingConvention: api schema
|
||||
values={toolConfig.allow_tools ?? []}
|
||||
suggestions={collectToolSuggestions(toolConfig.providers, toolsByProvider)}
|
||||
onAdd={(value) =>
|
||||
onUpdateToolConfig(index, {
|
||||
// biome-ignore lint/style/useNamingConvention: api schema
|
||||
allow_tools: addUnique(toolConfig.allow_tools ?? [], value),
|
||||
})
|
||||
}
|
||||
onRemove={(toolIndex) =>
|
||||
onUpdateToolConfig(index, {
|
||||
// biome-ignore lint/style/useNamingConvention: api schema
|
||||
allow_tools: (toolConfig.allow_tools ?? []).filter(
|
||||
(_, currentIndex) => currentIndex !== toolIndex,
|
||||
),
|
||||
})
|
||||
}
|
||||
placeholder="Type tool name and press Enter"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="grid gap-2">
|
||||
<FieldLabel
|
||||
label="Max turns"
|
||||
hint="Max tool-calling turns before forcing completion."
|
||||
/>
|
||||
<Input
|
||||
value={toolConfig.max_tool_call_turns ?? ""}
|
||||
onChange={(event) =>
|
||||
onUpdateToolConfig(index, {
|
||||
// biome-ignore lint/style/useNamingConvention: api schema
|
||||
max_tool_call_turns: event.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<FieldLabel
|
||||
label="Timeout sec"
|
||||
hint="Timeout per tool call."
|
||||
/>
|
||||
<Input
|
||||
value={toolConfig.timeout_sec ?? ""}
|
||||
onChange={(event) =>
|
||||
onUpdateToolConfig(index, {
|
||||
// biome-ignore lint/style/useNamingConvention: api schema
|
||||
timeout_sec: event.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
onClick={() => onRemoveToolConfig(index)}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -423,13 +423,13 @@ export function SeedDialog({ config, onUpdate, open }: SeedDialogProps): ReactEl
|
|||
);
|
||||
|
||||
return (
|
||||
<Tabs defaultValue="config" className="w-full">
|
||||
<Tabs defaultValue="config" className="w-full min-w-0">
|
||||
<TabsList className="w-full">
|
||||
<TabsTrigger value="config">Config</TabsTrigger>
|
||||
<TabsTrigger value="preview">Preview</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="config" className="pt-3">
|
||||
<TabsContent value="config" className="min-w-0 pt-3">
|
||||
<div className="space-y-4">
|
||||
{mode === "hf" && (
|
||||
<>
|
||||
|
|
@ -774,7 +774,7 @@ export function SeedDialog({ config, onUpdate, open }: SeedDialogProps): ReactEl
|
|||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="preview" className="pt-3">
|
||||
<TabsContent value="preview" className="min-w-0 pt-3">
|
||||
<div className="space-y-4">
|
||||
{previewRows.length === 0 ? (
|
||||
<div className="flex w-full items-center justify-center">
|
||||
|
|
@ -795,8 +795,8 @@ export function SeedDialog({ config, onUpdate, open }: SeedDialogProps): ReactEl
|
|||
<div className="text-xs text-muted-foreground">
|
||||
Loaded columns: {previewColumns.join(", ") || "None"}
|
||||
</div>
|
||||
<div className="max-h-[360px] overflow-auto rounded-xl corner-squircle border border-border/60">
|
||||
<Table className="corner-squircle">
|
||||
<div className="max-h-[360px] overflow-y-auto overflow-x-hidden rounded-xl corner-squircle border border-border/60">
|
||||
<Table className="corner-squircle min-w-max">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{previewColumns.map((col) => (
|
||||
|
|
|
|||
|
|
@ -1,13 +1,9 @@
|
|||
import type { LlmMcpProviderConfig, LlmToolConfig } from "../../../types";
|
||||
import type { LlmMcpProviderConfig } from "../../types";
|
||||
|
||||
export function createMcpProviderId(prefix: string, index: number): string {
|
||||
return `${prefix}-mcp-${Date.now()}-${index + 1}`;
|
||||
}
|
||||
|
||||
export function createToolConfigId(prefix: string, index: number): string {
|
||||
return `${prefix}-tool-${Date.now()}-${index + 1}`;
|
||||
}
|
||||
|
||||
export function addUnique(items: string[], value: string): string[] {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || items.includes(trimmed)) {
|
||||
|
|
@ -16,6 +12,32 @@ export function addUnique(items: string[], value: string): string[] {
|
|||
return [...items, trimmed];
|
||||
}
|
||||
|
||||
export function collectToolSuggestions(
|
||||
providerNames: string[],
|
||||
toolsByProvider: Record<string, string[]>,
|
||||
): string[] {
|
||||
return Array.from(
|
||||
new Set(
|
||||
providerNames.flatMap(
|
||||
(providerName) => toolsByProvider[providerName.trim()] ?? [],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function isProviderReadyForToolFetch(
|
||||
provider: LlmMcpProviderConfig,
|
||||
): boolean {
|
||||
const hasName = provider.name.trim().length > 0;
|
||||
if (!hasName) {
|
||||
return false;
|
||||
}
|
||||
if (provider.provider_type === "stdio") {
|
||||
return (provider.command?.trim().length ?? 0) > 0;
|
||||
}
|
||||
return (provider.endpoint?.trim().length ?? 0) > 0;
|
||||
}
|
||||
|
||||
export function toApiProvider(
|
||||
provider: LlmMcpProviderConfig,
|
||||
): Record<string, unknown> {
|
||||
|
|
@ -45,43 +67,3 @@ export function toApiProvider(
|
|||
api_key_env: provider.api_key_env?.trim() || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function collectToolSuggestions(
|
||||
providerNames: string[],
|
||||
toolsByProvider: Record<string, string[]>,
|
||||
): string[] {
|
||||
return Array.from(
|
||||
new Set(
|
||||
providerNames.flatMap((providerName) => {
|
||||
return toolsByProvider[providerName.trim()] ?? [];
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function isProviderReadyForToolFetch(
|
||||
provider: LlmMcpProviderConfig,
|
||||
): boolean {
|
||||
const hasName = provider.name.trim().length > 0;
|
||||
if (!hasName) {
|
||||
return false;
|
||||
}
|
||||
if (provider.provider_type === "stdio") {
|
||||
return (provider.command?.trim().length ?? 0) > 0;
|
||||
}
|
||||
return (provider.endpoint?.trim().length ?? 0) > 0;
|
||||
}
|
||||
|
||||
export function resolveLlmToolAlias(
|
||||
toolConfigs: LlmToolConfig[],
|
||||
previousAlias: string | undefined,
|
||||
): string {
|
||||
const toolAliases = toolConfigs
|
||||
.map((item) => item.tool_alias.trim())
|
||||
.filter(Boolean);
|
||||
const currentAlias = previousAlias?.trim() ?? "";
|
||||
if (currentAlias && toolAliases.includes(currentAlias)) {
|
||||
return currentAlias;
|
||||
}
|
||||
return toolAliases[0] ?? "";
|
||||
}
|
||||
|
|
@ -0,0 +1,800 @@
|
|||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { toastError } from "@/shared/toast";
|
||||
import {
|
||||
ArrowRight01Icon,
|
||||
Delete02Icon,
|
||||
PlusSignIcon,
|
||||
} from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { type ReactElement, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { listMcpTools } from "../../api";
|
||||
import { ChipInput } from "../../components/chip-input";
|
||||
import type { LlmMcpProviderConfig, McpEnvVar, ToolProfileConfig } from "../../types";
|
||||
import { FieldLabel } from "../shared/field-label";
|
||||
import { NameField } from "../shared/name-field";
|
||||
import {
|
||||
addUnique,
|
||||
collectToolSuggestions,
|
||||
createMcpProviderId,
|
||||
isProviderReadyForToolFetch,
|
||||
toApiProvider,
|
||||
} from "./helpers";
|
||||
|
||||
type ToolProfileDialogProps = {
|
||||
config: ToolProfileConfig;
|
||||
onUpdate: (patch: Partial<ToolProfileConfig>) => void;
|
||||
};
|
||||
|
||||
function EmptyState({
|
||||
title,
|
||||
description,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
}): ReactElement {
|
||||
return (
|
||||
<div className="rounded-2xl border border-dashed border-border/70 bg-muted/15 px-4 py-5 text-sm">
|
||||
<p className="font-semibold text-foreground">{title}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function isProviderConfigured(provider: LlmMcpProviderConfig): boolean {
|
||||
const hasName = provider.name.trim().length > 0;
|
||||
if (!hasName) {
|
||||
return false;
|
||||
}
|
||||
if (provider.provider_type === "stdio") {
|
||||
return (provider.command?.trim().length ?? 0) > 0;
|
||||
}
|
||||
return (provider.endpoint?.trim().length ?? 0) > 0;
|
||||
}
|
||||
|
||||
function McpServerCard({
|
||||
provider,
|
||||
index,
|
||||
toolsCount,
|
||||
error,
|
||||
open,
|
||||
onOpenChange,
|
||||
onUpdateProviderAt,
|
||||
onRemoveProvider,
|
||||
onAddProviderArg,
|
||||
onUpdateProviderArg,
|
||||
onRemoveProviderArg,
|
||||
onAddProviderEnv,
|
||||
onUpdateProviderEnv,
|
||||
onRemoveProviderEnv,
|
||||
}: {
|
||||
provider: LlmMcpProviderConfig;
|
||||
index: number;
|
||||
toolsCount?: number;
|
||||
error?: string;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onUpdateProviderAt: (
|
||||
index: number,
|
||||
patch: Partial<LlmMcpProviderConfig>,
|
||||
) => void;
|
||||
onRemoveProvider: (index: number) => void;
|
||||
onAddProviderArg: (index: number) => void;
|
||||
onUpdateProviderArg: (index: number, argIndex: number, value: string) => void;
|
||||
onRemoveProviderArg: (index: number, argIndex: number) => void;
|
||||
onAddProviderEnv: (index: number) => void;
|
||||
onUpdateProviderEnv: (
|
||||
index: number,
|
||||
envIndex: number,
|
||||
patch: Partial<McpEnvVar>,
|
||||
) => void;
|
||||
onRemoveProviderEnv: (index: number, envIndex: number) => void;
|
||||
}): ReactElement {
|
||||
const args = provider.args && provider.args.length > 0 ? provider.args : [""];
|
||||
const envVars =
|
||||
provider.env && provider.env.length > 0
|
||||
? provider.env
|
||||
: [{ key: "", value: "" }];
|
||||
const summaryTitle = provider.name.trim() || `MCP server ${index + 1}`;
|
||||
const transportLabel =
|
||||
provider.provider_type === "stdio" ? "STDIO" : "Streamable HTTP";
|
||||
const toolsLabel = typeof toolsCount === "number" ? `${toolsCount} tools` : null;
|
||||
const description =
|
||||
provider.provider_type === "stdio"
|
||||
? "Launches a local MCP process over stdio."
|
||||
: "Calls a remote MCP endpoint from the backend.";
|
||||
|
||||
return (
|
||||
<Collapsible open={open} onOpenChange={onOpenChange}>
|
||||
<div className="rounded-2xl border border-border/60 bg-background/80">
|
||||
<div className="flex items-start gap-2 px-4 py-4">
|
||||
<CollapsibleTrigger asChild={true}>
|
||||
<button
|
||||
type="button"
|
||||
className="flex min-w-0 flex-1 items-start gap-3 text-left"
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={ArrowRight01Icon}
|
||||
className={`mt-0.5 size-4 shrink-0 text-muted-foreground transition-transform ${
|
||||
open ? "rotate-90" : ""
|
||||
}`}
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="truncate text-sm font-semibold text-foreground">
|
||||
{summaryTitle}
|
||||
</p>
|
||||
<Badge variant="outline" className="rounded-full text-[10px] uppercase">
|
||||
{transportLabel}
|
||||
</Badge>
|
||||
{toolsLabel ? (
|
||||
<Badge variant="secondary" className="rounded-full text-[10px]">
|
||||
{toolsLabel}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
onClick={() => onRemoveProvider(index)}
|
||||
>
|
||||
<HugeiconsIcon icon={Delete02Icon} className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<CollapsibleContent className="space-y-4 border-t border-border/50 px-4 pt-4 pb-4">
|
||||
{error && (
|
||||
<div className="rounded-xl border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-2">
|
||||
<FieldLabel label="Server name" hint="Unique name inside this tool profile." />
|
||||
<Input
|
||||
className="nodrag"
|
||||
value={provider.name}
|
||||
placeholder="context7"
|
||||
onChange={(event) =>
|
||||
onUpdateProviderAt(index, { name: event.target.value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Tabs
|
||||
value={provider.provider_type}
|
||||
onValueChange={(value) =>
|
||||
onUpdateProviderAt(index, {
|
||||
// biome-ignore lint/style/useNamingConvention: ui schema
|
||||
provider_type: value === "stdio" ? "stdio" : "streamable_http",
|
||||
})
|
||||
}
|
||||
>
|
||||
<TabsList className="w-full">
|
||||
<TabsTrigger value="stdio">STDIO</TabsTrigger>
|
||||
<TabsTrigger value="streamable_http">Streamable HTTP</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
{provider.provider_type === "stdio" ? (
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-2">
|
||||
<FieldLabel label="Command" hint="Executable used to start the MCP server." />
|
||||
<Input
|
||||
className="nodrag"
|
||||
value={provider.command ?? ""}
|
||||
placeholder="npx"
|
||||
onChange={(event) =>
|
||||
onUpdateProviderAt(index, { command: event.target.value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<FieldLabel label="Args" hint="Optional CLI args." />
|
||||
<Button
|
||||
type="button"
|
||||
size="xs"
|
||||
variant="outline"
|
||||
onClick={() => onAddProviderArg(index)}
|
||||
>
|
||||
<HugeiconsIcon icon={PlusSignIcon} className="size-3.5" />
|
||||
Add arg
|
||||
</Button>
|
||||
</div>
|
||||
{args.map((arg, argIndex) => (
|
||||
<div key={`${provider.id}-arg-${argIndex}`} className="flex gap-2">
|
||||
<Input
|
||||
className="nodrag"
|
||||
value={arg}
|
||||
placeholder={argIndex === 0 ? "-y" : "argument"}
|
||||
onChange={(event) =>
|
||||
onUpdateProviderArg(index, argIndex, event.target.value)
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
onClick={() => onRemoveProviderArg(index, argIndex)}
|
||||
>
|
||||
<HugeiconsIcon icon={Delete02Icon} className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<FieldLabel label="Env vars" hint="Optional process env." />
|
||||
<Button
|
||||
type="button"
|
||||
size="xs"
|
||||
variant="outline"
|
||||
onClick={() => onAddProviderEnv(index)}
|
||||
>
|
||||
<HugeiconsIcon icon={PlusSignIcon} className="size-3.5" />
|
||||
Add env
|
||||
</Button>
|
||||
</div>
|
||||
{envVars.map((item, envIndex) => (
|
||||
<div
|
||||
key={`${provider.id}-env-${envIndex}`}
|
||||
className="grid grid-cols-[1fr_1fr_auto] gap-2"
|
||||
>
|
||||
<Input
|
||||
className="nodrag"
|
||||
value={item.key}
|
||||
placeholder="KEY"
|
||||
onChange={(event) =>
|
||||
onUpdateProviderEnv(index, envIndex, {
|
||||
key: event.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
className="nodrag"
|
||||
value={item.value}
|
||||
placeholder="value"
|
||||
onChange={(event) =>
|
||||
onUpdateProviderEnv(index, envIndex, {
|
||||
value: event.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
onClick={() => onRemoveProviderEnv(index, envIndex)}
|
||||
>
|
||||
<HugeiconsIcon icon={Delete02Icon} className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-2">
|
||||
<FieldLabel label="Endpoint" hint="Backend calls this MCP URL." />
|
||||
<Input
|
||||
className="nodrag"
|
||||
value={provider.endpoint ?? ""}
|
||||
placeholder="https://example.com/mcp"
|
||||
onChange={(event) =>
|
||||
onUpdateProviderAt(index, { endpoint: event.target.value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<div className="grid gap-2">
|
||||
<FieldLabel
|
||||
label="API key env"
|
||||
hint="Optional env var used on the backend."
|
||||
/>
|
||||
<Input
|
||||
className="nodrag"
|
||||
value={provider.api_key_env ?? ""}
|
||||
placeholder="MCP_API_KEY"
|
||||
onChange={(event) =>
|
||||
onUpdateProviderAt(index, {
|
||||
// biome-ignore lint/style/useNamingConvention: api schema
|
||||
api_key_env: event.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<FieldLabel
|
||||
label="API key"
|
||||
hint="Optional inline token."
|
||||
/>
|
||||
<Input
|
||||
className="nodrag"
|
||||
value={provider.api_key ?? ""}
|
||||
placeholder="token"
|
||||
onChange={(event) =>
|
||||
onUpdateProviderAt(index, {
|
||||
// biome-ignore lint/style/useNamingConvention: api schema
|
||||
api_key: event.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CollapsibleContent>
|
||||
</div>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
|
||||
export function ToolProfileDialog({
|
||||
config,
|
||||
onUpdate,
|
||||
}: ToolProfileDialogProps): ReactElement {
|
||||
const providers = config.mcp_providers;
|
||||
const [loadingTools, setLoadingTools] = useState(false);
|
||||
const [toolsByProvider, setToolsByProvider] = useState<Record<string, string[]>>(
|
||||
config.fetched_tools_by_provider ?? {},
|
||||
);
|
||||
const [providerErrors, setProviderErrors] = useState<Record<string, string>>({});
|
||||
const [duplicateTools, setDuplicateTools] = useState<Record<string, string[]>>({});
|
||||
const [openProviders, setOpenProviders] = useState<Record<string, boolean>>({});
|
||||
const previousProviderSignatureRef = useRef<string | null>(null);
|
||||
|
||||
const providerSignature = useMemo(
|
||||
() =>
|
||||
JSON.stringify(
|
||||
providers.map((provider) => ({
|
||||
name: provider.name,
|
||||
// biome-ignore lint/style/useNamingConvention: ui schema
|
||||
provider_type: provider.provider_type,
|
||||
command: provider.command,
|
||||
args: provider.args,
|
||||
env: provider.env,
|
||||
endpoint: provider.endpoint,
|
||||
// biome-ignore lint/style/useNamingConvention: api schema
|
||||
api_key: provider.api_key,
|
||||
// biome-ignore lint/style/useNamingConvention: api schema
|
||||
api_key_env: provider.api_key_env,
|
||||
})),
|
||||
),
|
||||
[providers],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const previousSignature = previousProviderSignatureRef.current;
|
||||
previousProviderSignatureRef.current = providerSignature;
|
||||
if (previousSignature === null) {
|
||||
setToolsByProvider(config.fetched_tools_by_provider ?? {});
|
||||
return;
|
||||
}
|
||||
if (previousSignature === providerSignature) {
|
||||
return;
|
||||
}
|
||||
setToolsByProvider({});
|
||||
setProviderErrors({});
|
||||
setDuplicateTools({});
|
||||
if (Object.keys(config.fetched_tools_by_provider ?? {}).length > 0) {
|
||||
onUpdate({
|
||||
// biome-ignore lint/style/useNamingConvention: ui schema
|
||||
fetched_tools_by_provider: {},
|
||||
});
|
||||
}
|
||||
}, [config.fetched_tools_by_provider, onUpdate, providerSignature]);
|
||||
|
||||
useEffect(() => {
|
||||
const tools = config.fetched_tools_by_provider ?? {};
|
||||
setToolsByProvider(tools);
|
||||
}, [config.fetched_tools_by_provider]);
|
||||
|
||||
useEffect(() => {
|
||||
setOpenProviders((current) => {
|
||||
const next: Record<string, boolean> = {};
|
||||
for (const provider of providers) {
|
||||
next[provider.id] =
|
||||
current[provider.id] ?? !isProviderConfigured(provider);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [providers]);
|
||||
|
||||
function updateProviders(nextProviders: LlmMcpProviderConfig[]): void {
|
||||
onUpdate({
|
||||
// biome-ignore lint/style/useNamingConvention: ui schema
|
||||
mcp_providers: nextProviders,
|
||||
});
|
||||
}
|
||||
|
||||
function updateProviderAt(
|
||||
index: number,
|
||||
patch: Partial<LlmMcpProviderConfig>,
|
||||
): void {
|
||||
updateProviders(
|
||||
providers.map((provider, currentIndex) =>
|
||||
currentIndex === index ? { ...provider, ...patch } : provider,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function mutateProviderAt(
|
||||
index: number,
|
||||
mapProvider: (provider: LlmMcpProviderConfig) => Partial<LlmMcpProviderConfig>,
|
||||
): void {
|
||||
const provider = providers[index];
|
||||
if (!provider) {
|
||||
return;
|
||||
}
|
||||
updateProviderAt(index, mapProvider(provider));
|
||||
}
|
||||
|
||||
function removeProvider(index: number): void {
|
||||
updateProviders(providers.filter((_, currentIndex) => currentIndex !== index));
|
||||
}
|
||||
|
||||
function addProvider(): void {
|
||||
updateProviders([
|
||||
...providers,
|
||||
{
|
||||
id: createMcpProviderId(config.id, providers.length),
|
||||
name: "",
|
||||
// biome-ignore lint/style/useNamingConvention: ui schema
|
||||
provider_type: "stdio",
|
||||
command: "",
|
||||
args: [],
|
||||
env: [],
|
||||
endpoint: "",
|
||||
// biome-ignore lint/style/useNamingConvention: api schema
|
||||
api_key: "",
|
||||
// biome-ignore lint/style/useNamingConvention: api schema
|
||||
api_key_env: "",
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
function addProviderArg(providerIndex: number): void {
|
||||
mutateProviderAt(providerIndex, (provider) => ({
|
||||
args: [...(provider.args ?? []), ""],
|
||||
}));
|
||||
}
|
||||
|
||||
function updateProviderArg(
|
||||
providerIndex: number,
|
||||
argIndex: number,
|
||||
value: string,
|
||||
): void {
|
||||
mutateProviderAt(providerIndex, (provider) => {
|
||||
const nextArgs =
|
||||
provider.args && provider.args.length > 0 ? [...provider.args] : [""];
|
||||
nextArgs[argIndex] = value;
|
||||
return { args: nextArgs };
|
||||
});
|
||||
}
|
||||
|
||||
function removeProviderArg(providerIndex: number, argIndex: number): void {
|
||||
mutateProviderAt(providerIndex, (provider) => ({
|
||||
args: (provider.args ?? []).filter((_, currentIndex) => currentIndex !== argIndex),
|
||||
}));
|
||||
}
|
||||
|
||||
function addProviderEnv(providerIndex: number): void {
|
||||
mutateProviderAt(providerIndex, (provider) => ({
|
||||
env: [...(provider.env ?? []), { key: "", value: "" }],
|
||||
}));
|
||||
}
|
||||
|
||||
function updateProviderEnv(
|
||||
providerIndex: number,
|
||||
envIndex: number,
|
||||
patch: Partial<McpEnvVar>,
|
||||
): void {
|
||||
mutateProviderAt(providerIndex, (provider) => ({
|
||||
env: (
|
||||
provider.env && provider.env.length > 0
|
||||
? provider.env
|
||||
: [{ key: "", value: "" }]
|
||||
).map((item, currentIndex) =>
|
||||
currentIndex === envIndex ? { ...item, ...patch } : item,
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
function removeProviderEnv(providerIndex: number, envIndex: number): void {
|
||||
mutateProviderAt(providerIndex, (provider) => ({
|
||||
env: (provider.env ?? []).filter((_, currentIndex) => currentIndex !== envIndex),
|
||||
}));
|
||||
}
|
||||
|
||||
async function loadTools(): Promise<void> {
|
||||
const readyProviders = providers.filter(isProviderReadyForToolFetch);
|
||||
if (readyProviders.length === 0) {
|
||||
toastError(
|
||||
"No MCP servers ready",
|
||||
"Add a server name plus command or endpoint first.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoadingTools(true);
|
||||
try {
|
||||
const timeoutRaw = config.timeout_sec?.trim();
|
||||
const timeoutSec =
|
||||
timeoutRaw && Number.isFinite(Number(timeoutRaw))
|
||||
? Number(timeoutRaw)
|
||||
: 15;
|
||||
const response = await listMcpTools({
|
||||
// biome-ignore lint/style/useNamingConvention: api schema
|
||||
mcp_providers: readyProviders.map(toApiProvider),
|
||||
// biome-ignore lint/style/useNamingConvention: api schema
|
||||
timeout_sec: timeoutSec,
|
||||
});
|
||||
const nextToolsByProvider = Object.fromEntries(
|
||||
response.providers
|
||||
.filter((provider) => provider.name.trim())
|
||||
.map((provider) => [provider.name.trim(), provider.tools]),
|
||||
);
|
||||
setToolsByProvider(nextToolsByProvider);
|
||||
onUpdate({
|
||||
// biome-ignore lint/style/useNamingConvention: ui schema
|
||||
fetched_tools_by_provider: nextToolsByProvider,
|
||||
});
|
||||
setProviderErrors(
|
||||
Object.fromEntries(
|
||||
response.providers
|
||||
.filter((provider) => provider.name.trim() && provider.error)
|
||||
.map((provider) => [provider.name.trim(), provider.error ?? "Failed to load tools."]),
|
||||
),
|
||||
);
|
||||
setDuplicateTools(response.duplicate_tools ?? {});
|
||||
} catch (error) {
|
||||
toastError(
|
||||
"Failed to load tools",
|
||||
error instanceof Error ? error.message : "Could not load MCP tools.",
|
||||
);
|
||||
} finally {
|
||||
setLoadingTools(false);
|
||||
}
|
||||
}
|
||||
|
||||
const providerNames = useMemo(
|
||||
() =>
|
||||
Array.from(
|
||||
new Set(providers.map((provider) => provider.name.trim()).filter(Boolean)),
|
||||
),
|
||||
[providers],
|
||||
);
|
||||
const availableTools = useMemo(
|
||||
() => collectToolSuggestions(providerNames, toolsByProvider),
|
||||
[providerNames, toolsByProvider],
|
||||
);
|
||||
const hasProviders = providers.length > 0;
|
||||
|
||||
return (
|
||||
<Tabs defaultValue="profile" className="w-full">
|
||||
<TabsList className="w-full">
|
||||
<TabsTrigger value="profile">Profile</TabsTrigger>
|
||||
<TabsTrigger value="servers">MCP servers</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="profile" className="space-y-4 pt-3">
|
||||
<NameField
|
||||
label="Tool profile name"
|
||||
value={config.name}
|
||||
onChange={(value) => onUpdate({ name: value })}
|
||||
/>
|
||||
|
||||
{!hasProviders ? (
|
||||
<EmptyState
|
||||
title="Add MCP server to configure tools"
|
||||
description="This profile becomes useful after at least one MCP server is configured in the MCP servers tab."
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<FieldLabel
|
||||
label="Configured servers"
|
||||
hint="All servers in this profile are available to any LLM using this tool profile."
|
||||
/>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{providerNames.map((providerName) => (
|
||||
<Badge key={providerName} variant="secondary" className="rounded-full">
|
||||
{providerName}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 rounded-2xl border border-border/60 bg-muted/10 p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-foreground">
|
||||
Available tool refs
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Load tools from backend so users pick tool names instead of guessing.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
size="xs"
|
||||
variant="outline"
|
||||
disabled={loadingTools}
|
||||
onClick={() => {
|
||||
void loadTools();
|
||||
}}
|
||||
>
|
||||
{loadingTools ? "Loading..." : "Load tools"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{Object.keys(toolsByProvider).length === 0 &&
|
||||
Object.keys(providerErrors).length === 0 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
No tools loaded yet.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{Object.entries(toolsByProvider).map(([providerName, toolNames]) => (
|
||||
<div key={providerName} className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-xs font-semibold uppercase text-muted-foreground">
|
||||
{providerName}
|
||||
</p>
|
||||
<Badge variant="outline" className="rounded-full text-[10px]">
|
||||
{toolNames.length}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{toolNames.map((toolName) => (
|
||||
<Badge key={`${providerName}-${toolName}`} variant="secondary">
|
||||
{toolName}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{Object.entries(duplicateTools).length > 0 && (
|
||||
<div className="rounded-xl border border-amber-500/30 bg-amber-500/5 px-3 py-2 text-xs text-amber-700 dark:text-amber-300">
|
||||
Duplicate tool names across servers:
|
||||
{" "}
|
||||
{Object.entries(duplicateTools)
|
||||
.map(([toolName, providerList]) => `${toolName} (${providerList.join(", ")})`)
|
||||
.join("; ")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<FieldLabel
|
||||
label="Allow tools (optional)"
|
||||
hint="Leave empty to allow all tools from configured MCP servers."
|
||||
/>
|
||||
<ChipInput
|
||||
values={config.allow_tools ?? []}
|
||||
suggestions={availableTools}
|
||||
onAdd={(value) =>
|
||||
onUpdate({
|
||||
// biome-ignore lint/style/useNamingConvention: api schema
|
||||
allow_tools: addUnique(config.allow_tools ?? [], value),
|
||||
})
|
||||
}
|
||||
onRemove={(toolIndex) =>
|
||||
onUpdate({
|
||||
// biome-ignore lint/style/useNamingConvention: api schema
|
||||
allow_tools: (config.allow_tools ?? []).filter(
|
||||
(_, currentIndex) => currentIndex !== toolIndex,
|
||||
),
|
||||
})
|
||||
}
|
||||
placeholder="Type tool name and press Enter"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="grid gap-2">
|
||||
<FieldLabel
|
||||
label="Max tool call turns"
|
||||
hint="Required. Data Designer defaults to 5."
|
||||
/>
|
||||
<Input
|
||||
className="nodrag"
|
||||
value={config.max_tool_call_turns ?? ""}
|
||||
onChange={(event) =>
|
||||
onUpdate({
|
||||
// biome-ignore lint/style/useNamingConvention: api schema
|
||||
max_tool_call_turns: event.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<FieldLabel
|
||||
label="Timeout sec"
|
||||
hint="Optional. Applies to MCP tool loading and calls."
|
||||
/>
|
||||
<Input
|
||||
className="nodrag"
|
||||
value={config.timeout_sec ?? ""}
|
||||
onChange={(event) =>
|
||||
onUpdate({
|
||||
// biome-ignore lint/style/useNamingConvention: api schema
|
||||
timeout_sec: event.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="servers" className="space-y-4 pt-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<FieldLabel
|
||||
label="MCP servers"
|
||||
hint="These server defs are owned by this tool profile and reused by linked LLMs."
|
||||
/>
|
||||
<Button type="button" size="xs" variant="outline" onClick={addProvider}>
|
||||
<HugeiconsIcon icon={PlusSignIcon} className="size-3.5" />
|
||||
Add MCP server
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{!hasProviders ? (
|
||||
<EmptyState
|
||||
title="No MCP servers yet"
|
||||
description="Add one or more servers here. Then go back to Profile to load and pick tools."
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{providers.map((provider, index) => (
|
||||
<McpServerCard
|
||||
key={provider.id}
|
||||
provider={provider}
|
||||
index={index}
|
||||
toolsCount={
|
||||
provider.name.trim()
|
||||
? (toolsByProvider[provider.name.trim()] ?? []).length
|
||||
: undefined
|
||||
}
|
||||
error={provider.name.trim() ? providerErrors[provider.name.trim()] : undefined}
|
||||
open={openProviders[provider.id] ?? !isProviderConfigured(provider)}
|
||||
onOpenChange={(open) =>
|
||||
setOpenProviders((current) => ({
|
||||
...current,
|
||||
[provider.id]: open,
|
||||
}))
|
||||
}
|
||||
onUpdateProviderAt={updateProviderAt}
|
||||
onRemoveProvider={removeProvider}
|
||||
onAddProviderArg={addProviderArg}
|
||||
onUpdateProviderArg={updateProviderArg}
|
||||
onRemoveProviderArg={removeProviderArg}
|
||||
onAddProviderEnv={addProviderEnv}
|
||||
onUpdateProviderEnv={updateProviderEnv}
|
||||
onRemoveProviderEnv={removeProviderEnv}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
|
|
@ -67,6 +67,7 @@ type UseRecipeEditorGraphArgs = {
|
|||
addLlmNode: (type: LlmType, position?: XYPosition, openDialog?: boolean) => void;
|
||||
addModelProviderNode: (position?: XYPosition, openDialog?: boolean) => void;
|
||||
addModelConfigNode: (position?: XYPosition, openDialog?: boolean) => void;
|
||||
addToolProfileNode: (position?: XYPosition, openDialog?: boolean) => void;
|
||||
addExpressionNode: (position?: XYPosition, openDialog?: boolean) => void;
|
||||
addValidatorNode: (
|
||||
type: "validator_python" | "validator_sql" | "validator_oxc",
|
||||
|
|
@ -90,6 +91,7 @@ type UseRecipeEditorGraphResult = {
|
|||
handleAddLlmFromSheet: (type: LlmType) => void;
|
||||
handleAddModelProviderFromSheet: () => void;
|
||||
handleAddModelConfigFromSheet: () => void;
|
||||
handleAddToolProfileFromSheet: () => void;
|
||||
handleAddExpressionFromSheet: () => void;
|
||||
handleAddValidatorFromSheet: (
|
||||
type: "validator_python" | "validator_sql" | "validator_oxc",
|
||||
|
|
@ -113,6 +115,7 @@ export function useRecipeEditorGraph({
|
|||
addLlmNode,
|
||||
addModelProviderNode,
|
||||
addModelConfigNode,
|
||||
addToolProfileNode,
|
||||
addExpressionNode,
|
||||
addValidatorNode,
|
||||
addMarkdownNoteNode,
|
||||
|
|
@ -231,6 +234,10 @@ export function useRecipeEditorGraph({
|
|||
addModelConfigNode(position, false);
|
||||
return;
|
||||
}
|
||||
if (payload.type === "tool_config") {
|
||||
addToolProfileNode(position, false);
|
||||
return;
|
||||
}
|
||||
addLlmNode(payload.type as LlmType, position, false);
|
||||
},
|
||||
[
|
||||
|
|
@ -239,6 +246,7 @@ export function useRecipeEditorGraph({
|
|||
addMarkdownNoteNode,
|
||||
addModelConfigNode,
|
||||
addModelProviderNode,
|
||||
addToolProfileNode,
|
||||
addSamplerNode,
|
||||
addSeedNode,
|
||||
addValidatorNode,
|
||||
|
|
@ -290,6 +298,10 @@ export function useRecipeEditorGraph({
|
|||
addExpressionNode(getViewportCenterPosition());
|
||||
}, [addExpressionNode, getViewportCenterPosition]);
|
||||
|
||||
const handleAddToolProfileFromSheet = useCallback(() => {
|
||||
addToolProfileNode(getViewportCenterPosition());
|
||||
}, [addToolProfileNode, getViewportCenterPosition]);
|
||||
|
||||
const handleAddValidatorFromSheet = useCallback(
|
||||
(type: "validator_python" | "validator_sql" | "validator_oxc") => {
|
||||
addValidatorNode(type, getViewportCenterPosition());
|
||||
|
|
@ -313,6 +325,7 @@ export function useRecipeEditorGraph({
|
|||
handleAddLlmFromSheet,
|
||||
handleAddModelProviderFromSheet,
|
||||
handleAddModelConfigFromSheet,
|
||||
handleAddToolProfileFromSheet,
|
||||
handleAddExpressionFromSheet,
|
||||
handleAddValidatorFromSheet,
|
||||
handleAddMarkdownNoteFromSheet,
|
||||
|
|
|
|||
|
|
@ -61,6 +61,16 @@ function stripApiKeys(value: unknown): unknown {
|
|||
}
|
||||
output[key] = stripApiKeys(entry);
|
||||
}
|
||||
if (
|
||||
output.provider_type === "stdio" &&
|
||||
output.env &&
|
||||
typeof output.env === "object" &&
|
||||
!Array.isArray(output.env)
|
||||
) {
|
||||
output.env = Object.fromEntries(
|
||||
Object.keys(output.env as Record<string, unknown>).map((envKey) => [envKey, ""]),
|
||||
);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {
|
|||
EqualSignIcon,
|
||||
FingerPrintIcon,
|
||||
FunctionIcon,
|
||||
Plug01Icon,
|
||||
Parabola02Icon,
|
||||
PencilEdit02Icon,
|
||||
Plant01Icon,
|
||||
|
|
@ -78,6 +79,9 @@ function resolveExecutionColumnIcon(config: NodeConfig | null): IconType {
|
|||
if (config.kind === "model_config") {
|
||||
return Plant01Icon;
|
||||
}
|
||||
if (config.kind === "tool_config") {
|
||||
return Plug01Icon;
|
||||
}
|
||||
return PencilEdit02Icon;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -104,6 +104,7 @@ export function RecipeStudioPage({
|
|||
addLlmNode,
|
||||
addModelProviderNode,
|
||||
addModelConfigNode,
|
||||
addToolProfileNode,
|
||||
addExpressionNode,
|
||||
addValidatorNode,
|
||||
addMarkdownNoteNode,
|
||||
|
|
@ -141,6 +142,7 @@ export function RecipeStudioPage({
|
|||
addLlmNode: state.addLlmNode,
|
||||
addModelProviderNode: state.addModelProviderNode,
|
||||
addModelConfigNode: state.addModelConfigNode,
|
||||
addToolProfileNode: state.addToolProfileNode,
|
||||
addExpressionNode: state.addExpressionNode,
|
||||
addValidatorNode: state.addValidatorNode,
|
||||
addMarkdownNoteNode: state.addMarkdownNoteNode,
|
||||
|
|
@ -191,6 +193,7 @@ export function RecipeStudioPage({
|
|||
handleAddLlmFromSheet,
|
||||
handleAddModelProviderFromSheet,
|
||||
handleAddModelConfigFromSheet,
|
||||
handleAddToolProfileFromSheet,
|
||||
handleAddExpressionFromSheet,
|
||||
handleAddValidatorFromSheet,
|
||||
handleAddMarkdownNoteFromSheet,
|
||||
|
|
@ -210,6 +213,7 @@ export function RecipeStudioPage({
|
|||
addLlmNode,
|
||||
addModelProviderNode,
|
||||
addModelConfigNode,
|
||||
addToolProfileNode,
|
||||
addExpressionNode,
|
||||
addValidatorNode,
|
||||
addMarkdownNoteNode,
|
||||
|
|
@ -584,10 +588,11 @@ export function RecipeStudioPage({
|
|||
onOpenChange={setBlockSheetOpen}
|
||||
onAddSampler={handleAddSamplerFromSheet}
|
||||
onAddSeed={handleAddSeedFromSheet}
|
||||
onAddLlm={handleAddLlmFromSheet}
|
||||
onAddModelProvider={handleAddModelProviderFromSheet}
|
||||
onAddModelConfig={handleAddModelConfigFromSheet}
|
||||
onAddExpression={handleAddExpressionFromSheet}
|
||||
onAddLlm={handleAddLlmFromSheet}
|
||||
onAddModelProvider={handleAddModelProviderFromSheet}
|
||||
onAddModelConfig={handleAddModelConfigFromSheet}
|
||||
onAddToolProfile={handleAddToolProfileFromSheet}
|
||||
onAddExpression={handleAddExpressionFromSheet}
|
||||
onAddValidator={handleAddValidatorFromSheet}
|
||||
onAddMarkdownNote={handleAddMarkdownNoteFromSheet}
|
||||
onOpenProcessors={openProcessorsFromSheet}
|
||||
|
|
@ -651,6 +656,7 @@ export function RecipeStudioPage({
|
|||
categoryOptions={dialogOptions.categoryOptions}
|
||||
modelConfigAliases={dialogOptions.modelConfigAliases}
|
||||
modelProviderOptions={dialogOptions.modelProviderOptions}
|
||||
toolProfileAliases={dialogOptions.toolProfileAliases}
|
||||
datetimeOptions={dialogOptions.datetimeOptions}
|
||||
onUpdate={updateConfig}
|
||||
container={sheetContainer}
|
||||
|
|
|
|||
|
|
@ -195,6 +195,41 @@ export function syncEdgesForConfigPatch(
|
|||
}
|
||||
}
|
||||
|
||||
const hasToolAliasPatch = Object.prototype.hasOwnProperty.call(
|
||||
patch,
|
||||
"tool_alias",
|
||||
);
|
||||
if (current.kind === "llm" && hasToolAliasPatch) {
|
||||
const nextAlias =
|
||||
(patch as Partial<NodeConfig> & { tool_alias?: string }).tool_alias ?? "";
|
||||
if (nextAlias.trim() === (current.tool_alias ?? "").trim()) {
|
||||
return nextEdges;
|
||||
}
|
||||
nextEdges = removeTargetEdgesBySource(
|
||||
nextEdges,
|
||||
configs,
|
||||
current.id,
|
||||
(source) => Boolean(source && source.kind === "tool_config"),
|
||||
);
|
||||
if (nextAlias) {
|
||||
const toolConfigId = findNodeIdByName(configs, nextAlias);
|
||||
if (toolConfigId) {
|
||||
const result = applyRecipeConnection(
|
||||
{
|
||||
source: toolConfigId,
|
||||
sourceHandle: HANDLE_IDS.semanticOut,
|
||||
target: current.id,
|
||||
targetHandle: HANDLE_IDS.semanticIn,
|
||||
},
|
||||
configs,
|
||||
nextEdges,
|
||||
layoutDirection,
|
||||
);
|
||||
nextEdges = result.edges;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const hasValidatorTargetsPatch = Object.prototype.hasOwnProperty.call(
|
||||
patch,
|
||||
"target_columns",
|
||||
|
|
|
|||
|
|
@ -86,6 +86,15 @@ function isConfigToLlmEdge(
|
|||
return source?.kind === "model_config" && target?.kind === "llm";
|
||||
}
|
||||
|
||||
function isToolConfigToLlmEdge(
|
||||
edge: Edge,
|
||||
configs: Record<string, NodeConfig>,
|
||||
): boolean {
|
||||
const source = configs[edge.source];
|
||||
const target = configs[edge.target];
|
||||
return source?.kind === "tool_config" && target?.kind === "llm";
|
||||
}
|
||||
|
||||
function usageKey(nodeId: string, handleId: string): string {
|
||||
return `${nodeId}::${handleId}`;
|
||||
}
|
||||
|
|
@ -263,9 +272,11 @@ export function optimizeModelInfraEdgeHandles(
|
|||
|
||||
const sourceHandleBefore = normalizeRecipeHandleId(edge.sourceHandle);
|
||||
const targetHandleBefore = normalizeRecipeHandleId(edge.targetHandle);
|
||||
const isModelSemantic =
|
||||
isProviderToConfigEdge(edge, configs) || isConfigToLlmEdge(edge, configs);
|
||||
if (!isModelSemantic) {
|
||||
const isSemanticInfra =
|
||||
isProviderToConfigEdge(edge, configs) ||
|
||||
isConfigToLlmEdge(edge, configs) ||
|
||||
isToolConfigToLlmEdge(edge, configs);
|
||||
if (!isSemanticInfra) {
|
||||
nextEdges.push(edge);
|
||||
continue;
|
||||
}
|
||||
|
|
@ -340,6 +351,7 @@ export function centerModelInfraNodes(
|
|||
): RecipeNode[] {
|
||||
const nodesById = new Map(nodes.map((node) => [node.id, node] as const));
|
||||
const configToLlmIds = new Map<string, string[]>();
|
||||
const toolConfigToLlmIds = new Map<string, string[]>();
|
||||
const providerToConfigIds = new Map<string, string[]>();
|
||||
|
||||
for (const edge of edges) {
|
||||
|
|
@ -357,6 +369,14 @@ export function centerModelInfraNodes(
|
|||
entries.push(edge.target);
|
||||
}
|
||||
configToLlmIds.set(edge.source, entries);
|
||||
continue;
|
||||
}
|
||||
if (isToolConfigToLlmEdge(edge, configs)) {
|
||||
const entries = toolConfigToLlmIds.get(edge.source) ?? [];
|
||||
if (!entries.includes(edge.target)) {
|
||||
entries.push(edge.target);
|
||||
}
|
||||
toolConfigToLlmIds.set(edge.source, entries);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -370,6 +390,9 @@ export function centerModelInfraNodes(
|
|||
(config) => config.kind === "model_provider" && nodesById.has(config.id),
|
||||
)
|
||||
.map((config) => config.id);
|
||||
const toolConfigIds = Object.values(configs)
|
||||
.filter((config) => config.kind === "tool_config" && nodesById.has(config.id))
|
||||
.map((config) => config.id);
|
||||
|
||||
const occupiedById = new Map(
|
||||
nodes.map((node) => [node.id, toRect(node)] as const),
|
||||
|
|
@ -444,5 +467,27 @@ export function centerModelInfraNodes(
|
|||
placeNode(modelProviderId, preferred);
|
||||
}
|
||||
|
||||
for (const toolConfigId of toolConfigIds) {
|
||||
const llmIds = toolConfigToLlmIds.get(toolConfigId) ?? [];
|
||||
const targetBounds = collectBounds(llmIds, nodesById);
|
||||
const toolConfigNode = nodesById.get(toolConfigId);
|
||||
if (!(targetBounds && toolConfigNode)) {
|
||||
continue;
|
||||
}
|
||||
const width = readNodeWidth(toolConfigNode) ?? DEFAULT_NODE_WIDTH;
|
||||
const height = readNodeHeight(toolConfigNode) ?? DEFAULT_NODE_HEIGHT;
|
||||
const preferred =
|
||||
direction === "LR"
|
||||
? {
|
||||
x: (targetBounds.minX + targetBounds.maxX) / 2 - width / 2,
|
||||
y: targetBounds.minY - height - clusterGap,
|
||||
}
|
||||
: {
|
||||
x: targetBounds.minX - width - clusterGap,
|
||||
y: (targetBounds.minY + targetBounds.maxY) / 2 - height / 2,
|
||||
};
|
||||
placeNode(toolConfigId, preferred);
|
||||
}
|
||||
|
||||
return nodes.map((node) => nodesById.get(node.id) ?? node);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -83,6 +83,10 @@ export function applyRenameToConfig(
|
|||
const base = next as LlmConfig;
|
||||
next = { ...base, model_alias: to };
|
||||
}
|
||||
if (config.kind === "llm" && config.tool_alias === from) {
|
||||
const base = next as LlmConfig;
|
||||
next = { ...base, tool_alias: to };
|
||||
}
|
||||
if (config.kind === "validator") {
|
||||
const targets = config.target_columns ?? [];
|
||||
if (targets.includes(from)) {
|
||||
|
|
@ -136,6 +140,10 @@ export function applyRemovalToConfig(
|
|||
const base = next as LlmConfig;
|
||||
next = { ...base, model_alias: "" };
|
||||
}
|
||||
if (config.kind === "llm" && config.tool_alias === ref) {
|
||||
const base = next as LlmConfig;
|
||||
next = { ...base, tool_alias: "" };
|
||||
}
|
||||
if (config.kind === "validator") {
|
||||
const targets = (config.target_columns ?? []).filter((target) => target !== ref);
|
||||
if (targets.length !== (config.target_columns ?? []).length) {
|
||||
|
|
|
|||
|
|
@ -95,6 +95,7 @@ type RecipeStudioState = {
|
|||
addLlmNode: (type: LlmType, position?: XYPosition, openDialog?: boolean) => void;
|
||||
addModelProviderNode: (position?: XYPosition, openDialog?: boolean) => void;
|
||||
addModelConfigNode: (position?: XYPosition, openDialog?: boolean) => void;
|
||||
addToolProfileNode: (position?: XYPosition, openDialog?: boolean) => void;
|
||||
addExpressionNode: (position?: XYPosition, openDialog?: boolean) => void;
|
||||
addValidatorNode: (
|
||||
type: "validator_python" | "validator_sql" | "validator_oxc",
|
||||
|
|
@ -249,7 +250,8 @@ function isModelSemanticEdge(edge: Edge, configs: Record<string, NodeConfig>): b
|
|||
source &&
|
||||
target &&
|
||||
((source.kind === "model_provider" && target.kind === "model_config") ||
|
||||
(source.kind === "model_config" && target.kind === "llm")),
|
||||
(source.kind === "model_config" && target.kind === "llm") ||
|
||||
(source.kind === "tool_config" && target.kind === "llm")),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -534,6 +536,49 @@ export const useRecipeStudioStore = create<RecipeStudioState>((set, get) => ({
|
|||
}
|
||||
return { ...added, nodes, edges, configs };
|
||||
}),
|
||||
addToolProfileNode: (position, openDialog = true) =>
|
||||
set((state) => {
|
||||
if (state.executionLocked) {
|
||||
return state;
|
||||
}
|
||||
const added = buildAddedNodeState(
|
||||
state,
|
||||
"llm",
|
||||
"tool_config",
|
||||
position,
|
||||
openDialog,
|
||||
);
|
||||
const context = getAddedNodeContext(added);
|
||||
if (!context) {
|
||||
return added;
|
||||
}
|
||||
let { nodes, configs } = context;
|
||||
let edges = state.edges;
|
||||
const unboundLlms = Object.values(configs).filter(
|
||||
(config) => config.kind === "llm" && !(config.tool_alias?.trim()),
|
||||
);
|
||||
if (!position && unboundLlms.length > 0) {
|
||||
nodes = placeNodeNear(
|
||||
nodes,
|
||||
context.newNodeId,
|
||||
unboundLlms[0].id,
|
||||
state.layoutDirection,
|
||||
"before",
|
||||
);
|
||||
}
|
||||
if (unboundLlms.length === 1) {
|
||||
const next = connectSemantic(
|
||||
edges,
|
||||
configs,
|
||||
context.newNodeId,
|
||||
unboundLlms[0].id,
|
||||
state.layoutDirection,
|
||||
);
|
||||
edges = next.edges;
|
||||
configs = next.configs;
|
||||
}
|
||||
return { ...added, nodes, edges, configs };
|
||||
}),
|
||||
addExpressionNode: (position, openDialog = true) =>
|
||||
set((state) => {
|
||||
if (state.executionLocked) {
|
||||
|
|
|
|||
|
|
@ -48,7 +48,8 @@ export type RecipeNodeData = {
|
|||
| "seed"
|
||||
| "note"
|
||||
| "model_provider"
|
||||
| "model_config";
|
||||
| "model_config"
|
||||
| "tool_config";
|
||||
subtype: string;
|
||||
blockType:
|
||||
| SamplerType
|
||||
|
|
@ -60,7 +61,8 @@ export type RecipeNodeData = {
|
|||
| "seed"
|
||||
| "markdown_note"
|
||||
| "model_provider"
|
||||
| "model_config";
|
||||
| "model_config"
|
||||
| "tool_config";
|
||||
layoutDirection?: LayoutDirection;
|
||||
runtimeState?: "idle" | "running" | "done";
|
||||
executionLocked?: boolean;
|
||||
|
|
@ -173,6 +175,22 @@ export type LlmToolConfig = {
|
|||
timeout_sec?: string;
|
||||
};
|
||||
|
||||
export type ToolProfileConfig = {
|
||||
id: string;
|
||||
kind: "tool_config";
|
||||
name: string;
|
||||
// biome-ignore lint/style/useNamingConvention: ui schema
|
||||
mcp_providers: LlmMcpProviderConfig[];
|
||||
// biome-ignore lint/style/useNamingConvention: ui schema
|
||||
fetched_tools_by_provider?: Record<string, string[]>;
|
||||
// biome-ignore lint/style/useNamingConvention: api schema
|
||||
allow_tools?: string[];
|
||||
// biome-ignore lint/style/useNamingConvention: api schema
|
||||
max_tool_call_turns?: string;
|
||||
// biome-ignore lint/style/useNamingConvention: api schema
|
||||
timeout_sec?: string;
|
||||
};
|
||||
|
||||
export type LlmImageContextConfig = {
|
||||
enabled: boolean;
|
||||
// biome-ignore lint/style/useNamingConvention: api schema
|
||||
|
|
@ -201,10 +219,6 @@ export type LlmConfig = {
|
|||
output_format?: string;
|
||||
// biome-ignore lint/style/useNamingConvention: api schema
|
||||
tool_alias?: string;
|
||||
// biome-ignore lint/style/useNamingConvention: api schema
|
||||
tool_configs?: LlmToolConfig[];
|
||||
// biome-ignore lint/style/useNamingConvention: ui schema
|
||||
mcp_providers?: LlmMcpProviderConfig[];
|
||||
scores?: Score[];
|
||||
// ui-only, serialized into multi_modal_context for DataDesigner
|
||||
// biome-ignore lint/style/useNamingConvention: ui schema
|
||||
|
|
@ -349,4 +363,5 @@ export type NodeConfig =
|
|||
| MarkdownNoteConfig
|
||||
| SeedConfig
|
||||
| ModelProviderConfig
|
||||
| ModelConfig;
|
||||
| ModelConfig
|
||||
| ToolProfileConfig;
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import type {
|
|||
SeedSourceType,
|
||||
SamplerConfig,
|
||||
SamplerType,
|
||||
ToolProfileConfig,
|
||||
ValidatorCodeLang,
|
||||
ValidatorType,
|
||||
ValidatorConfig,
|
||||
|
|
@ -203,10 +204,6 @@ export function makeLlmConfig(
|
|||
llmType === "structured" ? '{\n "field": "string"\n}' : undefined,
|
||||
// biome-ignore lint/style/useNamingConvention: api schema
|
||||
tool_alias: "",
|
||||
// biome-ignore lint/style/useNamingConvention: api schema
|
||||
tool_configs: [],
|
||||
// biome-ignore lint/style/useNamingConvention: ui schema
|
||||
mcp_providers: [],
|
||||
// biome-ignore lint/style/useNamingConvention: ui schema
|
||||
image_context: {
|
||||
enabled: false,
|
||||
|
|
@ -268,6 +265,27 @@ export function makeModelConfig(
|
|||
};
|
||||
}
|
||||
|
||||
export function makeToolProfileConfig(
|
||||
id: string,
|
||||
existing: NodeConfig[],
|
||||
): ToolProfileConfig {
|
||||
return {
|
||||
id,
|
||||
kind: "tool_config",
|
||||
name: nextName(existing, "tools"),
|
||||
// biome-ignore lint/style/useNamingConvention: ui schema
|
||||
mcp_providers: [],
|
||||
// biome-ignore lint/style/useNamingConvention: ui schema
|
||||
fetched_tools_by_provider: {},
|
||||
// biome-ignore lint/style/useNamingConvention: api schema
|
||||
allow_tools: [],
|
||||
// biome-ignore lint/style/useNamingConvention: api schema
|
||||
max_tool_call_turns: "5",
|
||||
// biome-ignore lint/style/useNamingConvention: api schema
|
||||
timeout_sec: "",
|
||||
};
|
||||
}
|
||||
|
||||
export function makeExpressionConfig(
|
||||
id: string,
|
||||
existing: NodeConfig[],
|
||||
|
|
|
|||
|
|
@ -58,7 +58,11 @@ function syncSubcategoryMapping(
|
|||
}
|
||||
|
||||
function isModelInfraNode(config: NodeConfig): boolean {
|
||||
return config.kind === "model_provider" || config.kind === "model_config";
|
||||
return (
|
||||
config.kind === "model_provider" ||
|
||||
config.kind === "model_config" ||
|
||||
config.kind === "tool_config"
|
||||
);
|
||||
}
|
||||
|
||||
function isSemanticLane(connection: Connection): boolean {
|
||||
|
|
@ -80,6 +84,7 @@ function isDataLane(connection: Connection): boolean {
|
|||
type SingleRefRelation =
|
||||
| "provider"
|
||||
| "model_alias"
|
||||
| "tool_alias"
|
||||
| "reference_column_name"
|
||||
| "subcategory_parent"
|
||||
| "validator_target_columns";
|
||||
|
|
@ -94,6 +99,9 @@ function getSingleRefRelation(
|
|||
if (source.kind === "model_config" && target.kind === "llm") {
|
||||
return "model_alias";
|
||||
}
|
||||
if (source.kind === "tool_config" && target.kind === "llm") {
|
||||
return "tool_alias";
|
||||
}
|
||||
if (
|
||||
source.kind === "sampler" &&
|
||||
source.sampler_type === "datetime" &&
|
||||
|
|
@ -134,6 +142,9 @@ function isCompetingIncomingEdge(
|
|||
if (relation === "model_alias") {
|
||||
return source.kind === "model_config";
|
||||
}
|
||||
if (relation === "tool_alias") {
|
||||
return source.kind === "tool_config";
|
||||
}
|
||||
if (relation === "subcategory_parent") {
|
||||
return isCategoryConfig(source);
|
||||
}
|
||||
|
|
@ -146,7 +157,8 @@ function isCompetingIncomingEdge(
|
|||
function isModelSemanticRelation(source: NodeConfig, target: NodeConfig): boolean {
|
||||
return (
|
||||
(source.kind === "model_provider" && target.kind === "model_config") ||
|
||||
(source.kind === "model_config" && target.kind === "llm")
|
||||
(source.kind === "model_config" && target.kind === "llm") ||
|
||||
(source.kind === "tool_config" && target.kind === "llm")
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -378,6 +390,10 @@ export function applyRecipeConnection(
|
|||
const next = { ...target, model_alias: source.name };
|
||||
return { edges: nextEdges, configs: { ...configs, [target.id]: next } };
|
||||
}
|
||||
if (source.kind === "tool_config" && target.kind === "llm") {
|
||||
const next = { ...target, tool_alias: source.name };
|
||||
return { edges: nextEdges, configs: { ...configs, [target.id]: next } };
|
||||
}
|
||||
if (
|
||||
source.kind === "sampler" &&
|
||||
source.sampler_type === "datetime" &&
|
||||
|
|
|
|||
|
|
@ -10,6 +10,9 @@ export function isSemanticRelation(
|
|||
if (source.kind === "model_config" && target.kind === "llm") {
|
||||
return true;
|
||||
}
|
||||
if (source.kind === "tool_config" && target.kind === "llm") {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
source.kind === "llm" &&
|
||||
source.llm_type === "code" &&
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ const DONE_UPSTREAM_KINDS: ReadonlySet<NodeConfig["kind"]> = new Set([
|
|||
"llm",
|
||||
"model_config",
|
||||
"model_provider",
|
||||
"tool_config",
|
||||
]);
|
||||
|
||||
export type GraphRuntimeVisualState = {
|
||||
|
|
|
|||
|
|
@ -20,6 +20,9 @@ function isSemanticConnection(source: NodeConfig, target: NodeConfig): boolean {
|
|||
if (source.kind === "model_config" && target.kind === "llm") {
|
||||
return true;
|
||||
}
|
||||
if (source.kind === "tool_config" && target.kind === "llm") {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
source.kind === "llm" &&
|
||||
source.llm_type === "code" &&
|
||||
|
|
@ -163,6 +166,9 @@ export function buildEdges(
|
|||
if (config.kind === "llm" && config.model_alias) {
|
||||
addEdgeByName(config.model_alias, config.name);
|
||||
}
|
||||
if (config.kind === "llm" && config.tool_alias) {
|
||||
addEdgeByName(config.tool_alias, config.name);
|
||||
}
|
||||
if (config.kind === "validator") {
|
||||
for (const targetColumn of config.target_columns ?? []) {
|
||||
if (targetColumn.trim()) {
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import type {
|
|||
SeedConfig,
|
||||
SamplerConfig,
|
||||
SeedSourceType,
|
||||
ToolProfileConfig,
|
||||
ValidatorConfig,
|
||||
} from "../../types";
|
||||
import { buildEdges } from "./edges";
|
||||
|
|
@ -216,15 +217,6 @@ function parseToolConfigs(input: unknown): Map<string, LlmToolConfig> {
|
|||
return toolConfigs;
|
||||
}
|
||||
|
||||
function cloneToolConfig(config: LlmToolConfig): LlmToolConfig {
|
||||
return {
|
||||
...config,
|
||||
providers: [...config.providers],
|
||||
// biome-ignore lint/style/useNamingConvention: api schema
|
||||
allow_tools: [...(config.allow_tools ?? [])],
|
||||
};
|
||||
}
|
||||
|
||||
function cloneMcpProvider(config: LlmMcpProviderConfig): LlmMcpProviderConfig {
|
||||
return {
|
||||
...config,
|
||||
|
|
@ -260,6 +252,46 @@ function parseUiMarkdownNoteNodes(input: unknown): UiMarkdownNoteNode[] {
|
|||
return noteNodes;
|
||||
}
|
||||
|
||||
function parseUiToolProfileNodes(input: unknown): Map<string, Record<string, string[]>> {
|
||||
const toolProfiles = new Map<string, Record<string, string[]>>();
|
||||
if (!Array.isArray(input)) {
|
||||
return toolProfiles;
|
||||
}
|
||||
for (const node of input) {
|
||||
if (!isRecord(node)) {
|
||||
continue;
|
||||
}
|
||||
const nodeType = readString(node.node_type) ?? readString(node.type);
|
||||
if (nodeType !== "tool_config") {
|
||||
continue;
|
||||
}
|
||||
const name = readString(node.name) ?? readString(node.id);
|
||||
if (!name?.trim()) {
|
||||
continue;
|
||||
}
|
||||
const rawToolsByProvider = isRecord(node.tools_by_provider)
|
||||
? node.tools_by_provider
|
||||
: null;
|
||||
if (!rawToolsByProvider) {
|
||||
continue;
|
||||
}
|
||||
const toolsByProvider = Object.fromEntries(
|
||||
Object.entries(rawToolsByProvider).flatMap(([providerName, tools]) => {
|
||||
const trimmedName = providerName.trim();
|
||||
if (!trimmedName || !Array.isArray(tools)) {
|
||||
return [];
|
||||
}
|
||||
const values = Array.from(
|
||||
new Set(tools.map((value) => String(value).trim()).filter(Boolean)),
|
||||
);
|
||||
return values.length > 0 ? [[trimmedName, values]] : [];
|
||||
}),
|
||||
);
|
||||
toolProfiles.set(name.trim(), toolsByProvider);
|
||||
}
|
||||
return toolProfiles;
|
||||
}
|
||||
|
||||
function parseAdvancedOpenByNode(input: unknown): Record<string, boolean> {
|
||||
if (!isRecord(input)) {
|
||||
return {};
|
||||
|
|
@ -296,28 +328,31 @@ function applyAdvancedOpen(
|
|||
config.advancedOpen = advancedOpenByNode[config.name] === true;
|
||||
}
|
||||
|
||||
function attachLlmTooling(
|
||||
config: LlmConfig,
|
||||
function buildToolProfileConfig(
|
||||
toolConfig: LlmToolConfig,
|
||||
toolConfigsByAlias: Map<string, LlmToolConfig>,
|
||||
mcpProvidersByName: Map<string, LlmMcpProviderConfig>,
|
||||
): void {
|
||||
const toolAlias = config.tool_alias?.trim();
|
||||
if (!toolAlias) {
|
||||
config.tool_alias = "";
|
||||
config.tool_configs = [];
|
||||
config.mcp_providers = [];
|
||||
return;
|
||||
}
|
||||
const toolConfig = toolConfigsByAlias.get(toolAlias);
|
||||
if (!toolConfig) {
|
||||
config.tool_configs = [];
|
||||
config.mcp_providers = [];
|
||||
return;
|
||||
}
|
||||
config.tool_configs = [cloneToolConfig(toolConfig)];
|
||||
config.mcp_providers = toolConfig.providers
|
||||
.map((providerName) => mcpProvidersByName.get(providerName))
|
||||
.flatMap((provider) => (provider ? [cloneMcpProvider(provider)] : []));
|
||||
fetchedToolsByProfileName: Map<string, Record<string, string[]>>,
|
||||
id: string,
|
||||
): ToolProfileConfig {
|
||||
const canonical = toolConfigsByAlias.get(toolConfig.tool_alias) ?? toolConfig;
|
||||
return {
|
||||
id,
|
||||
kind: "tool_config",
|
||||
name: canonical.tool_alias,
|
||||
// biome-ignore lint/style/useNamingConvention: ui schema
|
||||
mcp_providers: canonical.providers
|
||||
.map((providerName) => mcpProvidersByName.get(providerName))
|
||||
.flatMap((provider) => (provider ? [cloneMcpProvider(provider)] : [])),
|
||||
// biome-ignore lint/style/useNamingConvention: ui schema
|
||||
fetched_tools_by_provider: fetchedToolsByProfileName.get(canonical.tool_alias) ?? {},
|
||||
// biome-ignore lint/style/useNamingConvention: api schema
|
||||
allow_tools: [...(canonical.allow_tools ?? [])],
|
||||
// biome-ignore lint/style/useNamingConvention: api schema
|
||||
max_tool_call_turns: canonical.max_tool_call_turns ?? "5",
|
||||
// biome-ignore lint/style/useNamingConvention: api schema
|
||||
timeout_sec: canonical.timeout_sec ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
export function importRecipePayload(input: string): ImportResult {
|
||||
|
|
@ -378,6 +413,7 @@ export function importRecipePayload(input: string): ImportResult {
|
|||
);
|
||||
const uiAdvancedOpenByNode = parseAdvancedOpenByNode(ui?.advanced_open_by_node);
|
||||
const uiMarkdownNotes = parseUiMarkdownNoteNodes(ui?.nodes);
|
||||
const uiToolProfilesByName = parseUiToolProfileNodes(ui?.nodes);
|
||||
|
||||
for (const note of uiMarkdownNotes) {
|
||||
const id = `n${nextId}`;
|
||||
|
|
@ -471,6 +507,24 @@ export function importRecipePayload(input: string): ImportResult {
|
|||
});
|
||||
}
|
||||
|
||||
for (const toolConfig of toolConfigsByAlias.values()) {
|
||||
const id = `n${nextId}`;
|
||||
nextId += 1;
|
||||
const config = buildToolProfileConfig(
|
||||
toolConfig,
|
||||
toolConfigsByAlias,
|
||||
mcpProvidersByName,
|
||||
uiToolProfilesByName,
|
||||
id,
|
||||
);
|
||||
if (nameToId.has(config.name)) {
|
||||
errors.push(`Duplicate column name: ${config.name}.`);
|
||||
continue;
|
||||
}
|
||||
nameToId.set(config.name, config.id);
|
||||
configs.push(config);
|
||||
}
|
||||
|
||||
recipe.columns.forEach((column, index) => {
|
||||
if (!isRecord(column)) {
|
||||
errors.push(`Column ${index + 1}: invalid object.`);
|
||||
|
|
@ -482,9 +536,6 @@ export function importRecipePayload(input: string): ImportResult {
|
|||
if (!config) {
|
||||
return;
|
||||
}
|
||||
if (config.kind === "llm") {
|
||||
attachLlmTooling(config, toolConfigsByAlias, mcpProvidersByName);
|
||||
}
|
||||
applyAdvancedOpen(config, uiAdvancedOpenByNode);
|
||||
if (nameToId.has(config.name)) {
|
||||
errors.push(`Duplicate column name: ${config.name}.`);
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ export {
|
|||
makeModelProviderConfig,
|
||||
makeSamplerConfig,
|
||||
makeSeedConfig,
|
||||
makeToolProfileConfig,
|
||||
makeValidatorConfig,
|
||||
} from "./config-factories";
|
||||
export {
|
||||
|
|
|
|||
|
|
@ -97,6 +97,17 @@ export function nodeDataFromConfig(
|
|||
layoutDirection,
|
||||
};
|
||||
}
|
||||
if (config.kind === "tool_config") {
|
||||
const providerCount = config.mcp_providers.length;
|
||||
return {
|
||||
title: "Tool Profile",
|
||||
kind: "tool_config",
|
||||
subtype: providerCount === 1 ? "1 MCP server" : `${providerCount} MCP servers`,
|
||||
blockType: "tool_config",
|
||||
name: config.name,
|
||||
layoutDirection,
|
||||
};
|
||||
}
|
||||
return {
|
||||
title: "LLM",
|
||||
kind: "llm",
|
||||
|
|
|
|||
|
|
@ -24,14 +24,13 @@ import { readNodeWidth } from "../rf-node-dimensions";
|
|||
import {
|
||||
buildExpressionColumn,
|
||||
buildLlmColumn,
|
||||
buildLlmMcpProvider,
|
||||
buildLlmToolConfig,
|
||||
buildModelConfig,
|
||||
buildModelProvider,
|
||||
buildProcessors,
|
||||
buildSamplerColumn,
|
||||
buildSeedConfig,
|
||||
buildSeedDropProcessor,
|
||||
buildToolProfilePayload,
|
||||
buildValidatorColumn,
|
||||
pickFirstSeedConfig,
|
||||
} from "./builders";
|
||||
|
|
@ -169,34 +168,6 @@ export function buildRecipePayload(
|
|||
}
|
||||
}
|
||||
columns.push(buildLlmColumn(config, errors));
|
||||
for (const provider of config.mcp_providers ?? []) {
|
||||
const builtProvider = buildLlmMcpProvider(provider, errors);
|
||||
if (!builtProvider) {
|
||||
continue;
|
||||
}
|
||||
pushUniqueJson(
|
||||
"MCP provider",
|
||||
String(builtProvider.name),
|
||||
builtProvider,
|
||||
mcpProviderJsonByName,
|
||||
mcpProviders,
|
||||
errors,
|
||||
);
|
||||
}
|
||||
for (const toolConfig of config.tool_configs ?? []) {
|
||||
const builtToolConfig = buildLlmToolConfig(toolConfig, errors);
|
||||
if (!builtToolConfig) {
|
||||
continue;
|
||||
}
|
||||
pushUniqueJson(
|
||||
"Tool config",
|
||||
String(builtToolConfig.tool_alias),
|
||||
builtToolConfig,
|
||||
toolConfigJsonByAlias,
|
||||
toolConfigs,
|
||||
errors,
|
||||
);
|
||||
}
|
||||
if (config.model_alias) {
|
||||
modelAliases.add(config.model_alias);
|
||||
}
|
||||
|
|
@ -230,6 +201,30 @@ export function buildRecipePayload(
|
|||
modelProviderConfigs.push(config);
|
||||
continue;
|
||||
}
|
||||
if (config.kind === "tool_config") {
|
||||
const built = buildToolProfilePayload(config, errors);
|
||||
for (const provider of built.mcp_providers) {
|
||||
pushUniqueJson(
|
||||
"MCP provider",
|
||||
String(provider.name),
|
||||
provider,
|
||||
mcpProviderJsonByName,
|
||||
mcpProviders,
|
||||
errors,
|
||||
);
|
||||
}
|
||||
if (built.tool_config) {
|
||||
pushUniqueJson(
|
||||
"Tool config",
|
||||
String(built.tool_config.tool_alias),
|
||||
built.tool_config,
|
||||
toolConfigJsonByAlias,
|
||||
toolConfigs,
|
||||
errors,
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
modelConfigs.push(buildModelConfig(config, errors));
|
||||
modelConfigConfigs.push(config);
|
||||
}
|
||||
|
|
@ -272,6 +267,31 @@ export function buildRecipePayload(
|
|||
},
|
||||
];
|
||||
}
|
||||
if (config.kind === "tool_config") {
|
||||
const toolsByProvider = Object.fromEntries(
|
||||
Object.entries(config.fetched_tools_by_provider ?? {}).flatMap(
|
||||
([providerName, tools]) => {
|
||||
const name = providerName.trim();
|
||||
const values = Array.from(
|
||||
new Set(tools.map((tool) => tool.trim()).filter(Boolean)),
|
||||
);
|
||||
return name && values.length > 0 ? [[name, values]] : [];
|
||||
},
|
||||
),
|
||||
);
|
||||
return [
|
||||
{
|
||||
id: config.name,
|
||||
x: node.position.x,
|
||||
y: node.position.y,
|
||||
...(width !== null ? { width } : {}),
|
||||
node_type: "tool_config" as const,
|
||||
...(Object.keys(toolsByProvider).length > 0 && {
|
||||
tools_by_provider: toolsByProvider,
|
||||
}),
|
||||
},
|
||||
];
|
||||
}
|
||||
return [
|
||||
{
|
||||
id: config.name,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,9 @@
|
|||
import type { LlmConfig, LlmMcpProviderConfig, LlmToolConfig } from "../../types";
|
||||
import type {
|
||||
LlmConfig,
|
||||
LlmMcpProviderConfig,
|
||||
LlmToolConfig,
|
||||
ToolProfileConfig,
|
||||
} from "../../types";
|
||||
|
||||
function buildImageContext(
|
||||
config: LlmConfig,
|
||||
|
|
@ -200,3 +205,40 @@ export function buildLlmToolConfig(
|
|||
timeout_sec: timeoutSec,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildToolProfilePayload(
|
||||
config: ToolProfileConfig,
|
||||
errors: string[],
|
||||
): {
|
||||
// biome-ignore lint/style/useNamingConvention: api schema
|
||||
mcp_providers: Record<string, unknown>[];
|
||||
// biome-ignore lint/style/useNamingConvention: api schema
|
||||
tool_config: Record<string, unknown> | null;
|
||||
} {
|
||||
const mcpProviders = config.mcp_providers
|
||||
.map((provider) => buildLlmMcpProvider(provider, errors))
|
||||
.flatMap((provider) => (provider ? [provider] : []));
|
||||
const toolConfig = buildLlmToolConfig(
|
||||
{
|
||||
id: config.id,
|
||||
// biome-ignore lint/style/useNamingConvention: api schema
|
||||
tool_alias: config.name,
|
||||
providers: mcpProviders
|
||||
.map((provider) => String(provider.name ?? "").trim())
|
||||
.filter(Boolean),
|
||||
// biome-ignore lint/style/useNamingConvention: api schema
|
||||
allow_tools: config.allow_tools,
|
||||
// biome-ignore lint/style/useNamingConvention: api schema
|
||||
max_tool_call_turns: config.max_tool_call_turns,
|
||||
// biome-ignore lint/style/useNamingConvention: api schema
|
||||
timeout_sec: config.timeout_sec,
|
||||
},
|
||||
errors,
|
||||
);
|
||||
return {
|
||||
// biome-ignore lint/style/useNamingConvention: api schema
|
||||
mcp_providers: mcpProviders,
|
||||
// biome-ignore lint/style/useNamingConvention: api schema
|
||||
tool_config: toolConfig,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,9 @@
|
|||
export { buildLlmColumn, buildLlmMcpProvider, buildLlmToolConfig } from "./builders-llm";
|
||||
export {
|
||||
buildLlmColumn,
|
||||
buildLlmMcpProvider,
|
||||
buildLlmToolConfig,
|
||||
buildToolProfilePayload,
|
||||
} from "./builders-llm";
|
||||
export { buildModelConfig, buildModelProvider } from "./builders-model";
|
||||
export { buildExpressionColumn, buildProcessors } from "./builders-processors";
|
||||
export { buildSamplerColumn } from "./builders-sampler";
|
||||
|
|
|
|||
|
|
@ -37,11 +37,12 @@ export type RecipePayload = {
|
|||
x: number;
|
||||
y: number;
|
||||
width?: number;
|
||||
node_type?: "markdown_note";
|
||||
node_type?: "markdown_note" | "tool_config";
|
||||
name?: string;
|
||||
markdown?: string;
|
||||
note_color?: string;
|
||||
note_opacity?: string;
|
||||
tools_by_provider?: Record<string, string[]>;
|
||||
}>;
|
||||
edges: {
|
||||
from: string;
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ export type DialogOptions = {
|
|||
categoryOptions: SamplerConfig[];
|
||||
modelConfigAliases: string[];
|
||||
modelProviderOptions: string[];
|
||||
toolProfileAliases: string[];
|
||||
datetimeOptions: string[];
|
||||
};
|
||||
|
||||
|
|
@ -11,6 +12,7 @@ export function buildDialogOptions(configList: NodeConfig[]): DialogOptions {
|
|||
const categoryOptions: SamplerConfig[] = [];
|
||||
const modelConfigAliases: string[] = [];
|
||||
const modelProviderOptions: string[] = [];
|
||||
const toolProfileAliases: string[] = [];
|
||||
const datetimeOptions: string[] = [];
|
||||
|
||||
for (const config of configList) {
|
||||
|
|
@ -29,6 +31,10 @@ export function buildDialogOptions(configList: NodeConfig[]): DialogOptions {
|
|||
}
|
||||
if (config.kind === "model_provider") {
|
||||
modelProviderOptions.push(config.name);
|
||||
continue;
|
||||
}
|
||||
if (config.kind === "tool_config") {
|
||||
toolProfileAliases.push(config.name);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -36,6 +42,7 @@ export function buildDialogOptions(configList: NodeConfig[]): DialogOptions {
|
|||
categoryOptions,
|
||||
modelConfigAliases,
|
||||
modelProviderOptions,
|
||||
toolProfileAliases,
|
||||
datetimeOptions,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -195,6 +195,44 @@ export function getConfigErrors(config: NodeConfig | null): string[] {
|
|||
errors.push("Expression is required.");
|
||||
}
|
||||
}
|
||||
if (config.kind === "tool_config") {
|
||||
if (config.mcp_providers.length === 0) {
|
||||
errors.push("Add at least one MCP server.");
|
||||
}
|
||||
const serverNames = new Set<string>();
|
||||
for (const provider of config.mcp_providers) {
|
||||
const name = provider.name.trim();
|
||||
if (!name) {
|
||||
errors.push("Each MCP server needs a name.");
|
||||
continue;
|
||||
}
|
||||
if (serverNames.has(name)) {
|
||||
errors.push(`Duplicate MCP server name: ${name}.`);
|
||||
}
|
||||
serverNames.add(name);
|
||||
if (provider.provider_type === "stdio") {
|
||||
if (!provider.command?.trim()) {
|
||||
errors.push(`MCP server ${name}: command is required.`);
|
||||
}
|
||||
} else if (!provider.endpoint?.trim()) {
|
||||
errors.push(`MCP server ${name}: endpoint is required.`);
|
||||
}
|
||||
}
|
||||
const maxTurnsRaw = config.max_tool_call_turns?.trim();
|
||||
if (
|
||||
maxTurnsRaw &&
|
||||
(!Number.isFinite(Number(maxTurnsRaw)) || Number(maxTurnsRaw) < 1)
|
||||
) {
|
||||
errors.push("Max tool call turns must be >= 1.");
|
||||
}
|
||||
const timeoutRaw = config.timeout_sec?.trim();
|
||||
if (
|
||||
timeoutRaw &&
|
||||
(!Number.isFinite(Number(timeoutRaw)) || Number(timeoutRaw) <= 0)
|
||||
) {
|
||||
errors.push("Timeout must be > 0.");
|
||||
}
|
||||
}
|
||||
if (config.kind === "validator") {
|
||||
const targets = (config.target_columns ?? [])
|
||||
.map((value) => value.trim())
|
||||
|
|
|
|||
|
|
@ -29,7 +29,11 @@ export function getAvailableVariableEntries(
|
|||
if (config.id === currentId) {
|
||||
continue;
|
||||
}
|
||||
if (config.kind === "model_provider" || config.kind === "model_config") {
|
||||
if (
|
||||
config.kind === "model_provider" ||
|
||||
config.kind === "model_config" ||
|
||||
config.kind === "tool_config"
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
import { type ReactElement, useMemo, useState } from "react";
|
||||
import { type ReactElement, useEffect, useMemo } from "react";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { useChartPreferencesStore } from "./charts/chart-preferences-store";
|
||||
import { EvalLossChartCard } from "./charts/eval-loss-chart-card";
|
||||
import { GradNormChartCard } from "./charts/grad-norm-chart-card";
|
||||
import { LearningRateChartCard } from "./charts/learning-rate-chart-card";
|
||||
import { TrainingLossChartCard } from "./charts/training-loss-chart-card";
|
||||
import type { OutlierMode, ScaleMode, TrainingChartSeries, ViewSettingsState } from "./charts/types";
|
||||
import type { TrainingChartSeries } from "./charts/types";
|
||||
import {
|
||||
DEFAULT_VISIBLE_POINTS,
|
||||
MAX_RENDER_POINTS,
|
||||
applyOutlierCap,
|
||||
buildStepTicks,
|
||||
|
|
@ -16,29 +17,82 @@ import {
|
|||
toLog1p,
|
||||
} from "./charts/utils";
|
||||
|
||||
type LossDisplayPoint = {
|
||||
step: number;
|
||||
displayLoss: number;
|
||||
displaySmoothed: number;
|
||||
};
|
||||
|
||||
function isStepVisible(step: number, domain: [number, number]): boolean {
|
||||
return step >= domain[0] && step <= domain[1];
|
||||
}
|
||||
|
||||
function collectLossValues(
|
||||
data: LossDisplayPoint[],
|
||||
domain: [number, number],
|
||||
options: { includeRaw: boolean; includeSmoothed: boolean },
|
||||
): number[] {
|
||||
const values: number[] = [];
|
||||
|
||||
for (const point of data) {
|
||||
if (!isStepVisible(point.step, domain)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (options.includeRaw && Number.isFinite(point.displayLoss)) {
|
||||
values.push(point.displayLoss);
|
||||
}
|
||||
|
||||
if (options.includeSmoothed && Number.isFinite(point.displaySmoothed)) {
|
||||
values.push(point.displaySmoothed);
|
||||
}
|
||||
}
|
||||
|
||||
return values;
|
||||
}
|
||||
|
||||
export function ChartsContent({
|
||||
metrics,
|
||||
isTraining,
|
||||
evalEnabled,
|
||||
}: { metrics: TrainingChartSeries; isTraining: boolean; evalEnabled: boolean }): ReactElement {
|
||||
const [smoothing, setSmoothing] = useState(0.75);
|
||||
const [showRaw, setShowRaw] = useState(true);
|
||||
const [showSmoothed, setShowSmoothed] = useState(true);
|
||||
const [showAvgLine, setShowAvgLine] = useState(true);
|
||||
const [windowSize, setWindowSize] = useState<number | null>(
|
||||
Math.max(24, Math.floor(DEFAULT_VISIBLE_POINTS / 2)),
|
||||
}: {
|
||||
metrics: TrainingChartSeries;
|
||||
isTraining: boolean;
|
||||
evalEnabled: boolean;
|
||||
}): ReactElement {
|
||||
const {
|
||||
windowSize,
|
||||
smoothing,
|
||||
showRaw,
|
||||
showSmoothed,
|
||||
showAvgLine,
|
||||
lossScale,
|
||||
lrScale,
|
||||
gradScale,
|
||||
lossOutlierMode,
|
||||
gradOutlierMode,
|
||||
lrOutlierMode,
|
||||
setAvailableSteps,
|
||||
} = useChartPreferencesStore(
|
||||
useShallow((state) => ({
|
||||
windowSize: state.windowSize,
|
||||
smoothing: state.smoothing,
|
||||
showRaw: state.showRaw,
|
||||
showSmoothed: state.showSmoothed,
|
||||
showAvgLine: state.showAvgLine,
|
||||
lossScale: state.lossScale,
|
||||
lrScale: state.lrScale,
|
||||
gradScale: state.gradScale,
|
||||
lossOutlierMode: state.lossOutlierMode,
|
||||
gradOutlierMode: state.gradOutlierMode,
|
||||
lrOutlierMode: state.lrOutlierMode,
|
||||
setAvailableSteps: state.setAvailableSteps,
|
||||
})),
|
||||
);
|
||||
|
||||
const [lossScale, setLossScale] = useState<ScaleMode>("linear");
|
||||
const [lrScale, setLrScale] = useState<ScaleMode>("linear");
|
||||
const [gradScale, setGradScale] = useState<ScaleMode>("linear");
|
||||
|
||||
const [lossOutlierMode, setLossOutlierMode] = useState<OutlierMode>("none");
|
||||
const [gradOutlierMode, setGradOutlierMode] = useState<OutlierMode>("none");
|
||||
const [lrOutlierMode, setLrOutlierMode] = useState<OutlierMode>("none");
|
||||
|
||||
const smoothedData = useMemo(
|
||||
() => (metrics.lossHistory.length > 0 ? ema(metrics.lossHistory, 1 - smoothing) : []),
|
||||
() =>
|
||||
metrics.lossHistory.length > 0 ? ema(metrics.lossHistory, smoothing) : [],
|
||||
[metrics.lossHistory, smoothing],
|
||||
);
|
||||
|
||||
|
|
@ -61,11 +115,21 @@ export function ChartsContent({
|
|||
|
||||
const allSteps = useMemo(() => {
|
||||
const set = new Set<number>();
|
||||
for (const point of reducedLossData) set.add(point.step);
|
||||
for (const point of reducedGradNormData) set.add(point.step);
|
||||
for (const point of reducedLrData) set.add(point.step);
|
||||
for (const point of metrics.lossHistory) {
|
||||
set.add(point.step);
|
||||
}
|
||||
for (const point of metrics.gradNormHistory) {
|
||||
set.add(point.step);
|
||||
}
|
||||
for (const point of metrics.lrHistory) {
|
||||
set.add(point.step);
|
||||
}
|
||||
return Array.from(set).sort((a, b) => a - b);
|
||||
}, [reducedGradNormData, reducedLossData, reducedLrData]);
|
||||
}, [metrics.gradNormHistory, metrics.lossHistory, metrics.lrHistory]);
|
||||
|
||||
useEffect(() => {
|
||||
setAvailableSteps(allSteps.length);
|
||||
}, [allSteps.length, setAvailableSteps]);
|
||||
|
||||
const stepCount = Math.max(1, allSteps.length);
|
||||
const effectiveWindowSize =
|
||||
|
|
@ -129,35 +193,23 @@ export function ChartsContent({
|
|||
);
|
||||
|
||||
const visibleLossDisplayValues = useMemo(() => {
|
||||
const values: number[] = [];
|
||||
const visibleValues = collectLossValues(
|
||||
displayLossData,
|
||||
visibleStepDomain,
|
||||
{
|
||||
includeRaw: showRaw,
|
||||
includeSmoothed: showSmoothed,
|
||||
},
|
||||
);
|
||||
|
||||
for (const point of displayLossData) {
|
||||
if (point.step < visibleStepDomain[0] || point.step > visibleStepDomain[1]) {
|
||||
continue;
|
||||
}
|
||||
if (showRaw && Number.isFinite(point.displayLoss)) {
|
||||
values.push(point.displayLoss);
|
||||
}
|
||||
if (showSmoothed && Number.isFinite(point.displaySmoothed)) {
|
||||
values.push(point.displaySmoothed);
|
||||
}
|
||||
if (visibleValues.length > 0) {
|
||||
return visibleValues;
|
||||
}
|
||||
|
||||
if (values.length === 0) {
|
||||
for (const point of displayLossData) {
|
||||
if (point.step < visibleStepDomain[0] || point.step > visibleStepDomain[1]) {
|
||||
continue;
|
||||
}
|
||||
if (Number.isFinite(point.displayLoss)) {
|
||||
values.push(point.displayLoss);
|
||||
}
|
||||
if (Number.isFinite(point.displaySmoothed)) {
|
||||
values.push(point.displaySmoothed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return values;
|
||||
return collectLossValues(displayLossData, visibleStepDomain, {
|
||||
includeRaw: true,
|
||||
includeSmoothed: true,
|
||||
});
|
||||
}, [displayLossData, showRaw, showSmoothed, visibleStepDomain]);
|
||||
|
||||
const visibleGradDisplayValues = useMemo(
|
||||
|
|
@ -165,7 +217,8 @@ export function ChartsContent({
|
|||
displayGradData
|
||||
.filter(
|
||||
(point) =>
|
||||
point.step >= visibleStepDomain[0] && point.step <= visibleStepDomain[1],
|
||||
point.step >= visibleStepDomain[0] &&
|
||||
point.step <= visibleStepDomain[1],
|
||||
)
|
||||
.map((point) => point.displayGradNorm)
|
||||
.filter((value) => Number.isFinite(value)),
|
||||
|
|
@ -177,7 +230,8 @@ export function ChartsContent({
|
|||
displayLrData
|
||||
.filter(
|
||||
(point) =>
|
||||
point.step >= visibleStepDomain[0] && point.step <= visibleStepDomain[1],
|
||||
point.step >= visibleStepDomain[0] &&
|
||||
point.step <= visibleStepDomain[1],
|
||||
)
|
||||
.map((point) => point.displayLr)
|
||||
.filter((value) => Number.isFinite(value)),
|
||||
|
|
@ -185,11 +239,13 @@ export function ChartsContent({
|
|||
);
|
||||
|
||||
const lossDomain = useMemo(
|
||||
() => buildYDomain(applyOutlierCap(visibleLossDisplayValues, lossOutlierMode)),
|
||||
() =>
|
||||
buildYDomain(applyOutlierCap(visibleLossDisplayValues, lossOutlierMode)),
|
||||
[lossOutlierMode, visibleLossDisplayValues],
|
||||
);
|
||||
const gradDomain = useMemo(
|
||||
() => buildYDomain(applyOutlierCap(visibleGradDisplayValues, gradOutlierMode)),
|
||||
() =>
|
||||
buildYDomain(applyOutlierCap(visibleGradDisplayValues, gradOutlierMode)),
|
||||
[gradOutlierMode, visibleGradDisplayValues],
|
||||
);
|
||||
const lrDomain = useMemo(
|
||||
|
|
@ -203,7 +259,9 @@ export function ChartsContent({
|
|||
}, [reducedEvalLossData]);
|
||||
|
||||
const evalLossStepTicks = useMemo(() => {
|
||||
if (reducedEvalLossData.length < 2) return undefined;
|
||||
if (reducedEvalLossData.length < 2) {
|
||||
return undefined;
|
||||
}
|
||||
const min = reducedEvalLossData[0].step;
|
||||
const max = reducedEvalLossData[reducedEvalLossData.length - 1].step;
|
||||
return buildStepTicks(min, max);
|
||||
|
|
@ -218,21 +276,6 @@ export function ChartsContent({
|
|||
: 0;
|
||||
const avgDisplay = lossScale === "log" ? toLog1p(avgRaw) : avgRaw;
|
||||
|
||||
const minWindow = Math.min(10, Math.max(1, allSteps.length));
|
||||
const viewSettings: ViewSettingsState = {
|
||||
effectiveWindowSize,
|
||||
minWindow,
|
||||
allStepsLength: allSteps.length,
|
||||
setWindowSize: (value) => {
|
||||
const clampedWindow = clamp(Math.round(value), 1, Math.max(1, allSteps.length));
|
||||
if (clampedWindow >= allSteps.length) {
|
||||
setWindowSize(null);
|
||||
return;
|
||||
}
|
||||
setWindowSize(clampedWindow);
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
<TrainingLossChartCard
|
||||
|
|
@ -242,19 +285,10 @@ export function ChartsContent({
|
|||
xAxisTicks={xAxisTicks}
|
||||
avgRaw={avgRaw}
|
||||
avgDisplay={avgDisplay}
|
||||
smoothing={smoothing}
|
||||
setSmoothing={setSmoothing}
|
||||
showRaw={showRaw}
|
||||
setShowRaw={setShowRaw}
|
||||
showSmoothed={showSmoothed}
|
||||
setShowSmoothed={setShowSmoothed}
|
||||
showAvgLine={showAvgLine}
|
||||
setShowAvgLine={setShowAvgLine}
|
||||
viewSettings={viewSettings}
|
||||
scale={lossScale}
|
||||
setScale={setLossScale}
|
||||
outlierMode={lossOutlierMode}
|
||||
setOutlierMode={setLossOutlierMode}
|
||||
/>
|
||||
<GradNormChartCard
|
||||
data={displayGradData}
|
||||
|
|
@ -262,10 +296,6 @@ export function ChartsContent({
|
|||
visibleStepDomain={visibleStepDomain}
|
||||
xAxisTicks={xAxisTicks}
|
||||
scale={gradScale}
|
||||
setScale={setGradScale}
|
||||
outlierMode={gradOutlierMode}
|
||||
setOutlierMode={setGradOutlierMode}
|
||||
viewSettings={viewSettings}
|
||||
/>
|
||||
<LearningRateChartCard
|
||||
data={displayLrData}
|
||||
|
|
@ -273,10 +303,6 @@ export function ChartsContent({
|
|||
visibleStepDomain={visibleStepDomain}
|
||||
xAxisTicks={xAxisTicks}
|
||||
scale={lrScale}
|
||||
setScale={setLrScale}
|
||||
outlierMode={lrOutlierMode}
|
||||
setOutlierMode={setLrOutlierMode}
|
||||
viewSettings={viewSettings}
|
||||
/>
|
||||
<EvalLossChartCard
|
||||
data={reducedEvalLossData}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,93 @@
|
|||
import { create } from "zustand";
|
||||
import type { OutlierMode, ScaleMode } from "./types";
|
||||
import { DEFAULT_VISIBLE_POINTS, clamp } from "./utils";
|
||||
|
||||
const DEFAULT_WINDOW_SIZE = Math.max(
|
||||
24,
|
||||
Math.floor(DEFAULT_VISIBLE_POINTS / 2),
|
||||
);
|
||||
|
||||
type ChartPreferencesState = {
|
||||
availableSteps: number;
|
||||
windowSize: number | null;
|
||||
smoothing: number;
|
||||
showRaw: boolean;
|
||||
showSmoothed: boolean;
|
||||
showAvgLine: boolean;
|
||||
lossScale: ScaleMode;
|
||||
lrScale: ScaleMode;
|
||||
gradScale: ScaleMode;
|
||||
lossOutlierMode: OutlierMode;
|
||||
gradOutlierMode: OutlierMode;
|
||||
lrOutlierMode: OutlierMode;
|
||||
setAvailableSteps: (value: number) => void;
|
||||
setWindowSize: (value: number | null) => void;
|
||||
setSmoothing: (value: number) => void;
|
||||
setShowRaw: (value: boolean) => void;
|
||||
setShowSmoothed: (value: boolean) => void;
|
||||
setShowAvgLine: (value: boolean) => void;
|
||||
setLossScale: (value: ScaleMode) => void;
|
||||
setLrScale: (value: ScaleMode) => void;
|
||||
setGradScale: (value: ScaleMode) => void;
|
||||
setLossOutlierMode: (value: OutlierMode) => void;
|
||||
setGradOutlierMode: (value: OutlierMode) => void;
|
||||
setLrOutlierMode: (value: OutlierMode) => void;
|
||||
resetPreferences: () => void;
|
||||
};
|
||||
|
||||
const defaultPreferences = {
|
||||
windowSize: DEFAULT_WINDOW_SIZE as number | null,
|
||||
smoothing: 0.6,
|
||||
showRaw: true,
|
||||
showSmoothed: true,
|
||||
showAvgLine: true,
|
||||
lossScale: "linear" as ScaleMode,
|
||||
lrScale: "linear" as ScaleMode,
|
||||
gradScale: "linear" as ScaleMode,
|
||||
lossOutlierMode: "none" as OutlierMode,
|
||||
gradOutlierMode: "none" as OutlierMode,
|
||||
lrOutlierMode: "none" as OutlierMode,
|
||||
};
|
||||
|
||||
export const useChartPreferencesStore = create<ChartPreferencesState>(
|
||||
(set) => ({
|
||||
availableSteps: 0,
|
||||
...defaultPreferences,
|
||||
setAvailableSteps: (value) =>
|
||||
set((state) => {
|
||||
const availableSteps = Math.max(0, Math.round(value));
|
||||
if (state.windowSize == null || availableSteps <= 0) {
|
||||
return { availableSteps };
|
||||
}
|
||||
|
||||
if (state.windowSize >= availableSteps) {
|
||||
return { availableSteps, windowSize: null };
|
||||
}
|
||||
|
||||
return {
|
||||
availableSteps,
|
||||
windowSize: clamp(Math.round(state.windowSize), 1, availableSteps),
|
||||
};
|
||||
}),
|
||||
setWindowSize: (value) =>
|
||||
set((state) => {
|
||||
if (value == null || state.availableSteps <= 0) {
|
||||
return { windowSize: null };
|
||||
}
|
||||
|
||||
const next = clamp(Math.round(value), 1, state.availableSteps);
|
||||
return { windowSize: next >= state.availableSteps ? null : next };
|
||||
}),
|
||||
setSmoothing: (value) => set({ smoothing: clamp(value, 0, 0.9) }),
|
||||
setShowRaw: (value) => set({ showRaw: value }),
|
||||
setShowSmoothed: (value) => set({ showSmoothed: value }),
|
||||
setShowAvgLine: (value) => set({ showAvgLine: value }),
|
||||
setLossScale: (value) => set({ lossScale: value }),
|
||||
setLrScale: (value) => set({ lrScale: value }),
|
||||
setGradScale: (value) => set({ gradScale: value }),
|
||||
setLossOutlierMode: (value) => set({ lossOutlierMode: value }),
|
||||
setGradOutlierMode: (value) => set({ gradOutlierMode: value }),
|
||||
setLrOutlierMode: (value) => set({ lrOutlierMode: value }),
|
||||
resetPreferences: () => set({ ...defaultPreferences }),
|
||||
}),
|
||||
);
|
||||
|
|
@ -0,0 +1,312 @@
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Settings02Icon } from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { type ReactElement, useState } from "react";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { useChartPreferencesStore } from "./chart-preferences-store";
|
||||
import type { OutlierMode, ScaleMode } from "./types";
|
||||
|
||||
function ChoiceButtons<T extends string>({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
options: { label: string; value: T }[];
|
||||
value: T;
|
||||
onChange: (value: T) => void;
|
||||
}): ReactElement {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{options.map((option) => (
|
||||
<Button
|
||||
key={option.value}
|
||||
type="button"
|
||||
size="xs"
|
||||
variant={value === option.value ? "secondary" : "outline"}
|
||||
onClick={() => onChange(option.value)}
|
||||
>
|
||||
{option.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingRow({
|
||||
label,
|
||||
description,
|
||||
control,
|
||||
}: {
|
||||
label: string;
|
||||
description?: string;
|
||||
control: ReactElement;
|
||||
}): ReactElement {
|
||||
return (
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<Label className="text-sm">{label}</Label>
|
||||
{description ? (
|
||||
<p className="mt-1 text-xs text-muted-foreground">{description}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="shrink-0">{control}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ScaleSection({
|
||||
title,
|
||||
scale,
|
||||
setScale,
|
||||
outlierMode,
|
||||
setOutlierMode,
|
||||
}: {
|
||||
title: string;
|
||||
scale: ScaleMode;
|
||||
setScale: (value: ScaleMode) => void;
|
||||
outlierMode: OutlierMode;
|
||||
setOutlierMode: (value: OutlierMode) => void;
|
||||
}): ReactElement {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium">{title}</p>
|
||||
<p className="text-xs text-muted-foreground">Scale and cleanup</p>
|
||||
</div>
|
||||
<ChoiceButtons
|
||||
options={[
|
||||
{ label: "Linear", value: "linear" },
|
||||
{ label: "Log", value: "log" },
|
||||
]}
|
||||
value={scale}
|
||||
onChange={setScale}
|
||||
/>
|
||||
<ChoiceButtons
|
||||
options={[
|
||||
{ label: "No clip", value: "none" },
|
||||
{ label: "Clip p99", value: "p99" },
|
||||
{ label: "Clip p95", value: "p95" },
|
||||
]}
|
||||
value={outlierMode}
|
||||
onChange={setOutlierMode}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChartSettingsSheet(): ReactElement {
|
||||
const [open, setOpen] = useState(false);
|
||||
const {
|
||||
availableSteps,
|
||||
windowSize,
|
||||
smoothing,
|
||||
showRaw,
|
||||
showSmoothed,
|
||||
showAvgLine,
|
||||
lossScale,
|
||||
lrScale,
|
||||
gradScale,
|
||||
lossOutlierMode,
|
||||
gradOutlierMode,
|
||||
lrOutlierMode,
|
||||
setWindowSize,
|
||||
setSmoothing,
|
||||
setShowRaw,
|
||||
setShowSmoothed,
|
||||
setShowAvgLine,
|
||||
setLossScale,
|
||||
setLrScale,
|
||||
setGradScale,
|
||||
setLossOutlierMode,
|
||||
setGradOutlierMode,
|
||||
setLrOutlierMode,
|
||||
resetPreferences,
|
||||
} = useChartPreferencesStore(
|
||||
useShallow((state) => ({
|
||||
availableSteps: state.availableSteps,
|
||||
windowSize: state.windowSize,
|
||||
smoothing: state.smoothing,
|
||||
showRaw: state.showRaw,
|
||||
showSmoothed: state.showSmoothed,
|
||||
showAvgLine: state.showAvgLine,
|
||||
lossScale: state.lossScale,
|
||||
lrScale: state.lrScale,
|
||||
gradScale: state.gradScale,
|
||||
lossOutlierMode: state.lossOutlierMode,
|
||||
gradOutlierMode: state.gradOutlierMode,
|
||||
lrOutlierMode: state.lrOutlierMode,
|
||||
setWindowSize: state.setWindowSize,
|
||||
setSmoothing: state.setSmoothing,
|
||||
setShowRaw: state.setShowRaw,
|
||||
setShowSmoothed: state.setShowSmoothed,
|
||||
setShowAvgLine: state.setShowAvgLine,
|
||||
setLossScale: state.setLossScale,
|
||||
setLrScale: state.setLrScale,
|
||||
setGradScale: state.setGradScale,
|
||||
setLossOutlierMode: state.setLossOutlierMode,
|
||||
setGradOutlierMode: state.setGradOutlierMode,
|
||||
setLrOutlierMode: state.setLrOutlierMode,
|
||||
resetPreferences: state.resetPreferences,
|
||||
})),
|
||||
);
|
||||
|
||||
const minWindow = Math.min(10, Math.max(1, availableSteps));
|
||||
const effectiveWindowSize =
|
||||
windowSize == null ? Math.max(availableSteps, 1) : windowSize;
|
||||
const showingAll =
|
||||
availableSteps > 0 &&
|
||||
(windowSize == null || effectiveWindowSize >= availableSteps);
|
||||
const sliderMax = Math.max(minWindow, availableSteps || 1);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="rounded-full text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
onClick={() => setOpen(true)}
|
||||
aria-label="Open chart settings"
|
||||
>
|
||||
<HugeiconsIcon icon={Settings02Icon} className="size-4" />
|
||||
</Button>
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
<SheetContent
|
||||
className="w-full sm:max-w-md"
|
||||
overlayClassName="bg-transparent backdrop-blur-0"
|
||||
>
|
||||
<SheetHeader className="pb-4">
|
||||
<SheetTitle>Chart Settings</SheetTitle>
|
||||
<SheetDescription>
|
||||
Tune chart presentation while training keeps running.
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="flex-1 space-y-6 overflow-y-auto px-6 pb-6">
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium">View window</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Show latest steps only or the full history.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>Window</span>
|
||||
<span className="tabular-nums">
|
||||
{showingAll ? "All" : effectiveWindowSize}
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[effectiveWindowSize]}
|
||||
onValueChange={([value]) => setWindowSize(value)}
|
||||
min={minWindow}
|
||||
max={sliderMax}
|
||||
step={1}
|
||||
disabled={availableSteps <= 1}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium">Training loss</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Control overlays and EMA smoothing.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>Smoothing</span>
|
||||
<span className="tabular-nums">{smoothing.toFixed(2)}</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[smoothing]}
|
||||
onValueChange={([value]) => setSmoothing(value)}
|
||||
min={0}
|
||||
max={0.9}
|
||||
step={0.01}
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Move right for more smoothing. `0` = raw.
|
||||
</p>
|
||||
</div>
|
||||
<SettingRow
|
||||
label="Show raw loss"
|
||||
control={
|
||||
<Switch checked={showRaw} onCheckedChange={setShowRaw} />
|
||||
}
|
||||
/>
|
||||
<SettingRow
|
||||
label="Show smoothed loss"
|
||||
control={
|
||||
<Switch
|
||||
checked={showSmoothed}
|
||||
onCheckedChange={setShowSmoothed}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<SettingRow
|
||||
label="Show average line"
|
||||
control={
|
||||
<Switch
|
||||
checked={showAvgLine}
|
||||
onCheckedChange={setShowAvgLine}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<Separator />
|
||||
<ScaleSection
|
||||
title="Loss axis"
|
||||
scale={lossScale}
|
||||
setScale={setLossScale}
|
||||
outlierMode={lossOutlierMode}
|
||||
setOutlierMode={setLossOutlierMode}
|
||||
/>
|
||||
<Separator />
|
||||
<ScaleSection
|
||||
title="Gradient norm axis"
|
||||
scale={gradScale}
|
||||
setScale={setGradScale}
|
||||
outlierMode={gradOutlierMode}
|
||||
setOutlierMode={setGradOutlierMode}
|
||||
/>
|
||||
<Separator />
|
||||
<ScaleSection
|
||||
title="Learning rate axis"
|
||||
scale={lrScale}
|
||||
setScale={setLrScale}
|
||||
outlierMode={lrOutlierMode}
|
||||
setOutlierMode={setLrOutlierMode}
|
||||
/>
|
||||
</div>
|
||||
<SheetFooter className="mt-0 border-t border-border/60 bg-background/70 sm:flex-row sm:justify-between">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={resetPreferences}
|
||||
>
|
||||
Reset defaults
|
||||
</Button>
|
||||
<Button type="button" size="sm" onClick={() => setOpen(false)}>
|
||||
Done
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -11,7 +11,15 @@ import { ChartAverageIcon } from "@hugeicons/core-free-icons";
|
|||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import type { ReactElement } from "react";
|
||||
import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts";
|
||||
import { formatStepTick, placeholderEvalData } from "./utils";
|
||||
import {
|
||||
CHART_CONTAINER_CLASS,
|
||||
DEFAULT_CHART_MARGIN,
|
||||
DEFAULT_Y_AXIS_WIDTH,
|
||||
formatAxisMetric,
|
||||
formatMetric,
|
||||
formatStepTick,
|
||||
placeholderEvalData,
|
||||
} from "./utils";
|
||||
|
||||
const evalLossConfig = {
|
||||
loss: { label: "Eval Loss", color: "#ef4444" },
|
||||
|
|
@ -33,14 +41,18 @@ export function EvalLossChartCard({
|
|||
return (
|
||||
<Card data-tour="studio-eval-loss" size="sm">
|
||||
<CardHeader>
|
||||
<CardTitle className={`text-sm pl-2${data.length > 0 ? "" : " text-muted-foreground"}`}>
|
||||
<CardTitle className={`text-sm${data.length > 0 ? "" : " text-muted-foreground"}`}>
|
||||
Eval Loss
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{data.length > 0 ? (
|
||||
<ChartContainer config={evalLossConfig} className="-ml-3 h-[220px] w-full">
|
||||
<LineChart data={data} accessibilityLayer={true} margin={{ left: 0, right: 8 }}>
|
||||
<ChartContainer config={evalLossConfig} className={CHART_CONTAINER_CLASS}>
|
||||
<LineChart
|
||||
data={data}
|
||||
accessibilityLayer={true}
|
||||
margin={DEFAULT_CHART_MARGIN}
|
||||
>
|
||||
<CartesianGrid vertical={false} strokeDasharray="3 3" />
|
||||
<XAxis
|
||||
dataKey="step"
|
||||
|
|
@ -62,10 +74,11 @@ export function EvalLossChartCard({
|
|||
allowDataOverflow={true}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={4}
|
||||
tickMargin={8}
|
||||
tickCount={5}
|
||||
fontSize={10}
|
||||
width={52}
|
||||
tickFormatter={(value) => Number(value).toFixed(2)}
|
||||
width={DEFAULT_Y_AXIS_WIDTH}
|
||||
tickFormatter={(value) => formatAxisMetric(Number(value))}
|
||||
/>
|
||||
<ChartTooltip
|
||||
content={
|
||||
|
|
@ -73,6 +86,10 @@ export function EvalLossChartCard({
|
|||
labelFormatter={(_value, payload) =>
|
||||
`Step ${payload?.[0]?.payload?.step ?? ""}`
|
||||
}
|
||||
formatter={(_value, _name, item) => [
|
||||
formatMetric(Number(item?.payload?.loss)),
|
||||
"Eval Loss",
|
||||
]}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
|
@ -91,11 +108,14 @@ export function EvalLossChartCard({
|
|||
</ChartContainer>
|
||||
) : (
|
||||
<div className="relative">
|
||||
<ChartContainer config={evalLossConfig} className="-ml-3 h-[220px] w-full blur">
|
||||
<ChartContainer
|
||||
config={evalLossConfig}
|
||||
className={`${CHART_CONTAINER_CLASS} blur`}
|
||||
>
|
||||
<LineChart
|
||||
data={placeholderEvalData}
|
||||
accessibilityLayer={true}
|
||||
margin={{ left: 0, right: 8 }}
|
||||
margin={DEFAULT_CHART_MARGIN}
|
||||
>
|
||||
<CartesianGrid vertical={false} strokeDasharray="3 3" />
|
||||
<XAxis
|
||||
|
|
@ -111,9 +131,10 @@ export function EvalLossChartCard({
|
|||
<YAxis
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={4}
|
||||
tickMargin={8}
|
||||
tickCount={5}
|
||||
fontSize={10}
|
||||
width={40}
|
||||
width={DEFAULT_Y_AXIS_WIDTH}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
|
|
@ -126,7 +147,10 @@ export function EvalLossChartCard({
|
|||
</LineChart>
|
||||
</ChartContainer>
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center gap-1">
|
||||
<HugeiconsIcon icon={ChartAverageIcon} className="size-5 text-muted-foreground/50" />
|
||||
<HugeiconsIcon
|
||||
icon={ChartAverageIcon}
|
||||
className="size-5 text-muted-foreground/50"
|
||||
/>
|
||||
<p className="text-sm font-medium text-muted-foreground">
|
||||
{isTraining && evalEnabled
|
||||
? "Waiting for first evaluation step…"
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Card, CardAction, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
ChartContainer,
|
||||
ChartLegend,
|
||||
|
|
@ -7,19 +7,19 @@ import {
|
|||
ChartTooltipContent,
|
||||
} from "@/components/ui/chart";
|
||||
import type { ChartConfig } from "@/components/ui/chart";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Settings02Icon } from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import type { ReactElement } from "react";
|
||||
import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts";
|
||||
import { SharedChartSettings } from "./shared-chart-settings";
|
||||
import type { OutlierMode, ScaleMode, ViewSettingsState } from "./types";
|
||||
import { CHART_SYNC_ID, formatMetric, formatStepTick, fromLog1p } from "./utils";
|
||||
import type { ScaleMode } from "./types";
|
||||
import {
|
||||
CHART_SYNC_ID,
|
||||
CHART_CONTAINER_CLASS,
|
||||
DEFAULT_CHART_MARGIN,
|
||||
DEFAULT_Y_AXIS_WIDTH,
|
||||
formatAxisMetric,
|
||||
formatMetric,
|
||||
formatStepTick,
|
||||
fromLog1p,
|
||||
} from "./utils";
|
||||
|
||||
const gradNormConfig = {
|
||||
displayGradNorm: { label: "Grad Norm", color: "#f97316" },
|
||||
|
|
@ -37,56 +37,28 @@ export function GradNormChartCard({
|
|||
visibleStepDomain,
|
||||
xAxisTicks,
|
||||
scale,
|
||||
setScale,
|
||||
outlierMode,
|
||||
setOutlierMode,
|
||||
viewSettings,
|
||||
}: {
|
||||
data: GradNormPoint[];
|
||||
domain: [number, number];
|
||||
visibleStepDomain: [number, number];
|
||||
xAxisTicks: number[];
|
||||
scale: ScaleMode;
|
||||
setScale: (value: ScaleMode) => void;
|
||||
outlierMode: OutlierMode;
|
||||
setOutlierMode: (value: OutlierMode) => void;
|
||||
viewSettings: ViewSettingsState;
|
||||
}): ReactElement {
|
||||
const showPoint = data.length <= 1 ? { r: 3, strokeWidth: 0 } : false;
|
||||
|
||||
return (
|
||||
<Card size="sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm pl-2">Gradient Norm</CardTitle>
|
||||
<CardAction>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild={true}>
|
||||
<button
|
||||
type="button"
|
||||
className="cursor-pointer rounded-md p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
<HugeiconsIcon icon={Settings02Icon} className="size-3.5" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
<DropdownMenuLabel className="text-xs">Chart Settings</DropdownMenuLabel>
|
||||
<SharedChartSettings
|
||||
view={viewSettings}
|
||||
scale={scale}
|
||||
setScale={setScale}
|
||||
outlierMode={outlierMode}
|
||||
setOutlierMode={setOutlierMode}
|
||||
/>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</CardAction>
|
||||
<CardTitle className="text-sm">Gradient Norm</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ChartContainer config={gradNormConfig} className="-ml-3 h-[220px] w-full">
|
||||
<ChartContainer config={gradNormConfig} className={CHART_CONTAINER_CLASS}>
|
||||
<LineChart
|
||||
data={data}
|
||||
syncId={CHART_SYNC_ID}
|
||||
syncMethod="value"
|
||||
accessibilityLayer={true}
|
||||
margin={{ left: 0, right: 8 }}
|
||||
margin={DEFAULT_CHART_MARGIN}
|
||||
>
|
||||
<CartesianGrid vertical={false} strokeDasharray="3 3" />
|
||||
<XAxis
|
||||
|
|
@ -109,14 +81,17 @@ export function GradNormChartCard({
|
|||
allowDataOverflow={true}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={4}
|
||||
tickMargin={8}
|
||||
tickCount={5}
|
||||
fontSize={10}
|
||||
width={52}
|
||||
width={DEFAULT_Y_AXIS_WIDTH}
|
||||
tickFormatter={(value) => {
|
||||
const num = Number(value);
|
||||
if (!Number.isFinite(num)) return "0";
|
||||
if (!Number.isFinite(num)) {
|
||||
return "0";
|
||||
}
|
||||
const shown = scale === "log" ? fromLog1p(num) : num;
|
||||
return formatMetric(shown);
|
||||
return formatAxisMetric(shown);
|
||||
}}
|
||||
/>
|
||||
<ChartTooltip
|
||||
|
|
@ -133,11 +108,11 @@ export function GradNormChartCard({
|
|||
}
|
||||
/>
|
||||
<Line
|
||||
type="monotoneX"
|
||||
type="linear"
|
||||
dataKey="displayGradNorm"
|
||||
stroke="var(--color-displayGradNorm)"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
dot={showPoint}
|
||||
activeDot={{ r: 3, strokeWidth: 0 }}
|
||||
connectNulls={true}
|
||||
strokeLinecap="round"
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Card, CardAction, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
ChartContainer,
|
||||
ChartLegend,
|
||||
|
|
@ -7,19 +7,17 @@ import {
|
|||
ChartTooltipContent,
|
||||
} from "@/components/ui/chart";
|
||||
import type { ChartConfig } from "@/components/ui/chart";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Settings02Icon } from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import type { ReactElement } from "react";
|
||||
import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts";
|
||||
import { SharedChartSettings } from "./shared-chart-settings";
|
||||
import type { OutlierMode, ScaleMode, ViewSettingsState } from "./types";
|
||||
import { CHART_SYNC_ID, formatStepTick, fromLog1p } from "./utils";
|
||||
import type { ScaleMode } from "./types";
|
||||
import {
|
||||
CHART_CONTAINER_CLASS,
|
||||
CHART_SYNC_ID,
|
||||
DEFAULT_CHART_MARGIN,
|
||||
DEFAULT_Y_AXIS_WIDTH,
|
||||
formatStepTick,
|
||||
fromLog1p,
|
||||
} from "./utils";
|
||||
|
||||
const lrConfig = {
|
||||
displayLr: { label: "LR", color: "#8b5cf6" },
|
||||
|
|
@ -37,56 +35,28 @@ export function LearningRateChartCard({
|
|||
visibleStepDomain,
|
||||
xAxisTicks,
|
||||
scale,
|
||||
setScale,
|
||||
outlierMode,
|
||||
setOutlierMode,
|
||||
viewSettings,
|
||||
}: {
|
||||
data: LearningRatePoint[];
|
||||
domain: [number, number];
|
||||
visibleStepDomain: [number, number];
|
||||
xAxisTicks: number[];
|
||||
scale: ScaleMode;
|
||||
setScale: (value: ScaleMode) => void;
|
||||
outlierMode: OutlierMode;
|
||||
setOutlierMode: (value: OutlierMode) => void;
|
||||
viewSettings: ViewSettingsState;
|
||||
}): ReactElement {
|
||||
const showPoint = data.length <= 1 ? { r: 3, strokeWidth: 0 } : false;
|
||||
|
||||
return (
|
||||
<Card size="sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm pl-2">Learning Rate</CardTitle>
|
||||
<CardAction>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild={true}>
|
||||
<button
|
||||
type="button"
|
||||
className="cursor-pointer rounded-md p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
<HugeiconsIcon icon={Settings02Icon} className="size-3.5" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
<DropdownMenuLabel className="text-xs">Chart Settings</DropdownMenuLabel>
|
||||
<SharedChartSettings
|
||||
view={viewSettings}
|
||||
scale={scale}
|
||||
setScale={setScale}
|
||||
outlierMode={outlierMode}
|
||||
setOutlierMode={setOutlierMode}
|
||||
/>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</CardAction>
|
||||
<CardTitle className="text-sm">Learning Rate</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ChartContainer config={lrConfig} className="-ml-1.5 h-[220px] w-full">
|
||||
<ChartContainer config={lrConfig} className={CHART_CONTAINER_CLASS}>
|
||||
<LineChart
|
||||
data={data}
|
||||
syncId={CHART_SYNC_ID}
|
||||
syncMethod="value"
|
||||
accessibilityLayer={true}
|
||||
margin={{ left: 0, right: 8 }}
|
||||
margin={DEFAULT_CHART_MARGIN}
|
||||
>
|
||||
<CartesianGrid vertical={false} strokeDasharray="3 3" />
|
||||
<XAxis
|
||||
|
|
@ -109,12 +79,15 @@ export function LearningRateChartCard({
|
|||
allowDataOverflow={true}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={4}
|
||||
tickMargin={8}
|
||||
tickCount={5}
|
||||
fontSize={10}
|
||||
width={52}
|
||||
width={DEFAULT_Y_AXIS_WIDTH}
|
||||
tickFormatter={(value) => {
|
||||
const num = Number(value);
|
||||
if (!Number.isFinite(num)) return "0e+0";
|
||||
if (!Number.isFinite(num)) {
|
||||
return "0e+0";
|
||||
}
|
||||
const shown = scale === "log" ? fromLog1p(num) : num;
|
||||
return shown.toExponential(0);
|
||||
}}
|
||||
|
|
@ -136,11 +109,11 @@ export function LearningRateChartCard({
|
|||
}
|
||||
/>
|
||||
<Line
|
||||
type="monotoneX"
|
||||
type="linear"
|
||||
dataKey="displayLr"
|
||||
stroke="var(--color-displayLr)"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
dot={showPoint}
|
||||
activeDot={{ r: 3, strokeWidth: 0 }}
|
||||
connectNulls={true}
|
||||
strokeLinecap="round"
|
||||
|
|
|
|||
|
|
@ -1,80 +0,0 @@
|
|||
import { DropdownMenuCheckboxItem, DropdownMenuLabel, DropdownMenuSeparator } from "@/components/ui/dropdown-menu";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import type { ReactElement } from "react";
|
||||
import type { OutlierMode, ScaleMode, ViewSettingsState } from "./types";
|
||||
|
||||
export function SharedChartSettings({
|
||||
view,
|
||||
scale,
|
||||
setScale,
|
||||
outlierMode,
|
||||
setOutlierMode,
|
||||
}: {
|
||||
view: ViewSettingsState;
|
||||
scale: ScaleMode;
|
||||
setScale: (value: ScaleMode) => void;
|
||||
outlierMode: OutlierMode;
|
||||
setOutlierMode: (value: OutlierMode) => void;
|
||||
}): ReactElement {
|
||||
const showingAll = view.allStepsLength > 0 && view.effectiveWindowSize >= view.allStepsLength;
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel className="text-xs">View</DropdownMenuLabel>
|
||||
<div className="flex flex-col gap-1.5 px-2 py-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs">Window (steps)</Label>
|
||||
<span className="text-xs tabular-nums text-muted-foreground">
|
||||
{showingAll ? "All" : view.effectiveWindowSize}
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[view.effectiveWindowSize]}
|
||||
onValueChange={([v]) => view.setWindowSize(Math.max(1, Math.round(v)))}
|
||||
min={view.minWindow}
|
||||
max={Math.max(view.minWindow, view.allStepsLength)}
|
||||
step={1}
|
||||
/>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
Always follows latest steps
|
||||
</span>
|
||||
</div>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel className="text-xs">Y Scale</DropdownMenuLabel>
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={scale === "linear"}
|
||||
onCheckedChange={(checked) => checked && setScale("linear")}
|
||||
>
|
||||
Linear
|
||||
</DropdownMenuCheckboxItem>
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={scale === "log"}
|
||||
onCheckedChange={(checked) => checked && setScale("log")}
|
||||
>
|
||||
Log (log1p)
|
||||
</DropdownMenuCheckboxItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel className="text-xs">Outliers</DropdownMenuLabel>
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={outlierMode === "none"}
|
||||
onCheckedChange={(checked) => checked && setOutlierMode("none")}
|
||||
>
|
||||
No clipping
|
||||
</DropdownMenuCheckboxItem>
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={outlierMode === "p99"}
|
||||
onCheckedChange={(checked) => checked && setOutlierMode("p99")}
|
||||
>
|
||||
Clip above p99
|
||||
</DropdownMenuCheckboxItem>
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={outlierMode === "p95"}
|
||||
onCheckedChange={(checked) => checked && setOutlierMode("p95")}
|
||||
>
|
||||
Clip above p95
|
||||
</DropdownMenuCheckboxItem>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { Card, CardAction, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
ChartContainer,
|
||||
ChartLegend,
|
||||
|
|
@ -7,23 +7,26 @@ import {
|
|||
ChartTooltipContent,
|
||||
} from "@/components/ui/chart";
|
||||
import type { ChartConfig } from "@/components/ui/chart";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import { Settings02Icon } from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import type { ReactElement } from "react";
|
||||
import { CartesianGrid, Line, LineChart, ReferenceLine, XAxis, YAxis } from "recharts";
|
||||
import { SharedChartSettings } from "./shared-chart-settings";
|
||||
import type { OutlierMode, ScaleMode, ViewSettingsState } from "./types";
|
||||
import { CHART_SYNC_ID, formatMetric, formatStepTick, fromLog1p } from "./utils";
|
||||
import {
|
||||
CartesianGrid,
|
||||
Line,
|
||||
LineChart,
|
||||
ReferenceLine,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
import type { ScaleMode } from "./types";
|
||||
import {
|
||||
CHART_SYNC_ID,
|
||||
CHART_CONTAINER_CLASS,
|
||||
DEFAULT_CHART_MARGIN,
|
||||
DEFAULT_Y_AXIS_WIDTH,
|
||||
formatAxisMetric,
|
||||
formatMetric,
|
||||
formatStepTick,
|
||||
fromLog1p,
|
||||
} from "./utils";
|
||||
|
||||
const lossConfig = {
|
||||
displayLoss: { label: "Loss", color: "#3b82f6" },
|
||||
|
|
@ -45,19 +48,10 @@ export function TrainingLossChartCard({
|
|||
xAxisTicks,
|
||||
avgRaw,
|
||||
avgDisplay,
|
||||
smoothing,
|
||||
setSmoothing,
|
||||
showRaw,
|
||||
setShowRaw,
|
||||
showSmoothed,
|
||||
setShowSmoothed,
|
||||
showAvgLine,
|
||||
setShowAvgLine,
|
||||
viewSettings,
|
||||
scale,
|
||||
setScale,
|
||||
outlierMode,
|
||||
setOutlierMode,
|
||||
}: {
|
||||
data: LossChartPoint[];
|
||||
domain: [number, number];
|
||||
|
|
@ -65,90 +59,26 @@ export function TrainingLossChartCard({
|
|||
xAxisTicks: number[];
|
||||
avgRaw: number;
|
||||
avgDisplay: number;
|
||||
smoothing: number;
|
||||
setSmoothing: (value: number) => void;
|
||||
showRaw: boolean;
|
||||
setShowRaw: (value: boolean) => void;
|
||||
showSmoothed: boolean;
|
||||
setShowSmoothed: (value: boolean) => void;
|
||||
showAvgLine: boolean;
|
||||
setShowAvgLine: (value: boolean) => void;
|
||||
viewSettings: ViewSettingsState;
|
||||
scale: ScaleMode;
|
||||
setScale: (value: ScaleMode) => void;
|
||||
outlierMode: OutlierMode;
|
||||
setOutlierMode: (value: OutlierMode) => void;
|
||||
}): ReactElement {
|
||||
const showPoint = data.length <= 1 ? { r: 3, strokeWidth: 0 } : false;
|
||||
|
||||
return (
|
||||
<Card data-tour="studio-training-loss" size="sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm pl-2">Training Loss</CardTitle>
|
||||
<CardAction>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild={true}>
|
||||
<button
|
||||
type="button"
|
||||
className="cursor-pointer rounded-md p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
<HugeiconsIcon icon={Settings02Icon} className="size-3.5" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
<DropdownMenuLabel className="text-xs">Chart Settings</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<div className="flex flex-col gap-1.5 px-2 py-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs">Smoothing</Label>
|
||||
<span className="text-xs tabular-nums text-muted-foreground">
|
||||
{smoothing.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[smoothing]}
|
||||
onValueChange={([v]) => setSmoothing(v)}
|
||||
min={0}
|
||||
max={0.99}
|
||||
step={0.01}
|
||||
/>
|
||||
</div>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={showRaw}
|
||||
onCheckedChange={(value) => setShowRaw(Boolean(value))}
|
||||
>
|
||||
Show raw loss
|
||||
</DropdownMenuCheckboxItem>
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={showSmoothed}
|
||||
onCheckedChange={(value) => setShowSmoothed(Boolean(value))}
|
||||
>
|
||||
Show smoothed loss
|
||||
</DropdownMenuCheckboxItem>
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={showAvgLine}
|
||||
onCheckedChange={(value) => setShowAvgLine(Boolean(value))}
|
||||
>
|
||||
Show average line
|
||||
</DropdownMenuCheckboxItem>
|
||||
<SharedChartSettings
|
||||
view={viewSettings}
|
||||
scale={scale}
|
||||
setScale={setScale}
|
||||
outlierMode={outlierMode}
|
||||
setOutlierMode={setOutlierMode}
|
||||
/>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</CardAction>
|
||||
<CardTitle className="text-sm">Training Loss</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ChartContainer config={lossConfig} className="-ml-3 h-[220px] w-full">
|
||||
<ChartContainer config={lossConfig} className={CHART_CONTAINER_CLASS}>
|
||||
<LineChart
|
||||
data={data}
|
||||
syncId={CHART_SYNC_ID}
|
||||
syncMethod="value"
|
||||
accessibilityLayer={true}
|
||||
margin={{ left: 0, right: 8 }}
|
||||
margin={DEFAULT_CHART_MARGIN}
|
||||
>
|
||||
<CartesianGrid vertical={false} strokeDasharray="3 3" />
|
||||
<XAxis
|
||||
|
|
@ -171,14 +101,17 @@ export function TrainingLossChartCard({
|
|||
allowDataOverflow={true}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={4}
|
||||
tickMargin={8}
|
||||
tickCount={5}
|
||||
fontSize={10}
|
||||
width={52}
|
||||
width={DEFAULT_Y_AXIS_WIDTH}
|
||||
tickFormatter={(value) => {
|
||||
const num = Number(value);
|
||||
if (!Number.isFinite(num)) return "0";
|
||||
if (!Number.isFinite(num)) {
|
||||
return "0";
|
||||
}
|
||||
const shown = scale === "log" ? fromLog1p(num) : num;
|
||||
return formatMetric(shown);
|
||||
return formatAxisMetric(shown);
|
||||
}}
|
||||
/>
|
||||
<ChartTooltip
|
||||
|
|
@ -189,7 +122,10 @@ export function TrainingLossChartCard({
|
|||
}
|
||||
formatter={(_value, name, item) => {
|
||||
if (name === "displaySmoothed") {
|
||||
return [formatMetric(Number(item?.payload?.smoothed)), "Smoothed"];
|
||||
return [
|
||||
formatMetric(Number(item?.payload?.smoothed)),
|
||||
"Smoothed",
|
||||
];
|
||||
}
|
||||
return [formatMetric(Number(item?.payload?.loss)), "Loss"];
|
||||
}}
|
||||
|
|
@ -212,12 +148,12 @@ export function TrainingLossChartCard({
|
|||
)}
|
||||
{showRaw && (
|
||||
<Line
|
||||
type="monotoneX"
|
||||
type="linear"
|
||||
dataKey="displayLoss"
|
||||
stroke="var(--color-displayLoss)"
|
||||
strokeWidth={1.2}
|
||||
strokeOpacity={showSmoothed ? 0.35 : 1}
|
||||
dot={false}
|
||||
dot={showPoint}
|
||||
activeDot={{ r: 3, strokeWidth: 0 }}
|
||||
connectNulls={true}
|
||||
strokeLinecap="round"
|
||||
|
|
@ -227,11 +163,11 @@ export function TrainingLossChartCard({
|
|||
)}
|
||||
{showSmoothed && (
|
||||
<Line
|
||||
type="monotoneX"
|
||||
type="linear"
|
||||
dataKey="displaySmoothed"
|
||||
stroke="var(--color-displaySmoothed)"
|
||||
strokeWidth={2.2}
|
||||
dot={false}
|
||||
dot={showPoint}
|
||||
activeDot={{ r: 3, strokeWidth: 0 }}
|
||||
connectNulls={true}
|
||||
strokeLinecap="round"
|
||||
|
|
|
|||
|
|
@ -10,10 +10,3 @@ export interface TrainingChartSeries {
|
|||
gradNormHistory: { step: number; gradNorm: number }[];
|
||||
evalLossHistory: { step: number; loss: number }[];
|
||||
}
|
||||
|
||||
export interface ViewSettingsState {
|
||||
effectiveWindowSize: number;
|
||||
minWindow: number;
|
||||
allStepsLength: number;
|
||||
setWindowSize: (value: number) => void;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,11 @@ import type { LossHistoryItem, OutlierMode, SmoothedLossItem } from "./types";
|
|||
export const CHART_SYNC_ID = "train-metrics-sync";
|
||||
export const MAX_RENDER_POINTS = 800;
|
||||
export const DEFAULT_VISIBLE_POINTS = 160;
|
||||
export const CHART_CONTAINER_CLASS = "h-[220px] w-full";
|
||||
export const DEFAULT_CHART_MARGIN = { top: 4, right: 8, bottom: 0, left: 4 };
|
||||
export const DEFAULT_Y_AXIS_WIDTH = 41;
|
||||
const TRAILING_ZEROES_RE = /\.?0+$/;
|
||||
const NEGATIVE_ZERO_RE = /^-0$/;
|
||||
|
||||
export const placeholderEvalData = [
|
||||
{ step: 0, loss: 2.8 },
|
||||
|
|
@ -22,11 +27,56 @@ export function fromLog1p(value: number): number {
|
|||
}
|
||||
|
||||
export function formatMetric(value: number): string {
|
||||
if (!Number.isFinite(value)) return "0";
|
||||
if (value === 0) return "0";
|
||||
if (value >= 1000) return value.toFixed(0);
|
||||
if (value >= 1) return value.toFixed(2);
|
||||
return value.toExponential(2);
|
||||
if (!Number.isFinite(value)) {
|
||||
return "0";
|
||||
}
|
||||
const abs = Math.abs(value);
|
||||
let decimals = 6;
|
||||
|
||||
if (abs >= 1000) {
|
||||
decimals = 0;
|
||||
} else if (abs >= 100) {
|
||||
decimals = 2;
|
||||
} else if (abs >= 1) {
|
||||
decimals = 4;
|
||||
} else if (abs >= 0.01) {
|
||||
decimals = 5;
|
||||
} else if (abs >= 0.0001) {
|
||||
decimals = 6;
|
||||
} else {
|
||||
decimals = 8;
|
||||
}
|
||||
|
||||
return value
|
||||
.toFixed(decimals)
|
||||
.replace(TRAILING_ZEROES_RE, "")
|
||||
.replace(NEGATIVE_ZERO_RE, "0");
|
||||
}
|
||||
|
||||
export function formatAxisMetric(value: number): string {
|
||||
if (!Number.isFinite(value)) {
|
||||
return "0";
|
||||
}
|
||||
|
||||
const abs = Math.abs(value);
|
||||
let decimals = 4;
|
||||
|
||||
if (abs >= 1000) {
|
||||
decimals = 0;
|
||||
} else if (abs >= 100) {
|
||||
decimals = 1;
|
||||
} else if (abs >= 1) {
|
||||
decimals = 3;
|
||||
} else if (abs >= 0.01) {
|
||||
decimals = 4;
|
||||
} else {
|
||||
decimals = 5;
|
||||
}
|
||||
|
||||
return value
|
||||
.toFixed(decimals)
|
||||
.replace(TRAILING_ZEROES_RE, "")
|
||||
.replace(NEGATIVE_ZERO_RE, "0");
|
||||
}
|
||||
|
||||
export function formatStepTick(value: number): string {
|
||||
|
|
@ -54,18 +104,12 @@ export function clamp(value: number, min: number, max: number): number {
|
|||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
export function getDefaultWindowSize(totalSteps: number): number {
|
||||
if (totalSteps <= 1) {
|
||||
return Math.max(totalSteps, 1);
|
||||
}
|
||||
if (totalSteps <= DEFAULT_VISIBLE_POINTS) {
|
||||
return clamp(Math.floor(totalSteps * 0.6), 1, totalSteps);
|
||||
}
|
||||
return DEFAULT_VISIBLE_POINTS;
|
||||
}
|
||||
|
||||
export function buildStepTicks(min: number, max: number, targetCount = 6): number[] {
|
||||
if (!Number.isFinite(min) || !Number.isFinite(max)) {
|
||||
export function buildStepTicks(
|
||||
min: number,
|
||||
max: number,
|
||||
targetCount = 6,
|
||||
): number[] {
|
||||
if (!(Number.isFinite(min) && Number.isFinite(max))) {
|
||||
return [0, 1];
|
||||
}
|
||||
if (max <= min) {
|
||||
|
|
@ -104,10 +148,17 @@ export function buildYDomain(values: number[]): [number, number] {
|
|||
return [min - pad, max + pad];
|
||||
}
|
||||
|
||||
function getUpperPercentile(values: number[], mode: OutlierMode): number | null {
|
||||
if (mode === "none") return null;
|
||||
function getUpperPercentile(
|
||||
values: number[],
|
||||
mode: OutlierMode,
|
||||
): number | null {
|
||||
if (mode === "none") {
|
||||
return null;
|
||||
}
|
||||
const finiteValues = values.filter((value) => Number.isFinite(value));
|
||||
if (finiteValues.length < 3) return null;
|
||||
if (finiteValues.length < 3) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sorted = [...finiteValues].sort((a, b) => a - b);
|
||||
const q = mode === "p99" ? 0.99 : 0.95;
|
||||
|
|
@ -120,18 +171,36 @@ function getUpperPercentile(values: number[], mode: OutlierMode): number | null
|
|||
|
||||
export function applyOutlierCap(values: number[], mode: OutlierMode): number[] {
|
||||
const cap = getUpperPercentile(values, mode);
|
||||
if (cap == null) return values;
|
||||
if (cap == null) {
|
||||
return values;
|
||||
}
|
||||
return values.map((value) => Math.min(value, cap));
|
||||
}
|
||||
|
||||
export function ema(data: LossHistoryItem[], alpha: number): SmoothedLossItem[] {
|
||||
export function ema(
|
||||
data: LossHistoryItem[],
|
||||
alpha: number,
|
||||
): SmoothedLossItem[] {
|
||||
if (data.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
let s = data[0].loss;
|
||||
return data.map((d) => {
|
||||
s = alpha * d.loss + (1 - alpha) * s;
|
||||
return { ...d, smoothed: +s.toFixed(4) };
|
||||
const values = data.map((point) => point.loss);
|
||||
const isConstant = values.every((value) => value === values[0]);
|
||||
|
||||
let last = 0;
|
||||
let count = 0;
|
||||
|
||||
return data.map((point) => {
|
||||
const next = point.loss;
|
||||
if (!Number.isFinite(next) || isConstant) {
|
||||
return { ...point, smoothed: next };
|
||||
}
|
||||
|
||||
last = last * alpha + (1 - alpha) * next;
|
||||
count += 1;
|
||||
|
||||
const debias = alpha === 1 ? 1 : 1 - alpha ** count;
|
||||
return { ...point, smoothed: last / debias };
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,11 +15,16 @@ import {
|
|||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { OPTIMIZER_OPTIONS } from "@/config/training";
|
||||
import { setTrainingCompareHandoff } from "@/features/chat";
|
||||
import {
|
||||
useTrainingConfigStore,
|
||||
useTrainingActions,
|
||||
useTrainingConfigStore,
|
||||
useTrainingRuntimeStore,
|
||||
} from "@/features/training";
|
||||
import { useGpuUtilization } from "@/hooks";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
ChartAverageIcon,
|
||||
DashboardSpeed01Icon,
|
||||
|
|
@ -30,13 +35,28 @@ import {
|
|||
ZapIcon,
|
||||
} from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { useEffect, useState, type ReactElement, type ReactNode } from "react";
|
||||
import { Link, useNavigate } from "@tanstack/react-router";
|
||||
import { type ReactElement, type ReactNode, useEffect, useState } from "react";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { useGpuUtilization } from "@/hooks";
|
||||
import { setTrainingCompareHandoff } from "@/features/chat";
|
||||
import { OPTIMIZER_OPTIONS } from "@/config/training";
|
||||
import { formatDuration, formatNumber, phaseColors, phaseLabel } from "./progress-section-lib";
|
||||
import { ChartSettingsSheet } from "./charts/chart-settings-sheet";
|
||||
import {
|
||||
formatDuration,
|
||||
formatNumber,
|
||||
phaseColors,
|
||||
phaseLabel,
|
||||
} from "./progress-section-lib";
|
||||
|
||||
type ConfigGroup = {
|
||||
section: string;
|
||||
rows: [string, string | number | null | undefined][];
|
||||
};
|
||||
|
||||
function configRow(
|
||||
label: string,
|
||||
value: string | number | null | undefined,
|
||||
): [string, string | number | null | undefined] {
|
||||
return [label, value];
|
||||
}
|
||||
|
||||
export function ProgressSection(): ReactElement {
|
||||
const navigate = useNavigate();
|
||||
|
|
@ -94,12 +114,12 @@ export function ProgressSection(): ReactElement {
|
|||
const pct =
|
||||
runtime.totalSteps > 0
|
||||
? Math.min(
|
||||
100,
|
||||
Math.max(
|
||||
0,
|
||||
Math.round((runtime.currentStep / runtime.totalSteps) * 100),
|
||||
),
|
||||
)
|
||||
100,
|
||||
Math.max(
|
||||
0,
|
||||
Math.round((runtime.currentStep / runtime.totalSteps) * 100),
|
||||
),
|
||||
)
|
||||
: Math.round(runtime.progressPercent);
|
||||
|
||||
const elapsed = runtime.elapsedSeconds;
|
||||
|
|
@ -110,15 +130,26 @@ export function ProgressSection(): ReactElement {
|
|||
const eta = runtime.etaSeconds ?? derivedEta;
|
||||
|
||||
const stepsPerSecond =
|
||||
elapsed != null && elapsed > 0
|
||||
? runtime.currentStep / elapsed
|
||||
: null;
|
||||
elapsed != null && elapsed > 0 ? runtime.currentStep / elapsed : null;
|
||||
const showHalfwayHint =
|
||||
runtime.phase === "training" && pct >= 50 && pct < 100;
|
||||
const showCompletedHint = runtime.phase === "completed";
|
||||
const handleCompareInChat = () => {
|
||||
const handleCompareInChat = async () => {
|
||||
setTrainingCompareHandoff(config.selectedModel);
|
||||
void navigate({ to: "/chat" });
|
||||
await navigate({ to: "/chat" });
|
||||
};
|
||||
const requestStop = async (saveCheckpoint: boolean) => {
|
||||
setStopRequested(true);
|
||||
setStopDialogOpen(false);
|
||||
useTrainingRuntimeStore.getState().setStopRequested(true);
|
||||
try {
|
||||
const ok = await stopTrainingRun(saveCheckpoint);
|
||||
if (!ok) {
|
||||
setStopRequested(false);
|
||||
}
|
||||
} catch {
|
||||
setStopRequested(false);
|
||||
}
|
||||
};
|
||||
|
||||
const stoppedLoss = getDisplayMetric(
|
||||
|
|
@ -133,37 +164,37 @@ export function ProgressSection(): ReactElement {
|
|||
);
|
||||
const stoppedGradNorm = runtime.isTrainingRunning
|
||||
? runtime.currentGradNorm
|
||||
: lastNonZeroValue(runtime.gradNormHistory) ?? runtime.currentGradNorm;
|
||||
: (lastNonZeroValue(runtime.gradNormHistory) ?? runtime.currentGradNorm);
|
||||
|
||||
const optimizerLabel =
|
||||
OPTIMIZER_OPTIONS.find((o) => o.value === config.optimizerType)?.label ??
|
||||
config.optimizerType;
|
||||
|
||||
const configItems = [
|
||||
const configItems: ConfigGroup[] = [
|
||||
{
|
||||
section: "Hyperparams",
|
||||
rows: [
|
||||
["Epochs", config.epochs],
|
||||
["Batch size", config.batchSize],
|
||||
["Learning rate", config.learningRate],
|
||||
["Optimizer", optimizerLabel],
|
||||
["Max steps", config.maxSteps],
|
||||
["Context length", config.contextLength],
|
||||
["Warmup steps", config.warmupSteps],
|
||||
configRow("Epochs", config.epochs),
|
||||
configRow("Batch size", config.batchSize),
|
||||
configRow("Learning rate", config.learningRate),
|
||||
configRow("Optimizer", optimizerLabel),
|
||||
configRow("Max steps", config.maxSteps),
|
||||
configRow("Context length", config.contextLength),
|
||||
configRow("Warmup steps", config.warmupSteps),
|
||||
],
|
||||
},
|
||||
...(config.trainingMethod !== "full"
|
||||
? [
|
||||
{
|
||||
section: "LoRA",
|
||||
rows: [
|
||||
["Rank", config.loraRank],
|
||||
["Alpha", config.loraAlpha],
|
||||
["Dropout", config.loraDropout],
|
||||
["Variant", config.loraVariant],
|
||||
],
|
||||
},
|
||||
]
|
||||
{
|
||||
section: "LoRA",
|
||||
rows: [
|
||||
configRow("Rank", config.loraRank),
|
||||
configRow("Alpha", config.loraAlpha),
|
||||
configRow("Dropout", config.loraDropout),
|
||||
configRow("Variant", config.loraVariant),
|
||||
],
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
|
|
@ -173,182 +204,76 @@ export function ProgressSection(): ReactElement {
|
|||
title="Training Progress"
|
||||
description={runtime.message || "Live training metrics"}
|
||||
accent="emerald"
|
||||
className="shadow-border ring-1 ring-border"
|
||||
className="shadow-border border border-border/60 bg-card/90 ring-0 backdrop-blur-sm"
|
||||
headerAction={
|
||||
<div className="flex items-center gap-2">
|
||||
<Popover>
|
||||
<PopoverTrigger asChild={true}>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md p-1 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors cursor-pointer"
|
||||
>
|
||||
<HugeiconsIcon icon={Notebook01Icon} className="size-4" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-64" align="end">
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="text-xs font-semibold">Training Config</p>
|
||||
{configItems.map((group) => (
|
||||
<div key={group.section} className="flex flex-col gap-1">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{group.section}
|
||||
</p>
|
||||
{group.rows.map(([label, value]) => (
|
||||
<div
|
||||
key={String(label)}
|
||||
className="flex justify-between text-xs"
|
||||
>
|
||||
<span className="text-muted-foreground">
|
||||
{String(label)}
|
||||
</span>
|
||||
<span className="font-medium tabular-nums">
|
||||
{String(value)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<AlertDialog open={stopDialogOpen} onOpenChange={setStopDialogOpen}>
|
||||
<Button
|
||||
data-tour="studio-training-stop"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className={`h-7 px-3 text-xs ${stopRequested ? "cursor-not-allowed opacity-60" : "cursor-pointer"}`}
|
||||
onClick={() => setStopDialogOpen(true)}
|
||||
disabled={!runtime.isTrainingRunning || stopRequested}
|
||||
>
|
||||
<HugeiconsIcon icon={StopIcon} className="size-3" />
|
||||
{stopRequested ? "Stopping…" : "Stop"}
|
||||
</Button>
|
||||
<AlertDialogContent overlayClassName="bg-background/40 supports-backdrop-filter:backdrop-blur-[1px]">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Stop Training</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Choose how you want to stop the current training run.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Continue Training</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
setStopRequested(true);
|
||||
setStopDialogOpen(false);
|
||||
useTrainingRuntimeStore.getState().setStopRequested(true);
|
||||
void stopTrainingRun(false).then((ok) => {
|
||||
if (!ok) setStopRequested(false);
|
||||
});
|
||||
}}
|
||||
>
|
||||
Cancel Training
|
||||
</AlertDialogAction>
|
||||
<AlertDialogAction
|
||||
onClick={() => {
|
||||
setStopRequested(true);
|
||||
setStopDialogOpen(false);
|
||||
useTrainingRuntimeStore.getState().setStopRequested(true);
|
||||
void stopTrainingRun(true).then((ok) => {
|
||||
if (!ok) setStopRequested(false);
|
||||
});
|
||||
}}
|
||||
>
|
||||
Stop and Save
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
<TrainingHeaderActions
|
||||
configItems={configItems}
|
||||
isTrainingRunning={runtime.isTrainingRunning}
|
||||
onOpenStopDialog={setStopDialogOpen}
|
||||
onRequestStop={requestStop}
|
||||
stopDialogOpen={stopDialogOpen}
|
||||
stopRequested={stopRequested}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
<div className="grid grid-cols-1 gap-5 lg:grid-cols-[minmax(0,1.2fr)_minmax(18rem,0.8fr)]">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span
|
||||
className={`rounded-full px-2.5 py-0.5 text-[10px] font-semibold ${phaseColors[runtime.phase]}`}
|
||||
className={`rounded-full px-2.5 py-1 text-[10px] font-semibold ${phaseColors[runtime.phase]}`}
|
||||
>
|
||||
{phaseLabel[runtime.phase]}
|
||||
</span>
|
||||
<span className="text-[10px] tabular-nums text-muted-foreground">
|
||||
Epoch {runtime.currentEpoch.toFixed(2)}
|
||||
</span>
|
||||
<span className="rounded-full border border-border/60 px-2.5 py-1 text-[10px] font-medium tabular-nums text-muted-foreground">
|
||||
{pct}% complete
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex justify-between text-xs text-muted-foreground">
|
||||
<span>
|
||||
Step {runtime.currentStep} / {runtime.totalSteps || "--"}
|
||||
</span>
|
||||
<span>{pct}%</span>
|
||||
</div>
|
||||
<div className="h-2.5 w-full rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full bg-gradient-to-r from-emerald-500 to-teal-400 transition-all duration-300"
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<Progress value={pct} className="h-2 bg-foreground/[0.05]" />
|
||||
</div>
|
||||
|
||||
{(showHalfwayHint || showCompletedHint) && (
|
||||
<div className="rounded-xl border border-emerald-500/25 bg-emerald-500/8 p-3">
|
||||
<p className="text-xs font-medium text-emerald-900 dark:text-emerald-200">
|
||||
{showCompletedHint
|
||||
? "Training done. Next step: compare base vs fine-tuned outputs."
|
||||
: "Halfway done. Training is past 50%."}
|
||||
</p>
|
||||
{showCompletedHint && (
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
<Button size="xs" onClick={handleCompareInChat}>
|
||||
Compare in Chat
|
||||
</Button>
|
||||
<Button asChild={true} size="xs" variant="outline">
|
||||
<Link to="/export">Export Model</Link>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<MilestoneCallout
|
||||
showCompletedHint={showCompletedHint}
|
||||
showHalfwayHint={showHalfwayHint}
|
||||
onCompareInChat={handleCompareInChat}
|
||||
/>
|
||||
|
||||
{runtime.error && (
|
||||
<p className="text-xs text-red-500 leading-relaxed">{runtime.error}</p>
|
||||
<p className="rounded-2xl border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs text-red-500 leading-relaxed">
|
||||
{runtime.error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-baseline gap-4">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Loss</p>
|
||||
<p className="text-3xl font-bold tabular-nums tracking-tight">
|
||||
{stoppedLoss.toFixed(4)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">LR</p>
|
||||
<p className="text-lg font-semibold tabular-nums">
|
||||
{stoppedLr.toExponential(2)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Grad Norm</p>
|
||||
<p className="text-lg font-semibold tabular-nums">
|
||||
{formatNumber(stoppedGradNorm, 3)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Model</p>
|
||||
<p className="text-lg font-semibold truncate max-w-[140px]">
|
||||
{config.selectedModel ?? "--"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Method</p>
|
||||
<p className="text-lg font-semibold">
|
||||
{config.trainingMethod.toUpperCase()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-x-4 gap-y-3 pt-1 sm:grid-cols-2 xl:grid-cols-5">
|
||||
<MetricStat
|
||||
label="Loss"
|
||||
valueClassName="text-2xl font-bold tracking-tight"
|
||||
>
|
||||
{stoppedLoss.toFixed(4)}
|
||||
</MetricStat>
|
||||
<MetricStat label="LR">{stoppedLr.toExponential(2)}</MetricStat>
|
||||
<MetricStat label="Grad Norm">
|
||||
{formatNumber(stoppedGradNorm, 3)}
|
||||
</MetricStat>
|
||||
<MetricStat label="Model" valueClassName="truncate">
|
||||
{config.selectedModel ?? "--"}
|
||||
</MetricStat>
|
||||
<MetricStat label="Method">
|
||||
{config.trainingMethod.toUpperCase()}
|
||||
</MetricStat>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-4 text-xs text-muted-foreground">
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||||
<span>Elapsed: {formatDuration(elapsed)}</span>
|
||||
<span>ETA: {formatDuration(eta)}</span>
|
||||
<span>
|
||||
|
|
@ -363,8 +288,13 @@ export function ProgressSection(): ReactElement {
|
|||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="text-xs font-medium text-muted-foreground">GPU Monitor</p>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs font-medium text-muted-foreground">
|
||||
GPU Monitor
|
||||
</p>
|
||||
<span className="text-[11px] text-muted-foreground">Live</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2.5">
|
||||
<GpuStat
|
||||
label="Utilization"
|
||||
icon={
|
||||
|
|
@ -373,26 +303,44 @@ export function ProgressSection(): ReactElement {
|
|||
className="size-3.5"
|
||||
/>
|
||||
}
|
||||
value={gpu.gpu_utilization_pct != null ? `${gpu.gpu_utilization_pct}%` : "--"}
|
||||
value={
|
||||
gpu.gpu_utilization_pct != null
|
||||
? `${gpu.gpu_utilization_pct}%`
|
||||
: "--"
|
||||
}
|
||||
pct={gpu.gpu_utilization_pct ?? 0}
|
||||
/>
|
||||
<GpuStat
|
||||
label="Temperature"
|
||||
icon={<HugeiconsIcon icon={TemperatureIcon} className="size-3.5" />}
|
||||
value={gpu.temperature_c != null ? `${gpu.temperature_c}°C` : "--"}
|
||||
icon={
|
||||
<HugeiconsIcon icon={TemperatureIcon} className="size-3.5" />
|
||||
}
|
||||
value={
|
||||
gpu.temperature_c != null ? `${gpu.temperature_c}°C` : "--"
|
||||
}
|
||||
pct={gpu.temperature_c ?? 0}
|
||||
max={100}
|
||||
/>
|
||||
<GpuStat
|
||||
label="VRAM"
|
||||
icon={<HugeiconsIcon icon={RamMemoryIcon} className="size-3.5" />}
|
||||
value={gpu.vram_used_gb != null && gpu.vram_total_gb != null ? `${gpu.vram_used_gb} / ${gpu.vram_total_gb} GB` : "--"}
|
||||
value={
|
||||
gpu.vram_used_gb != null && gpu.vram_total_gb != null
|
||||
? `${gpu.vram_used_gb} / ${gpu.vram_total_gb} GB`
|
||||
: "--"
|
||||
}
|
||||
pct={gpu.vram_utilization_pct ?? 0}
|
||||
/>
|
||||
<GpuStat
|
||||
label="Power"
|
||||
icon={<HugeiconsIcon icon={ZapIcon} className="size-3.5" />}
|
||||
value={gpu.power_draw_w != null ? (gpu.power_limit_w != null ? `${gpu.power_draw_w} / ${gpu.power_limit_w} W` : `${gpu.power_draw_w} W`) : "--"}
|
||||
value={
|
||||
gpu.power_draw_w != null
|
||||
? gpu.power_limit_w != null
|
||||
? `${gpu.power_draw_w} / ${gpu.power_limit_w} W`
|
||||
: `${gpu.power_draw_w} W`
|
||||
: "--"
|
||||
}
|
||||
pct={gpu.power_utilization_pct ?? 0}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -402,6 +350,171 @@ export function ProgressSection(): ReactElement {
|
|||
);
|
||||
}
|
||||
|
||||
function TrainingHeaderActions({
|
||||
configItems,
|
||||
isTrainingRunning,
|
||||
onOpenStopDialog,
|
||||
onRequestStop,
|
||||
stopDialogOpen,
|
||||
stopRequested,
|
||||
}: {
|
||||
configItems: ConfigGroup[];
|
||||
isTrainingRunning: boolean;
|
||||
onOpenStopDialog: (open: boolean) => void;
|
||||
onRequestStop: (saveCheckpoint: boolean) => Promise<void>;
|
||||
stopDialogOpen: boolean;
|
||||
stopRequested: boolean;
|
||||
}): ReactElement {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Popover>
|
||||
<PopoverTrigger asChild={true}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="rounded-full text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
aria-label="Open training config"
|
||||
>
|
||||
<HugeiconsIcon icon={Notebook01Icon} className="size-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-72" align="end">
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="text-xs font-semibold">Training Config</p>
|
||||
{configItems.map((group) => (
|
||||
<div key={group.section} className="flex flex-col gap-1">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{group.section}
|
||||
</p>
|
||||
{group.rows.map(([label, value]) => (
|
||||
<div key={label} className="flex justify-between text-xs">
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
<span className="font-medium tabular-nums">
|
||||
{String(value)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<ChartSettingsSheet />
|
||||
<AlertDialog open={stopDialogOpen} onOpenChange={onOpenStopDialog}>
|
||||
<Button
|
||||
data-tour="studio-training-stop"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-8 rounded-full px-3.5 text-xs shadow-sm",
|
||||
stopRequested ? "cursor-not-allowed opacity-60" : "cursor-pointer",
|
||||
)}
|
||||
onClick={() => onOpenStopDialog(true)}
|
||||
disabled={!isTrainingRunning || stopRequested}
|
||||
>
|
||||
<HugeiconsIcon icon={StopIcon} className="size-3" />
|
||||
{stopRequested ? "Stopping…" : "Stop"}
|
||||
</Button>
|
||||
<AlertDialogContent overlayClassName="bg-background/40 supports-backdrop-filter:backdrop-blur-[1px]">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Stop Training</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Choose how you want to stop the current training run.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Continue Training</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
onClick={() => onRequestStop(false)}
|
||||
>
|
||||
Cancel Training
|
||||
</AlertDialogAction>
|
||||
<AlertDialogAction onClick={() => onRequestStop(true)}>
|
||||
Stop and Save
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MilestoneCallout({
|
||||
showCompletedHint,
|
||||
showHalfwayHint,
|
||||
onCompareInChat,
|
||||
}: {
|
||||
showCompletedHint: boolean;
|
||||
showHalfwayHint: boolean;
|
||||
onCompareInChat: () => Promise<void>;
|
||||
}): ReactElement | null {
|
||||
if (!(showHalfwayHint || showCompletedHint)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="corner-squircle rounded-2xl border border-border/60 bg-muted/30 px-3 py-2.5">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
{!showCompletedHint && (
|
||||
<p className="text-[10px] font-medium uppercase tracking-[0.12em] text-muted-foreground">
|
||||
Milestone
|
||||
</p>
|
||||
)}
|
||||
<p
|
||||
className={cn(
|
||||
"text-xs text-foreground/85",
|
||||
!showCompletedHint && "mt-1",
|
||||
)}
|
||||
>
|
||||
{showCompletedHint
|
||||
? "Training done. Next step: compare base vs fine-tuned outputs."
|
||||
: "Halfway done. Training is past 50%."}
|
||||
</p>
|
||||
</div>
|
||||
{!showCompletedHint && (
|
||||
<span className="rounded-full border border-border/60 bg-background/80 px-2 py-0.5 text-[10px] font-medium text-muted-foreground">
|
||||
50%+
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{showCompletedHint && (
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
<Button size="xs" onClick={onCompareInChat}>
|
||||
Compare in Chat
|
||||
</Button>
|
||||
<Button asChild={true} size="xs" variant="outline">
|
||||
<Link to="/export">Export Model</Link>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MetricStat({
|
||||
label,
|
||||
children,
|
||||
valueClassName,
|
||||
}: {
|
||||
label: string;
|
||||
children: ReactNode;
|
||||
valueClassName?: string;
|
||||
}): ReactElement {
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<p className="text-[11px] text-muted-foreground">{label}</p>
|
||||
<p
|
||||
className={`mt-1 text-base font-semibold tabular-nums ${valueClassName ?? ""}`}
|
||||
>
|
||||
{children}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function lastNonZeroValue(points: { value: number }[]): number | null {
|
||||
for (let i = points.length - 1; i >= 0; i -= 1) {
|
||||
const value = points[i]?.value;
|
||||
|
|
@ -436,7 +549,7 @@ function GpuStat({
|
|||
pct: number;
|
||||
max?: number;
|
||||
}): ReactElement {
|
||||
const clamped = Math.min(pct, max ?? 100);
|
||||
const clamped = Math.max(0, Math.min(pct, max ?? 100));
|
||||
let barColor = "bg-red-500";
|
||||
if (clamped < 60) {
|
||||
barColor = "bg-emerald-500";
|
||||
|
|
@ -445,7 +558,7 @@ function GpuStat({
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5 rounded-xl bg-muted/50 p-3">
|
||||
<div className="corner-squircle flex flex-col gap-2 rounded-2xl border border-border/50 bg-background/60 p-3">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="flex items-center gap-1.5 text-muted-foreground">
|
||||
{icon}
|
||||
|
|
@ -453,7 +566,7 @@ function GpuStat({
|
|||
</span>
|
||||
<span className="font-medium tabular-nums">{value}</span>
|
||||
</div>
|
||||
<div className="h-1.5 w-full rounded-full bg-muted overflow-hidden">
|
||||
<div className="h-2 w-full overflow-hidden rounded-full bg-muted/80">
|
||||
<div
|
||||
className={`h-full rounded-full ${barColor} transition-all duration-300`}
|
||||
style={{ width: `${clamped}%` }}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue