From dcd6f7fd414ef6222563c58f058b879646a2b347 Mon Sep 17 00:00:00 2001 From: admin <572701190@qq.com> Date: Wed, 1 Jul 2026 00:13:05 +0800 Subject: [PATCH] Split UI pages and add inference workflow --- README.md | 2 + backend/app/agents/evaluation_agent.py | 17 +- backend/tests/test_agents.py | 3 + docs/DATASET_PREPARATION.md | 86 +++++++ frontend/src/main.tsx | 344 +++++++++++++++++++++++-- frontend/src/styles.css | 203 ++++++++++++++- 6 files changed, 618 insertions(+), 37 deletions(-) create mode 100644 docs/DATASET_PREPARATION.md diff --git a/README.md b/README.md index b5cc66e..d003a6f 100644 --- a/README.md +++ b/README.md @@ -213,6 +213,8 @@ checks YOLO txt labels and mask dimensions, and can generate a `dataset.yaml` for the `yolo.train_custom` task. The selected upload dataset also exposes direct YOLO custom train, predict, and heatmap actions; custom outputs are written under `var/custom_yolo_runs` and are scanned by the results dashboard. +The detailed color-label to mask/txt conversion path is documented in +`docs/DATASET_PREPARATION.md`. When a dataset is selected, the dataset panel shows its custom YOLO `best.pt`, prediction previews, heatmap previews, and inline training curve previews. Segmentation previews, YOLO heatmaps, and loss/metric artifacts are grouped on diff --git a/backend/app/agents/evaluation_agent.py b/backend/app/agents/evaluation_agent.py index cc0328f..11007c3 100644 --- a/backend/app/agents/evaluation_agent.py +++ b/backend/app/agents/evaluation_agent.py @@ -30,6 +30,7 @@ def evaluate_project() -> dict: backend = settings.project_root / "backend" / "app" / "main.py" jobs_backend = settings.project_root / "backend" / "app" / "jobs.py" readme = settings.project_root / "README.md" + dataset_doc = settings.project_root / "docs" / "DATASET_PREPARATION.md" results_service = settings.project_root / "backend" / "app" / "modules" / "results" / "service.py" catalog = get_catalog() coverage = get_coverage_report() @@ -42,12 +43,26 @@ def evaluate_project() -> dict: acceptance_text = (settings.project_root / "backend" / "app" / "acceptance.py").read_text(encoding="utf-8") results_service_text = results_service.read_text(encoding="utf-8") if results_service.exists() else "" readme_text = readme.read_text(encoding="utf-8") if readme.exists() else "" + dataset_doc_text = dataset_doc.read_text(encoding="utf-8") if dataset_doc.exists() else "" expectations = { - "left_nav_dataset": "数据集" in frontend_text and "#datasets" in frontend_text, + "left_nav_dataset": "数据集" in frontend_text and '"datasets"' in frontend_text and "navigate(item.id)" in frontend_text, + "separated_pages_ui": "type PageId" in frontend_text + and "data-page-section" in frontend_text + and 'data-page={page}' in frontend_text + and '"inference"' in frontend_text + and '"results"' in frontend_text, "upload_ui": "uploadDatasetFiles" in frontend_text and "labels" in frontend_text and "masks" in frontend_text, "dataset_quality_ui": "DatasetQuality" in frontend_text and "generateSelectedYoloYaml" in frontend_text, + "dataset_preparation_doc": "DATASET_PREPARATION.md" in readme_text + and "彩色 label 到训练 mask" in dataset_doc_text + and "YOLO txt" in dataset_doc_text, "uploaded_yolo_workflow_ui": "startSelectedYoloTrain" in frontend_text and "startSelectedYoloPredict" in frontend_text and "startSelectedYoloHeatmap" in frontend_text, + "trained_model_inference_ui": "modelWeightOptions" in frontend_text + and "selectedInferenceWeight" in frontend_text + and "startInferenceJob" in frontend_text + and "yolo.predict_custom" in frontend_text + and "yolo.heatmap_custom" in frontend_text, "dataset_yolo_outputs_ui": "DatasetYoloOutputs" in frontend_text and "selectedYoloOutputs" in frontend_text and "BEST.PT READY" in frontend_text diff --git a/backend/tests/test_agents.py b/backend/tests/test_agents.py index df271bd..c3ba949 100644 --- a/backend/tests/test_agents.py +++ b/backend/tests/test_agents.py @@ -12,6 +12,9 @@ def test_evaluation_agent_returns_checks(): assert checks["real_workspace_acceptance"] is True assert checks["real_train_acceptance"] is True assert checks["single_epoch_curve_support"] is True + assert checks["separated_pages_ui"] is True + assert checks["trained_model_inference_ui"] is True + assert checks["dataset_preparation_doc"] is True def test_validation_agent_lightweight(monkeypatch): diff --git a/docs/DATASET_PREPARATION.md b/docs/DATASET_PREPARATION.md new file mode 100644 index 0000000..6ae2cc5 --- /dev/null +++ b/docs/DATASET_PREPARATION.md @@ -0,0 +1,86 @@ +# 数据集、Label、Mask 处理流程 + +本项目保留 `Seg/` 里的原始处理逻辑,并在网页端把上传数据集统一组织成: + +```text +var/uploads/datasets// + images/ 原始图片或待推理图片 + labels/ YOLO txt/json/yaml 标注 + masks/ 语义分割灰度 mask 或彩色 label/mask +``` + +## 现有数据集样例 + +可以参考两个现有路径: + +- `Seg/DataSet_Own/A_Ori` 与 `Seg/DataSet_Own/A_Label_Ori` +- `Seg/Seg_All_In_One_YoloModel/Yolo数据集构建/ORI` 与 `Label` + +它们的核心要求是:原图和标注图用同一个文件 stem 配对。比如: + +```text +images/sample_001.png +masks/sample_001.png +labels/sample_001.txt +``` + +如果彩色 label 带 `_label` 后缀,原脚本会在配对或转换时剥离这个后缀。 + +## 彩色 label 到训练 mask + +`Seg/DataSet_Own/1. 图片预处理(内含使用手册)/4_deal_labels.py` 的流程是: + +1. 从人工彩色 label 读取 RGB。 +2. 用边缘检测、连通域检测、分水岭填充清理标注区域,输出 `*_label.png` 到 `*_pro_label_fold`。 +3. 按 `Annotate_CLASSES` 和 `Annotate_PALETTE` 精确匹配颜色。 +4. 背景写成 `0`,第一个类别从 `1` 开始写入灰度 GT mask。 +5. 输出训练用 mask 到 `*_GT_label_fold`,默认后缀是 `_gtFine_labelTrainIds.png`。 + +因此,新数据如果是 MMSeg/SegModel 语义分割训练,推荐准备: + +```text +images/*.png +masks/*.png # 单通道灰度,0 是背景,1..N 是类别 id +``` + +如果手里只有彩色 label,需要先按调色板转换成灰度 mask。新增类别时要同步维护: + +- `Annotate_CLASSES` +- `Annotate_PALETTE` +- `bg_PALETTE` + +## 彩色 label 或 GT mask 到 YOLO txt + +YOLO 分割训练使用 polygon txt。现有脚本路径: + +```text +Seg/Seg_All_In_One_YoloModel/Yolo数据集构建/2_Check_and_Gen_Txt_Label_sort_label.py +``` + +该脚本会读取 `ORI_GT_label_fold` 中的灰度 GT mask: + +- `0` 是背景; +- 非背景灰度值代表类别; +- 每个类别区域提取轮廓; +- 坐标按图片宽高归一化; +- 输出 `class_id x1 y1 x2 y2 ...` 到 `Data/labels/train` 和 `Data/labels/val`。 + +网页端的上传数据集如果要直接训练 YOLO,需要: + +```text +images/sample_001.png +labels/sample_001.txt +``` + +然后在数据集页生成 `dataset.yaml`,再启动 `yolo.train_custom`。 + +## 新图片推理 + +如果只是对新图片做推理,不需要 mask 或 label: + +1. 在网页数据集页创建一个新数据集。 +2. 把新图片上传到 `images`。 +3. 进入推理页,选择一个已经训练好的权重,例如 `best.pt`。 +4. 图片来源选择这个数据集,启动预测或热度图任务。 + +如果要把新图片加入训练,则必须额外提供同名的 `mask` 或 `label txt`,否则只能用于推理或人工复核。 diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 1e58846..d6b6bbe 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -311,6 +311,34 @@ type ValidationAgentPayload = { checks: AgentCheck[]; }; +type PageId = "overview" | "datasets" | "training" | "inference" | "results" | "system" | "agents"; + +type ModelWeightOption = { + key: string; + label: string; + path: string; + artifactPath: string; + family: string; + role: string; + size: number; + source: string; +}; + +const pages: Array<{ id: PageId; label: string; eyebrow: string; title: string; description: string; icon: React.ReactNode }> = [ + { id: "overview", label: "总览", eyebrow: "Operations Map", title: "分割平台运行总览", description: "能力矩阵、关键资产和最近产物集中看板。", icon: }, + { id: "datasets", label: "数据集", eyebrow: "Dataset Bench", title: "数据集、Label、Mask 工作台", description: "按数据集管理上传、配对校验、训练数据生成和样例预览。", icon: }, + { id: "training", label: "训练", eyebrow: "Training Queue", title: "训练任务与实时日志", description: "选择 SegModel、YOLO、MMSeg 或数据处理任务并跟踪进度。", icon: }, + { id: "inference", label: "推理", eyebrow: "Model Inference", title: "选择训练模型进行图片推理", description: "从已有 best.pt/last.pt/manifest 权重中选择模型,对上传图片或数据集运行预测与热度图。", icon: }, + { id: "results", label: "结果", eyebrow: "Result Studio", title: "分割结果、热度图与 Loss 曲线", description: "按模型族和产物类型浏览训练结果、推理图、热度图和 CSV 曲线。", icon: }, + { id: "system", label: "系统", eyebrow: "Runtime Control", title: "GPU、环境、权重与覆盖验收", description: "检查 GPU、conda 环境、权重 manifest、脚本覆盖和验收任务。", icon: }, + { id: "agents", label: "Agent", eyebrow: "Review Agents", title: "评价建议与验证 Agent", description: "持续审查网页平台覆盖度、运行状态和下一步风险。", icon: } +]; + +function pageFromHash(): PageId { + const raw = window.location.hash.replace(/^#/, ""); + return pages.some((item) => item.id === raw) ? (raw as PageId) : "overview"; +} + async function api(path: string, init?: RequestInit): Promise { const res = await fetch(`${API_BASE}${path}`, { headers: { "Content-Type": "application/json" }, @@ -483,6 +511,7 @@ function JobProgressBar({ progress }: { progress?: JobProgress }) { function App() { const { catalog, gpus, condaEnvs, runtimeReadiness, capabilities, agentEvaluation, jobs, results, curves, weightManifest, datasets, datasetValidations, coverage, acceptance, realAcceptance, realTrainAcceptance, deepAcceptance, error, refresh } = useData(); + const [page, setPage] = useState(pageFromHash); const [taskType, setTaskType] = useState("mock.echo"); const [params, setParams] = useState(JSON.stringify(defaultParams["mock.echo"], null, 2)); const [selectedJob, setSelectedJob] = useState(null); @@ -501,12 +530,21 @@ function App() { const [agentValidation, setAgentValidation] = useState(null); const [weightVerification, setWeightVerification] = useState(null); const [agentBusy, setAgentBusy] = useState(false); + const [selectedInferenceWeight, setSelectedInferenceWeight] = useState(""); + const [inferenceSourcePath, setInferenceSourcePath] = useState(""); + const [inferenceModelKey, setInferenceModelKey] = useState("YOLO11n-seg"); const eventSourceRef = useRef(null); useEffect(() => () => { eventSourceRef.current?.close(); }, []); + useEffect(() => { + const syncPage = () => setPage(pageFromHash()); + window.addEventListener("hashchange", syncPage); + return () => window.removeEventListener("hashchange", syncPage); + }, []); + const runningCount = jobs.filter((job) => job.status === "running").length; const successCount = jobs.filter((job) => job.status === "success").length; const failedCount = jobs.filter((job) => job.status === "failed").length; @@ -552,7 +590,7 @@ function App() { const selectedYoloWeightReady = Boolean(selectedYoloOutputs.bestWeight); const availableGpus = gpus?.gpus ?? []; const condaEnvOptions = useMemo(() => ["auto", ...Array.from(new Set((condaEnvs?.envs ?? []).map((item) => item.name))).sort()], [condaEnvs]); - const selectedGpuDevice = selectedGpuIds.length ? selectedGpuIds.map((_, index) => index).join(",") : "cpu"; + const selectedGpuDevice = selectedGpuIds.length ? selectedGpuIds.join(",") : "cpu"; const resultFamilyOptions = useMemo(() => ["all", ...Array.from(new Set(results.map((item) => item.family ?? "artifact"))).sort()], [results]); const resultRoleOptions = useMemo(() => ["all", ...Array.from(new Set(results.map((item) => item.role ?? "artifact"))).sort()], [results]); const filteredResults = useMemo( @@ -562,6 +600,63 @@ function App() { ), [resultFamilyFilter, resultRoleFilter, results] ); + const modelWeightOptions = useMemo(() => { + const options = new Map(); + for (const item of results) { + if (item.role !== "weight" || !["pt", "pth", "onnx", "engine"].includes(item.kind)) continue; + options.set(item.path, { + key: item.path, + label: `${item.family ?? "model"} · ${item.name}`, + path: item.path, + artifactPath: item.relative_path, + family: item.family ?? "model", + role: item.role ?? "weight", + size: item.size, + source: "result" + }); + } + for (const item of weightManifest?.files ?? []) { + if (!/\.(pt|pth|onnx|engine)$/i.test(item.stored_path)) continue; + options.set(item.stored_path, { + key: item.stored_path, + label: `${item.family} · ${fileName(item.source_path)}`, + path: item.stored_path, + artifactPath: item.stored_path, + family: item.family, + role: item.role, + size: item.size, + source: "manifest" + }); + } + return Array.from(options.values()).sort((a, b) => { + const score = (item: ModelWeightOption) => (item.label.toLowerCase().includes("best.pt") ? 3 : item.label.toLowerCase().includes("last.pt") ? 2 : item.source === "result" ? 1 : 0); + return score(b) - score(a) || a.label.localeCompare(b.label); + }); + }, [results, weightManifest]); + const activePage = pages.find((item) => item.id === page) ?? pages[0]; + const inferenceSource = inferenceSourcePath.trim() || selectedDataset?.absolute_layout?.images || ""; + const inferenceOutputs = useMemo(() => { + const predictions = results.filter((item) => item.role === "segmentation" && item.previewable).slice(0, 8); + const heatmaps = results.filter((item) => item.role === "heatmap" && item.previewable).slice(0, 8); + return { predictions, heatmaps }; + }, [results]); + + useEffect(() => { + if (!selectedInferenceWeight && modelWeightOptions.length) { + setSelectedInferenceWeight(modelWeightOptions[0].path); + } + }, [modelWeightOptions, selectedInferenceWeight]); + + useEffect(() => { + if (!selectedDatasetName && datasets.length) { + setSelectedDatasetName(datasets[0].name); + } + }, [datasets, selectedDatasetName]); + + function navigate(next: PageId) { + setPage(next); + window.location.hash = next; + } function pickTask(next: string) { setTaskType(next); @@ -571,7 +666,7 @@ function App() { function pickDatasetTask(next: string) { setTaskType(next); setParams(JSON.stringify(datasetParamsForTask(next), null, 2)); - window.location.hash = "jobs"; + navigate("training"); } function toggleGpu(index: number) { @@ -776,7 +871,7 @@ function App() { body: JSON.stringify(jobPayload("yolo.train_custom", { model: "YOLO11n-seg", data: generated.path, epochs: 10, imgsz: 640, batch: 1, workers: 0, device: selectedGpuDevice, project: "var/custom_yolo_runs", name: selectedDataset.name, exist_ok: true })) }); await inspectJob(job); - window.location.hash = "jobs"; + navigate("training"); await refresh(); } finally { setBusy(false); @@ -792,7 +887,7 @@ function App() { body: JSON.stringify(jobPayload("yolo.predict_custom", { weights: customYoloWeightPath(selectedDataset), source: selectedDataset.absolute_layout.images, imgsz: 640, conf: 0.25, device: selectedGpuDevice, project: "var/custom_yolo_runs", name: `${selectedDataset.name}_predict`, exist_ok: true })) }); await inspectJob(job); - window.location.hash = "jobs"; + navigate("inference"); await refresh(); } finally { setBusy(false); @@ -808,7 +903,47 @@ function App() { body: JSON.stringify(jobPayload("yolo.heatmap_custom", { weights: customYoloWeightPath(selectedDataset), source: selectedDataset.absolute_layout.images, model_key: "YOLO11n-seg", cam_method: "GradCAM", target_layers: "model.model.model[9]", limit: 3, device: selectedGpuDevice, project: "var/custom_yolo_runs", name: `${selectedDataset.name}_heatmap` })) }); await inspectJob(job); - window.location.hash = "jobs"; + navigate("inference"); + await refresh(); + } finally { + setBusy(false); + } + } + + async function startInferenceJob(kind: "predict" | "heatmap") { + if (!selectedInferenceWeight || !inferenceSource) return; + setBusy(true); + try { + const baseName = selectedDataset?.name ?? "manual_source"; + const jobType = kind === "predict" ? "yolo.predict_custom" : "yolo.heatmap_custom"; + const rawParams = kind === "predict" + ? { + weights: selectedInferenceWeight, + source: inferenceSource, + imgsz: 640, + conf: 0.25, + device: selectedGpuDevice, + project: "var/custom_yolo_runs", + name: `${baseName}_model_infer`, + exist_ok: true + } + : { + weights: selectedInferenceWeight, + source: inferenceSource, + model_key: inferenceModelKey, + cam_method: "GradCAM", + target_layers: "model.model.model[9]", + limit: 3, + device: selectedGpuDevice, + project: "var/custom_yolo_runs", + name: `${baseName}_model_heatmap` + }; + const job = await api("/api/jobs", { + method: "POST", + body: JSON.stringify(jobPayload(jobType, rawParams)) + }); + await inspectJob(job); + navigate("inference"); await refresh(); } finally { setBusy(false); @@ -844,7 +979,7 @@ function App() { } return ( -
+
-

Segmentation Operations

-

训练、预测、分析与权重资产控制台

+

{activePage.eyebrow}

+

{activePage.title}

+ {activePage.description}
+ + + +
+ {modelWeightOptions.slice(0, 10).map((item) => ( + + {item.label} + {item.family} · {item.role} · {formatBytes(item.size)} + + ))} +
+ + +
+
+
+

Inference Outputs

+

最近推理产物

+
+ +
+
+
+ 分割结果 + +
+
+ 热度图 + +
+
+
+
+ +
@@ -1236,7 +1489,7 @@ function App() {
-
+
@@ -1275,7 +1528,7 @@ function App() {
-
+
@@ -1351,7 +1604,7 @@ function App() {
-
+
@@ -1386,7 +1639,7 @@ function App() {
-
+
@@ -1458,6 +1711,45 @@ function JobDiagnostics({ job }: { job: Job }) { ); } +function DatasetPipelineGuide({ selectedDataset, validation }: { selectedDataset?: UploadedDataset; validation?: DatasetValidation }) { + const activeName = selectedDataset?.name ?? "未选择"; + return ( +
+
+
+

Label Pipeline

+

彩色 Label 到训练数据

+
+ +
+
+
+ 现有样例 + DataSet_Own/A_Ori + A_Label_Ori + 同名图片先配对,再生成 A_Label_pro_label_fold 与 A_Label_GT_label_fold。 +
+
+ YOLO 样例 + Seg_All_In_One_YoloModel/Yolo数据集构建 + ORI/Label 生成 ORI_GT_label_fold,再输出 Data/images 与 Data/labels/*.txt。 +
+
+
+
1原图与彩色标注同名例如 `xxx.png` 对 `xxx.png`,或 label 后缀为 `_label` 时由配对脚本剥离后缀匹配。
+
2彩色 label 先清理成 pro label`4_deal_labels.py` 使用边缘检测、连通域和分水岭填充,输出 `*_label.png`。
+
3pro label 转训练 mask按 `Annotate_PALETTE` 精确匹配 RGB,背景为 0,类别从 1 开始写入灰度 GT mask。
+
4YOLO 再转多边形 txt`2_Check_and_Gen_Txt_Label_sort_label.py` 从灰度 GT 提轮廓,写入归一化 polygon 坐标。
+
+
+
当前数据集{activeName}
+
Image/Label{validation?.pairs.image_label ?? 0}
+
Image/Mask{validation?.pairs.image_mask ?? 0}
+
YOLO Ready{validation?.ready.yolo ? "OK" : "Check"}
+
+
+ ); +} + function WeightPanel({ catalog, manifest, diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 949d1f8..d2f213c 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -89,21 +89,39 @@ nav { gap: 8px; } -nav a { +nav a, +nav button { display: flex; align-items: center; gap: 10px; + width: 100%; color: var(--muted); padding: 11px 10px; border-radius: 6px; text-decoration: none; + background: transparent; + text-align: left; } -nav a:hover { +nav a:hover, +nav button:hover, +nav button.active { color: var(--ink); background: var(--panel-2); } +nav button.active { + border: 1px solid rgba(157, 226, 111, 0.42); + background: rgba(157, 226, 111, 0.08); +} + +nav button span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + .workspace { padding: 26px; } @@ -137,6 +155,23 @@ h2 { font-size: 18px; } +.pageHint { + display: block; + margin-top: 7px; + color: var(--muted); + font-size: 13px; +} + +.shell[data-page="overview"] [data-page-section]:not([data-page-section~="overview"]), +.shell[data-page="datasets"] [data-page-section]:not([data-page-section~="datasets"]), +.shell[data-page="training"] [data-page-section]:not([data-page-section~="training"]), +.shell[data-page="inference"] [data-page-section]:not([data-page-section~="inference"]), +.shell[data-page="results"] [data-page-section]:not([data-page-section~="results"]), +.shell[data-page="system"] [data-page-section]:not([data-page-section~="system"]), +.shell[data-page="agents"] [data-page-section]:not([data-page-section~="agents"]) { + display: none; +} + .iconButton, .primary { height: 38px; display: inline-flex; @@ -529,7 +564,9 @@ textarea { margin-top: 0; } -.field input { +.field input, +.field select { + width: 100%; height: 38px; padding: 0 10px; border-radius: 6px; @@ -537,6 +574,145 @@ textarea { background: var(--field); } +.pathStack, +.inferenceForm, +.modelList, +.pipelinePanel, +.pipelineSteps, +.pipelineStats, +.pipelineExample { + display: grid; + gap: 10px; +} + +.pathStack div { + min-width: 0; + display: grid; + gap: 5px; +} + +.pathStack span, +.pipelineExample span, +.pipelineStats span { + color: var(--muted); + font-size: 11px; +} + +.pathStack code { + display: block; + padding: 9px; + border: 1px solid var(--line); + border-radius: 6px; + background: #080a08; + color: var(--ink); + overflow-wrap: anywhere; +} + +.pipelineExample { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.pipelineExample div, +.pipelineSteps div, +.pipelineStats div { + min-width: 0; + padding: 10px; + border-radius: 7px; + border: 1px solid var(--line); + background: #101310; +} + +.pipelineExample strong, +.pipelineExample small, +.pipelineSteps strong, +.pipelineSteps small, +.pipelineStats strong { + display: block; + min-width: 0; +} + +.pipelineExample strong, +.pipelineStats strong { + margin-top: 3px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.pipelineExample small, +.pipelineSteps small { + margin-top: 5px; + color: var(--muted); + line-height: 1.4; +} + +.pipelineSteps { + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + +.pipelineSteps div { + display: grid; + align-content: start; + gap: 6px; +} + +.pipelineSteps span { + width: 24px; + height: 24px; + display: grid; + place-items: center; + border-radius: 6px; + background: var(--green); + color: #0b0d0b; + font-weight: 760; +} + +.pipelineStats { + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + +.modelList { + margin-top: 14px; + max-height: 330px; + overflow: auto; +} + +.modelList a { + min-width: 0; + display: grid; + gap: 3px; + padding: 9px; + border-radius: 6px; + border: 1px solid var(--line); + background: #101310; + color: var(--ink); + text-decoration: none; +} + +.modelList a.selected { + border-color: var(--green); + background: rgba(157, 226, 111, 0.08); +} + +.modelList span, +.modelList small { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.inferencePreview { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; +} + +.inferencePreview > div { + min-width: 0; + display: grid; + gap: 10px; +} + .datasetForm { display: grid; gap: 12px; @@ -1614,16 +1790,19 @@ meter { align-items: center; } - nav a { + nav a, + nav button { justify-content: center; padding: 10px 6px; } - nav a svg { + nav a svg, + nav button svg { flex: none; } - nav a { + nav a, + nav button { font-size: 0; } @@ -1656,10 +1835,14 @@ meter { .datasetOutputPreview, .gpuPicker, .taskCheckList, - .agentCheckList, - .jobDetailGrid, - .qualityStats, - .qualityChecks { + .agentCheckList, + .jobDetailGrid, + .qualityStats, + .qualityChecks, + .pipelineExample, + .pipelineSteps, + .pipelineStats, + .inferencePreview { grid-template-columns: repeat(2, minmax(0, 1fr)); } }