Merge remote-tracking branch 'origin/main' into ci/workflow-permissions-and-fewer-skips
# Conflicts: # tests/studio/test_chat_preset_builtin_invariants.py
|
|
@ -437,6 +437,7 @@ class LlamaCppBackend:
|
|||
self._effective_context_length: Optional[int] = None
|
||||
self._max_context_length: Optional[int] = None
|
||||
self._chat_template: Optional[str] = None
|
||||
self._chat_template_override: Optional[str] = None
|
||||
self._supports_reasoning: bool = False
|
||||
self._reasoning_always_on: bool = False
|
||||
self._reasoning_style: str = "enable_thinking"
|
||||
|
|
@ -621,6 +622,10 @@ class LlamaCppBackend:
|
|||
def chat_template(self) -> Optional[str]:
|
||||
return self._chat_template
|
||||
|
||||
@property
|
||||
def chat_template_override(self) -> Optional[str]:
|
||||
return self._chat_template_override
|
||||
|
||||
@property
|
||||
def supports_reasoning(self) -> bool:
|
||||
return self._supports_reasoning
|
||||
|
|
@ -2221,12 +2226,12 @@ class LlamaCppBackend:
|
|||
self._speculative_type = None
|
||||
|
||||
# Apply custom chat template override if provided
|
||||
self._chat_template_override = chat_template_override
|
||||
if chat_template_override:
|
||||
import tempfile
|
||||
|
||||
self._chat_template = chat_template_override
|
||||
flags = detect_reasoning_flags(
|
||||
self._chat_template,
|
||||
chat_template_override,
|
||||
self._model_identifier,
|
||||
log_source = "GGUF chat template override",
|
||||
)
|
||||
|
|
@ -2525,6 +2530,7 @@ class LlamaCppBackend:
|
|||
self._effective_context_length = None
|
||||
self._max_context_length = None
|
||||
self._chat_template = None
|
||||
self._chat_template_override = None
|
||||
self._supports_reasoning = False
|
||||
self._reasoning_always_on = False
|
||||
self._reasoning_style = "enable_thinking"
|
||||
|
|
@ -4211,6 +4217,8 @@ class LlamaCppBackend:
|
|||
return "csm"
|
||||
if len(_tok("<|startoftranscript|>")) == 1:
|
||||
return "whisper"
|
||||
if len(_tok("<audio_soft_token>")) == 1:
|
||||
return "audio_vlm"
|
||||
if (
|
||||
len(_tok("<|bicodec_semantic_0|>")) == 1
|
||||
and len(_tok("<|bicodec_global_0|>")) == 1
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ from .training import (
|
|||
TrainingRunMetrics,
|
||||
TrainingRunDetailResponse,
|
||||
TrainingRunDeleteResponse,
|
||||
TrainingRunUpdateRequest,
|
||||
)
|
||||
from .models import (
|
||||
CheckpointInfo,
|
||||
|
|
@ -81,6 +82,7 @@ __all__ = [
|
|||
"TrainingRunMetrics",
|
||||
"TrainingRunDetailResponse",
|
||||
"TrainingRunDeleteResponse",
|
||||
"TrainingRunUpdateRequest",
|
||||
# Model management schemas
|
||||
"ModelDetails",
|
||||
"LocalModelInfo",
|
||||
|
|
|
|||
|
|
@ -11,7 +11,14 @@ import time
|
|||
import uuid
|
||||
from typing import Annotated, Any, Dict, Literal, Optional, List, Union
|
||||
|
||||
from pydantic import BaseModel, Discriminator, Field, Tag, model_validator
|
||||
from pydantic import (
|
||||
BaseModel,
|
||||
Discriminator,
|
||||
Field,
|
||||
Tag,
|
||||
field_validator,
|
||||
model_validator,
|
||||
)
|
||||
|
||||
|
||||
class LoadRequest(BaseModel):
|
||||
|
|
@ -43,6 +50,16 @@ class LoadRequest(BaseModel):
|
|||
None,
|
||||
description = "Custom Jinja2 chat template to use instead of the model's default",
|
||||
)
|
||||
|
||||
@field_validator("chat_template_override")
|
||||
@classmethod
|
||||
def normalize_blank_chat_template_override(
|
||||
cls, value: Optional[str]
|
||||
) -> Optional[str]:
|
||||
if value is not None and value.strip() == "":
|
||||
return None
|
||||
return value
|
||||
|
||||
cache_type_kv: Optional[str] = Field(
|
||||
None,
|
||||
description = "KV cache data type for both K and V (e.g. 'f16', 'bf16', 'q8_0', 'q4_1', 'q5_1')",
|
||||
|
|
@ -299,10 +316,6 @@ class InferenceStatusResponse(BaseModel):
|
|||
supports_tools: bool = Field(
|
||||
False, description = "Whether the active model supports tool calling"
|
||||
)
|
||||
chat_template: Optional[str] = Field(
|
||||
None,
|
||||
description = "Jinja2 chat template string for the active model",
|
||||
)
|
||||
context_length: Optional[int] = Field(
|
||||
None, description = "Context length of the active model"
|
||||
)
|
||||
|
|
@ -314,6 +327,17 @@ class InferenceStatusResponse(BaseModel):
|
|||
None,
|
||||
description = "Model's native context length from GGUF metadata (not capped by VRAM)",
|
||||
)
|
||||
cache_type_kv: Optional[str] = Field(
|
||||
None,
|
||||
description = "KV cache quantization dtype (e.g. 'q8_0'), or None for default",
|
||||
)
|
||||
chat_template: Optional[str] = Field(
|
||||
None, description = "Model's default chat template (Jinja2 source), if any"
|
||||
)
|
||||
chat_template_override: Optional[str] = Field(
|
||||
None,
|
||||
description = "Active chat template override applied at load time, or None if model is using its default",
|
||||
)
|
||||
speculative_type: Optional[str] = Field(
|
||||
None,
|
||||
description = "Active speculative decoding mode (e.g. 'ngram-simple', 'ngram-mod'), or None if disabled",
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
Pydantic schemas for Training API
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
from typing import Any, Optional, List, Dict, Literal
|
||||
|
||||
|
||||
|
|
@ -224,6 +224,7 @@ class TrainingRunSummary(BaseModel):
|
|||
status: Literal["running", "completed", "stopped", "error"]
|
||||
model_name: str
|
||||
dataset_name: str
|
||||
display_name: Optional[str] = None
|
||||
started_at: str
|
||||
ended_at: Optional[str] = None
|
||||
total_steps: Optional[int] = None
|
||||
|
|
@ -237,6 +238,14 @@ class TrainingRunSummary(BaseModel):
|
|||
resumed_later: bool = False
|
||||
|
||||
|
||||
class TrainingRunUpdateRequest(BaseModel):
|
||||
"""Mutable fields on a training run."""
|
||||
|
||||
model_config = ConfigDict(extra = "forbid")
|
||||
|
||||
display_name: Optional[str] = Field(None, max_length = 120)
|
||||
|
||||
|
||||
class TrainingRunListResponse(BaseModel):
|
||||
"""Response for listing training runs."""
|
||||
|
||||
|
|
|
|||
|
|
@ -474,7 +474,6 @@ async def load_model(
|
|||
f"Model already loaded (GGUF): {model_log_label} variant={request.gguf_variant}, skipping reload"
|
||||
)
|
||||
inference_config = load_inference_config(llama_backend.model_identifier)
|
||||
from utils.models import is_audio_input_type
|
||||
|
||||
_gguf_audio = (
|
||||
llama_backend._audio_type
|
||||
|
|
@ -495,9 +494,7 @@ async def load_model(
|
|||
is_gguf = True,
|
||||
is_audio = _gguf_is_audio,
|
||||
audio_type = _gguf_audio,
|
||||
has_audio_input = is_audio_input_type(_gguf_audio)
|
||||
if _gguf_audio
|
||||
else False,
|
||||
has_audio_input = False,
|
||||
inference = inference_config,
|
||||
requires_trust_remote_code = bool(
|
||||
inference_config.get("trust_remote_code", False)
|
||||
|
|
@ -658,9 +655,10 @@ async def load_model(
|
|||
f"Loaded GGUF model via llama-server: {model_log_label if native_grant_backed else config.identifier}"
|
||||
)
|
||||
|
||||
# Detect TTS audio by probing the loaded model's vocabulary
|
||||
from utils.models import is_audio_input_type
|
||||
|
||||
# Detect TTS/audio marker tokens by probing the loaded model's vocabulary.
|
||||
# GGUF audio input is not wired through the chat path yet, so do not
|
||||
# advertise has_audio_input for GGUF models until uploaded audio is
|
||||
# actually forwarded to llama-server.
|
||||
_gguf_audio = llama_backend.detect_audio_type()
|
||||
_gguf_is_audio = _gguf_audio in ("snac", "bicodec", "dac")
|
||||
llama_backend._is_audio = _gguf_is_audio
|
||||
|
|
@ -686,7 +684,7 @@ async def load_model(
|
|||
is_gguf = True,
|
||||
is_audio = _gguf_is_audio,
|
||||
audio_type = _gguf_audio,
|
||||
has_audio_input = is_audio_input_type(_gguf_audio),
|
||||
has_audio_input = False,
|
||||
inference = inference_config,
|
||||
requires_trust_remote_code = bool(
|
||||
inference_config.get("trust_remote_code", False)
|
||||
|
|
@ -1156,13 +1154,15 @@ async def get_status(
|
|||
):
|
||||
_display_model_id = os.path.basename(_model_id)
|
||||
_inference_cfg = load_inference_config(_model_id) if _model_id else None
|
||||
_audio_type = getattr(llama_backend, "_audio_type", None)
|
||||
return InferenceStatusResponse(
|
||||
active_model = _display_model_id,
|
||||
is_vision = llama_backend.is_vision,
|
||||
is_gguf = True,
|
||||
gguf_variant = llama_backend.hf_variant,
|
||||
is_audio = getattr(llama_backend, "_is_audio", False),
|
||||
audio_type = getattr(llama_backend, "_audio_type", None),
|
||||
audio_type = _audio_type,
|
||||
has_audio_input = False,
|
||||
loading = [],
|
||||
loaded = [_display_model_id] if _display_model_id else [],
|
||||
inference = _inference_cfg,
|
||||
|
|
@ -1178,6 +1178,8 @@ async def get_status(
|
|||
context_length = llama_backend.context_length,
|
||||
max_context_length = llama_backend.max_context_length,
|
||||
native_context_length = llama_backend.native_context_length,
|
||||
cache_type_kv = llama_backend.cache_type_kv,
|
||||
chat_template_override = llama_backend.chat_template_override,
|
||||
speculative_type = llama_backend.speculative_type,
|
||||
)
|
||||
|
||||
|
|
@ -1669,6 +1671,12 @@ async def openai_chat_completions(
|
|||
and not _effective_enable_tools(payload)
|
||||
and (_tools_passthrough or _has_response_format)
|
||||
):
|
||||
if payload.audio_base64:
|
||||
raise HTTPException(
|
||||
status_code = 400,
|
||||
detail = "Audio input is not supported for GGUF chat models yet.",
|
||||
)
|
||||
|
||||
# Preserve the vision guard that would otherwise run in the
|
||||
# non-passthrough path below: text-only tool-capable GGUFs
|
||||
# should return a clear 400 here rather than forwarding the
|
||||
|
|
@ -1716,6 +1724,12 @@ async def openai_chat_completions(
|
|||
|
||||
# ── GGUF path: proxy to llama-server /v1/chat/completions ──
|
||||
if using_gguf:
|
||||
if payload.audio_base64:
|
||||
raise HTTPException(
|
||||
status_code = 400,
|
||||
detail = "Audio input is not supported for GGUF chat models yet.",
|
||||
)
|
||||
|
||||
# Reject images if this GGUF model doesn't support vision
|
||||
image_b64 = extracted_image_b64 or payload.image_base64
|
||||
if image_b64 and not llama_backend.is_vision:
|
||||
|
|
|
|||
|
|
@ -18,8 +18,15 @@ from models import (
|
|||
TrainingRunListResponse,
|
||||
TrainingRunMetrics,
|
||||
TrainingRunSummary,
|
||||
TrainingRunUpdateRequest,
|
||||
)
|
||||
from storage.studio_db import (
|
||||
delete_run,
|
||||
get_run,
|
||||
get_run_metrics,
|
||||
list_runs,
|
||||
update_run_display_name,
|
||||
)
|
||||
from storage.studio_db import delete_run, get_run, get_run_metrics, list_runs
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
|
@ -73,6 +80,34 @@ async def get_training_run_detail(
|
|||
)
|
||||
|
||||
|
||||
@router.patch("/runs/{run_id}", response_model = TrainingRunSummary)
|
||||
async def update_training_run(
|
||||
run_id: str,
|
||||
payload: TrainingRunUpdateRequest,
|
||||
current_subject: str = Depends(get_current_subject),
|
||||
):
|
||||
"""Update mutable fields on a training run (currently only display_name)."""
|
||||
run = get_run(run_id)
|
||||
if run is None:
|
||||
raise HTTPException(status_code = 404, detail = f"Run {run_id} not found")
|
||||
|
||||
if "display_name" in payload.model_fields_set:
|
||||
next_display = payload.display_name
|
||||
if next_display is not None:
|
||||
next_display = next_display.strip() or None
|
||||
update_run_display_name(run_id, next_display)
|
||||
|
||||
refreshed = get_run(run_id)
|
||||
if refreshed is None:
|
||||
raise HTTPException(status_code = 404, detail = f"Run {run_id} not found")
|
||||
return TrainingRunSummary(
|
||||
**{
|
||||
**{k: v for k, v in refreshed.items() if k != "config_json"},
|
||||
"can_resume": can_resume_run(refreshed),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/runs/{run_id}", response_model = TrainingRunDeleteResponse)
|
||||
async def delete_training_run(
|
||||
run_id: str,
|
||||
|
|
|
|||
|
|
@ -75,10 +75,16 @@ def _ensure_schema(conn: sqlite3.Connection) -> None:
|
|||
output_dir TEXT,
|
||||
error_message TEXT,
|
||||
duration_seconds REAL,
|
||||
loss_sparkline TEXT
|
||||
loss_sparkline TEXT,
|
||||
display_name TEXT
|
||||
)
|
||||
"""
|
||||
)
|
||||
existing_cols = {
|
||||
row[1] for row in conn.execute("PRAGMA table_info(training_runs)").fetchall()
|
||||
}
|
||||
if "display_name" not in existing_cols:
|
||||
conn.execute("ALTER TABLE training_runs ADD COLUMN display_name TEXT")
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS training_metrics (
|
||||
|
|
@ -261,6 +267,18 @@ def insert_metrics_batch(run_id: str, metrics: list[dict]) -> None:
|
|||
conn.close()
|
||||
|
||||
|
||||
def update_run_display_name(id: str, display_name: Optional[str]) -> None:
|
||||
conn = get_connection()
|
||||
try:
|
||||
conn.execute(
|
||||
"UPDATE training_runs SET display_name = ? WHERE id = ?",
|
||||
(display_name, id),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def list_runs(limit: int = 50, offset: int = 0) -> dict:
|
||||
conn = get_connection()
|
||||
try:
|
||||
|
|
@ -270,7 +288,7 @@ def list_runs(limit: int = 50, offset: int = 0) -> dict:
|
|||
SELECT r.id, r.status, r.model_name, r.dataset_name, r.started_at,
|
||||
r.ended_at, r.total_steps, r.final_step, r.final_loss,
|
||||
r.output_dir, r.duration_seconds, r.error_message,
|
||||
r.loss_sparkline,
|
||||
r.loss_sparkline, r.display_name,
|
||||
CASE
|
||||
WHEN r.status = 'stopped'
|
||||
AND r.output_dir IS NOT NULL
|
||||
|
|
|
|||
36
studio/backend/tests/test_inference_model_validation.py
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
_backend = os.path.join(os.path.dirname(__file__), "..")
|
||||
sys.path.insert(0, _backend)
|
||||
|
||||
from models.inference import LoadRequest
|
||||
|
||||
|
||||
def _base_load_request(**overrides):
|
||||
data = {
|
||||
"model_path": "unsloth/test-model-GGUF",
|
||||
"hf_token": None,
|
||||
"max_seq_length": 4096,
|
||||
"load_in_4bit": True,
|
||||
"is_lora": False,
|
||||
"gguf_variant": "Q4_K_M",
|
||||
}
|
||||
data.update(overrides)
|
||||
return LoadRequest.model_validate(data)
|
||||
|
||||
|
||||
def test_blank_chat_template_override_normalizes_to_none():
|
||||
req = _base_load_request(chat_template_override = " \n\t")
|
||||
|
||||
assert req.chat_template_override is None
|
||||
|
||||
|
||||
def test_nonblank_chat_template_override_is_preserved_verbatim():
|
||||
template = " {{ messages }} "
|
||||
req = _base_load_request(chat_template_override = template)
|
||||
|
||||
assert req.chat_template_override == template
|
||||
100
studio/backend/tests/test_training_history_update.py
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
_backend = os.path.join(os.path.dirname(__file__), "..")
|
||||
sys.path.insert(0, _backend)
|
||||
|
||||
from models.training import TrainingRunUpdateRequest
|
||||
from routes import training_history
|
||||
|
||||
|
||||
BASE_RUN = {
|
||||
"id": "run-1",
|
||||
"status": "stopped",
|
||||
"model_name": "unsloth/test-model",
|
||||
"dataset_name": "test-dataset",
|
||||
"display_name": "Existing name",
|
||||
"started_at": "2026-01-01T00:00:00Z",
|
||||
"ended_at": "2026-01-01T00:01:00Z",
|
||||
"total_steps": 10,
|
||||
"final_step": 5,
|
||||
"output_dir": "/tmp/run-1",
|
||||
"resumed_later": False,
|
||||
}
|
||||
|
||||
|
||||
def _patch_run(monkeypatch: pytest.MonkeyPatch, payload: TrainingRunUpdateRequest):
|
||||
stored = dict(BASE_RUN)
|
||||
calls: list[str | None] = []
|
||||
|
||||
def fake_get_run(run_id: str):
|
||||
assert run_id == "run-1"
|
||||
return dict(stored)
|
||||
|
||||
def fake_update_run_display_name(run_id: str, display_name: str | None):
|
||||
assert run_id == "run-1"
|
||||
calls.append(display_name)
|
||||
stored["display_name"] = display_name
|
||||
|
||||
monkeypatch.setattr(training_history, "get_run", fake_get_run)
|
||||
monkeypatch.setattr(
|
||||
training_history,
|
||||
"update_run_display_name",
|
||||
fake_update_run_display_name,
|
||||
)
|
||||
monkeypatch.setattr(training_history, "can_resume_run", lambda run: True)
|
||||
|
||||
result = asyncio.run(
|
||||
training_history.update_training_run(
|
||||
"run-1",
|
||||
payload,
|
||||
current_subject = "test-user",
|
||||
)
|
||||
)
|
||||
return result, calls
|
||||
|
||||
|
||||
def test_update_run_omitted_display_name_is_noop(monkeypatch: pytest.MonkeyPatch):
|
||||
result, calls = _patch_run(monkeypatch, TrainingRunUpdateRequest.model_validate({}))
|
||||
|
||||
assert calls == []
|
||||
assert result.display_name == "Existing name"
|
||||
assert result.can_resume is True
|
||||
|
||||
|
||||
def test_update_run_explicit_null_clears_display_name(monkeypatch: pytest.MonkeyPatch):
|
||||
result, calls = _patch_run(
|
||||
monkeypatch,
|
||||
TrainingRunUpdateRequest.model_validate({"display_name": None}),
|
||||
)
|
||||
|
||||
assert calls == [None]
|
||||
assert result.display_name is None
|
||||
assert result.can_resume is True
|
||||
|
||||
|
||||
def test_update_run_whitespace_clears_display_name(monkeypatch: pytest.MonkeyPatch):
|
||||
result, calls = _patch_run(
|
||||
monkeypatch,
|
||||
TrainingRunUpdateRequest.model_validate({"display_name": " "}),
|
||||
)
|
||||
|
||||
assert calls == [None]
|
||||
assert result.display_name is None
|
||||
|
||||
|
||||
def test_update_run_rejects_unknown_fields():
|
||||
with pytest.raises(ValidationError):
|
||||
TrainingRunUpdateRequest.model_validate({"unknown": "value"})
|
||||
|
||||
|
||||
def test_update_run_rejects_overlong_display_name():
|
||||
with pytest.raises(ValidationError):
|
||||
TrainingRunUpdateRequest.model_validate({"display_name": "x" * 121})
|
||||
|
Before Width: | Height: | Size: 138 KiB |
|
Before Width: | Height: | Size: 162 KiB |
|
Before Width: | Height: | Size: 9 KiB |
|
Before Width: | Height: | Size: 8.8 KiB |
|
Before Width: | Height: | Size: 157 KiB |
|
Before Width: | Height: | Size: 153 KiB |
|
Before Width: | Height: | Size: 136 KiB |
|
Before Width: | Height: | Size: 159 KiB |
|
|
@ -28,6 +28,16 @@ import {
|
|||
DropdownMenuShortcut,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useAnimatedThemeToggle } from "@/components/ui/animated-theme-toggler";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
|
|
@ -35,8 +45,8 @@ import {
|
|||
ColumnInsertIcon,
|
||||
CursorInfo02Icon,
|
||||
Delete02Icon,
|
||||
Download03Icon,
|
||||
GemIcon,
|
||||
DownloadSquare01Icon,
|
||||
Edit03Icon,
|
||||
Globe02Icon,
|
||||
HelpCircleIcon,
|
||||
Search01Icon,
|
||||
|
|
@ -44,6 +54,7 @@ import {
|
|||
PencilEdit02Icon,
|
||||
LayoutAlignLeftIcon,
|
||||
Settings02Icon,
|
||||
TestTube01Icon,
|
||||
ZapIcon,
|
||||
} from "@hugeicons/core-free-icons";
|
||||
import {
|
||||
|
|
@ -52,25 +63,34 @@ import {
|
|||
} from "@/components/ui/tooltip";
|
||||
import { Tooltip as TooltipPrimitive } from "radix-ui";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { ChevronDown, ChevronsUpDown, Moon, Sun } from "lucide-react";
|
||||
import { ChevronDown, ChevronsUpDown, MoreHorizontalIcon, Moon, Sun } from "lucide-react";
|
||||
import { Link, useNavigate, useRouterState } from "@tanstack/react-router";
|
||||
import { useTrainingRuntimeStore } from "@/features/training";
|
||||
import {
|
||||
ChatSearchDialog,
|
||||
deleteChatItem,
|
||||
renameChatItem,
|
||||
useChatRuntimeStore,
|
||||
useChatSearchStore,
|
||||
useChatSidebarItems,
|
||||
type SidebarItem,
|
||||
} from "@/features/chat";
|
||||
import { useSettingsDialogStore } from "@/features/settings";
|
||||
import { useEffectiveProfile, UserAvatar } from "@/features/profile";
|
||||
import { usePlatformStore } from "@/config/env";
|
||||
import { TOUR_OPEN_EVENT } from "@/features/tour";
|
||||
import {
|
||||
useChatSidebarItems,
|
||||
deleteChatItem,
|
||||
} from "@/features/chat/hooks/use-chat-sidebar-items";
|
||||
import { useChatRuntimeStore } from "@/features/chat/stores/chat-runtime-store";
|
||||
import { useChatSearchStore } from "@/features/chat/stores/chat-search-store";
|
||||
import { ChatSearchDialog } from "@/features/chat/components/chat-search-dialog";
|
||||
import { useTrainingHistorySidebarItems, deleteTrainingRun } from "@/features/training";
|
||||
deleteTrainingRun,
|
||||
emitTrainingRunDeleted,
|
||||
emitTrainingRunUpdated,
|
||||
removeTrainingUnloadGuard,
|
||||
renameTrainingRun,
|
||||
useTrainingHistorySidebarItems,
|
||||
useTrainingRuntimeStore,
|
||||
} from "@/features/training";
|
||||
import type { TrainingRunSummary } from "@/features/training";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { ShutdownDialog } from "@/components/shutdown-dialog";
|
||||
import { removeTrainingUnloadGuard } from "@/features/training/hooks/use-training-unload-guard";
|
||||
|
||||
function getTourId(pathname: string): string | null {
|
||||
if (pathname.startsWith("/studio")) return "studio";
|
||||
|
|
@ -79,6 +99,16 @@ function getTourId(pathname: string): string | null {
|
|||
return null;
|
||||
}
|
||||
|
||||
// Hugeicons' TestTube01Icon ships with two interior bubbles (paths #4
|
||||
// and #5 of the 5-path definition). Slicing to the first three paths
|
||||
// keeps the test-tube outline + horizontal cap + liquid line, dropping
|
||||
// the bubbles. The original export stays untouched, and HugeiconsIcon
|
||||
// renders this trimmed array exactly the same way.
|
||||
const TestTubeOutlineIcon = TestTube01Icon.slice(
|
||||
0,
|
||||
3,
|
||||
) as typeof TestTube01Icon;
|
||||
|
||||
function runStatusDotClass(status: TrainingRunSummary["status"]): string {
|
||||
switch (status) {
|
||||
case "running":
|
||||
|
|
@ -141,10 +171,10 @@ function NavItem({
|
|||
onClick={onClick}
|
||||
isActive={active}
|
||||
data-tour={dataTour}
|
||||
className="h-[32px] rounded-[10px] gap-[8.5px] px-2.5 font-medium text-[#383835] dark:text-[#c7c7c4] hover:bg-[#f0f0f0]! dark:hover:bg-[#2a2c2f]! hover:text-black! dark:hover:text-white! data-active:bg-[#f0f0f0]! dark:data-active:bg-[#2a2c2f]! data-active:text-black! dark:data-active:text-white! group-data-[collapsible=icon]:!w-[32px] group-data-[collapsible=icon]:!rounded-[11px] group-data-[collapsible=icon]:mx-auto"
|
||||
className="sidebar-nav-btn h-[35px] rounded-[10px] gap-[8.5px] px-2.5 font-medium group-data-[collapsible=icon]:!w-[32px] group-data-[collapsible=icon]:!rounded-[10px] group-data-[collapsible=icon]:mx-auto"
|
||||
>
|
||||
<HugeiconsIcon icon={icon} strokeWidth={1.75} className="size-[18px]! shrink-0 group-hover/menu-button:animate-icon-pop" />
|
||||
<span className="text-[14px] leading-[18px] tracking-[0.01em]">{label}</span>
|
||||
<HugeiconsIcon icon={icon} strokeWidth={1.75} className="size-icon! shrink-0 group-hover/menu-button:animate-icon-pop" />
|
||||
<span className="text-[14.5px] leading-[19px] tracking-nav">{label}</span>
|
||||
</SidebarMenuButton>
|
||||
</div>
|
||||
{children}
|
||||
|
|
@ -181,6 +211,17 @@ export function AppSidebar() {
|
|||
useEffect(() => { if (isChatRoute) setChatOpen(true); }, [isChatRoute]);
|
||||
useEffect(() => { if (isStudioRoute) setRunsOpen(true); }, [isStudioRoute]);
|
||||
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||
const [scrolled, setScrolled] = useState(false);
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
const handler = () => setScrolled(el.scrollTop > 0);
|
||||
handler();
|
||||
el.addEventListener("scroll", handler, { passive: true });
|
||||
return () => el.removeEventListener("scroll", handler);
|
||||
}, []);
|
||||
|
||||
const isRecipesRoute = pathname.startsWith("/data-recipes");
|
||||
const { displayTitle, avatarDataUrl } = useEffectiveProfile();
|
||||
|
||||
|
|
@ -195,7 +236,7 @@ export function AppSidebar() {
|
|||
: undefined;
|
||||
|
||||
// Training runs
|
||||
const { items: runItems, refresh: refreshRuns } = useTrainingHistorySidebarItems(
|
||||
const { items: runItems } = useTrainingHistorySidebarItems(
|
||||
!chatOnly && isStudioRoute,
|
||||
);
|
||||
const activeJobId = useTrainingRuntimeStore((s) => s.jobId);
|
||||
|
|
@ -213,6 +254,93 @@ export function AppSidebar() {
|
|||
});
|
||||
}
|
||||
|
||||
type RenameTarget =
|
||||
| { kind: "chat"; item: SidebarItem; current: string }
|
||||
| { kind: "run"; run: TrainingRunSummary; current: string };
|
||||
const [renamingTarget, setRenamingTarget] = useState<RenameTarget | null>(
|
||||
null,
|
||||
);
|
||||
const [renameDraft, setRenameDraft] = useState("");
|
||||
const renameTrimmed = renameDraft.trim();
|
||||
const nextRunDisplayName = renameTrimmed.length > 0 ? renameTrimmed : null;
|
||||
const renameDirty =
|
||||
renamingTarget !== null &&
|
||||
(renamingTarget.kind === "chat"
|
||||
? renameTrimmed.length > 0 && renameTrimmed !== renamingTarget.current
|
||||
: renameTrimmed.length > 0
|
||||
? renameTrimmed !== renamingTarget.current
|
||||
: renamingTarget.run.display_name != null);
|
||||
|
||||
function openRenameChat(item: SidebarItem) {
|
||||
setRenameDraft(item.title);
|
||||
setRenamingTarget({ kind: "chat", item, current: item.title });
|
||||
}
|
||||
function openRenameRun(run: TrainingRunSummary) {
|
||||
const current = run.display_name ?? run.model_name;
|
||||
setRenameDraft(current);
|
||||
setRenamingTarget({ kind: "run", run, current });
|
||||
}
|
||||
async function commitRename() {
|
||||
const target = renamingTarget;
|
||||
if (!target || !renameDirty) return;
|
||||
setRenamingTarget(null);
|
||||
if (target.kind === "chat") {
|
||||
try {
|
||||
await renameChatItem(target.item, renameTrimmed);
|
||||
} catch (err) {
|
||||
toast.error("Failed to rename chat", {
|
||||
description: err instanceof Error ? err.message : undefined,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const updated = await renameTrainingRun(target.run.id, nextRunDisplayName);
|
||||
emitTrainingRunUpdated(updated);
|
||||
} catch (err) {
|
||||
toast.error("Failed to rename run", {
|
||||
description: err instanceof Error ? err.message : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
type DeleteTarget =
|
||||
| { kind: "chat"; item: SidebarItem }
|
||||
| { kind: "run"; run: TrainingRunSummary };
|
||||
const [confirmingDelete, setConfirmingDelete] =
|
||||
useState<DeleteTarget | null>(null);
|
||||
|
||||
async function commitDelete() {
|
||||
const target = confirmingDelete;
|
||||
if (!target) return;
|
||||
setConfirmingDelete(null);
|
||||
if (target.kind === "chat") {
|
||||
try {
|
||||
await handleDeleteThread(target.item);
|
||||
} catch (err) {
|
||||
toast.error("Failed to delete chat", {
|
||||
description: err instanceof Error ? err.message : undefined,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (target.run.status === "running") {
|
||||
toast.error("Cannot delete a running training run");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await deleteTrainingRun(target.run.id);
|
||||
if (selectedHistoryRunId === target.run.id) {
|
||||
setSelectedHistoryRunId(null);
|
||||
}
|
||||
emitTrainingRunDeleted(target.run.id);
|
||||
} catch (err) {
|
||||
toast.error("Failed to delete run", {
|
||||
description: err instanceof Error ? err.message : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Sidebar
|
||||
|
|
@ -220,7 +348,7 @@ export function AppSidebar() {
|
|||
variant="sidebar"
|
||||
className="font-heading group-data-[collapsible=icon]:[&_[data-sidebar=sidebar]]:bg-white dark:group-data-[collapsible=icon]:[&_[data-sidebar=sidebar]]:bg-background"
|
||||
>
|
||||
<SidebarHeader className="pl-[17px] pr-3 pt-[12px] pb-[12px] group-data-[collapsible=icon]:px-0">
|
||||
<SidebarHeader className="pl-[17px] pr-3 pt-[12px] pb-[8px] group-data-[collapsible=icon]:px-0">
|
||||
{/* Expanded: compact logo + close toggle */}
|
||||
<div className="flex items-center justify-between gap-[8.5px] group-data-[collapsible=icon]:hidden">
|
||||
<Link
|
||||
|
|
@ -246,10 +374,7 @@ export function AppSidebar() {
|
|||
<span className="font-heading text-[21px] font-semibold tracking-[-0.01em] dark:tracking-[0.02em] leading-none text-black dark:text-white">
|
||||
unsloth
|
||||
</span>
|
||||
<span
|
||||
style={{ fontFamily: '"Inter Variable", ui-sans-serif, system-ui, sans-serif' }}
|
||||
className="ml-0.5 inline-flex items-center justify-center rounded-full border border-[#e0ded6] px-[5px] py-[2px] text-[8px] font-medium leading-none tracking-[0.04em] text-[#62605a] antialiased subpixel-antialiased shadow-[0_1px_2px_rgba(0,0,0,0.06)] dark:border-[#3a3c3f] dark:text-[#9d9fa5] dark:shadow-[0_1px_2px_rgba(0,0,0,0.35)]"
|
||||
>
|
||||
<span className="nav-badge ml-0.5 inline-flex items-center justify-center rounded-full border border-nav-beta-border px-[5px] pt-[3px] pb-[2px] text-[8px] font-medium leading-none tracking-[0.04em] text-nav-fg-muted antialiased subpixel-antialiased shadow-[0_1px_2px_rgba(0,0,0,0.06)] dark:shadow-[0_1px_2px_rgba(0,0,0,0.35)]">
|
||||
BETA
|
||||
</span>
|
||||
</Link>
|
||||
|
|
@ -259,13 +384,17 @@ export function AppSidebar() {
|
|||
<button
|
||||
type="button"
|
||||
onClick={togglePinned}
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded-[10px] text-[#8f8f8f] dark:text-[#5c5c5c] transition-colors hover:bg-[#f0f0f0] dark:hover:bg-[#2a2c2f] hover:text-black dark:hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
className="inline-flex h-[35px] w-[32px] items-center justify-center rounded-[10px] text-nav-icon-idle dark:text-nav-fg-muted transition-colors hover:bg-nav-surface-hover hover:text-black dark:hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
aria-label="Close sidebar"
|
||||
>
|
||||
<HugeiconsIcon icon={LayoutAlignLeftIcon} strokeWidth={1.75} className="size-[18px]" />
|
||||
<HugeiconsIcon icon={LayoutAlignLeftIcon} strokeWidth={1.75} className="size-icon" />
|
||||
</button>
|
||||
</TooltipPrimitive.Trigger>
|
||||
<TooltipContent side="bottom" sideOffset={6}>
|
||||
<TooltipContent
|
||||
side="bottom"
|
||||
sideOffset={6}
|
||||
className="tooltip-compact"
|
||||
>
|
||||
Close sidebar
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
|
@ -274,19 +403,23 @@ export function AppSidebar() {
|
|||
|
||||
{/* Collapsed: panel icon doubles as expand trigger */}
|
||||
{!isMobile && (
|
||||
<div className="hidden group-data-[collapsible=icon]:flex h-[34px] items-center justify-center w-full">
|
||||
<div className="hidden group-data-[collapsible=icon]:flex h-[35px] items-center justify-center w-full">
|
||||
<Tooltip>
|
||||
<TooltipPrimitive.Trigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={togglePinned}
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded-[10px] text-[#383835] dark:text-[#c7c7c4] transition-colors hover:bg-[#f0f0f0] dark:hover:bg-[#2a2c2f] hover:text-black dark:hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
className="inline-flex h-[35px] w-[32px] items-center justify-center rounded-[10px] text-nav-fg transition-colors hover:bg-nav-surface-hover hover:text-black dark:hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
aria-label="Open sidebar"
|
||||
>
|
||||
<HugeiconsIcon icon={LayoutAlignLeftIcon} strokeWidth={1.75} className="size-[18px]" />
|
||||
<HugeiconsIcon icon={LayoutAlignLeftIcon} strokeWidth={1.75} className="size-icon" />
|
||||
</button>
|
||||
</TooltipPrimitive.Trigger>
|
||||
<TooltipContent side="right" sideOffset={8}>
|
||||
<TooltipContent
|
||||
side="right"
|
||||
sideOffset={8}
|
||||
className="tooltip-compact"
|
||||
>
|
||||
Open sidebar
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
|
@ -294,7 +427,7 @@ export function AppSidebar() {
|
|||
)}
|
||||
</SidebarHeader>
|
||||
|
||||
<SidebarGroup className="group-data-[collapsible=icon]:px-0 px-2 pt-[10px] pb-[14px] shrink-0">
|
||||
<SidebarGroup className="group-data-[collapsible=icon]:px-0 px-2 pt-[9px] pb-[8px] shrink-0">
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
<NavItem
|
||||
|
|
@ -337,66 +470,65 @@ export function AppSidebar() {
|
|||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
|
||||
<SidebarContent className="gap-0 overflow-y-auto overscroll-contain min-h-0">
|
||||
{/* Navigate (no header) */}
|
||||
<SidebarGroup data-tour="navbar" className="group-data-[collapsible=icon]:px-0 px-2 pt-[10px] pb-[14px]">
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
<NavItem
|
||||
icon={GemIcon}
|
||||
label="Train"
|
||||
active={pathname === "/studio" || pathname.startsWith("/studio/")}
|
||||
disabled={chatOnly}
|
||||
onClick={() => {
|
||||
if (chatOnly) return;
|
||||
navigate({ to: "/studio" });
|
||||
closeMobileIfOpen();
|
||||
}}
|
||||
/>
|
||||
<SidebarGroup data-tour="navbar" className="group-data-[collapsible=icon]:px-0 px-2 pt-[9px] pb-[20px] shrink-0">
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
<NavItem
|
||||
icon={TestTubeOutlineIcon}
|
||||
label="Train"
|
||||
active={pathname === "/studio" || pathname.startsWith("/studio/")}
|
||||
disabled={chatOnly}
|
||||
onClick={() => {
|
||||
if (chatOnly) return;
|
||||
navigate({ to: "/studio" });
|
||||
closeMobileIfOpen();
|
||||
}}
|
||||
/>
|
||||
|
||||
<NavItem
|
||||
icon={ChefHatIcon}
|
||||
label="Recipes"
|
||||
active={isRecipesRoute}
|
||||
onClick={() => {
|
||||
navigate({ to: "/data-recipes" });
|
||||
closeMobileIfOpen();
|
||||
}}
|
||||
/>
|
||||
<NavItem
|
||||
icon={ChefHatIcon}
|
||||
label="Recipes"
|
||||
active={isRecipesRoute}
|
||||
onClick={() => {
|
||||
navigate({ to: "/data-recipes" });
|
||||
closeMobileIfOpen();
|
||||
}}
|
||||
/>
|
||||
|
||||
<NavItem
|
||||
icon={Download03Icon}
|
||||
label="Export"
|
||||
active={pathname === "/export" || pathname.startsWith("/export/")}
|
||||
disabled={chatOnly}
|
||||
onClick={() => {
|
||||
if (chatOnly) return;
|
||||
navigate({ to: "/export" });
|
||||
closeMobileIfOpen();
|
||||
}}
|
||||
/>
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
<NavItem
|
||||
icon={DownloadSquare01Icon}
|
||||
label="Export"
|
||||
active={pathname === "/export" || pathname.startsWith("/export/")}
|
||||
disabled={chatOnly}
|
||||
onClick={() => {
|
||||
if (chatOnly) return;
|
||||
navigate({ to: "/export" });
|
||||
closeMobileIfOpen();
|
||||
}}
|
||||
/>
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
|
||||
<SidebarContent ref={scrollRef} className="gap-0 overflow-y-auto overscroll-contain min-h-0">
|
||||
{/* Recent Chats — hide on Studio only (Eyera fac13); chatOpen = ec695 clickability */}
|
||||
{!isStudioRoute && chatItems.length > 0 && (
|
||||
<Collapsible open={chatOpen} onOpenChange={setChatOpen} asChild>
|
||||
<SidebarGroup className="group-data-[collapsible=icon]:hidden overflow-hidden px-2 py-0">
|
||||
<SidebarGroupLabel className="pt-2 pb-1.5 pl-2.5 pr-2 text-[12.5px]! font-normal normal-case tracking-normal text-[#62605a] dark:text-[#9d9fa5] focus-visible:ring-0! focus-visible:outline-none" asChild>
|
||||
<SidebarGroup className="group-data-[collapsible=icon]:hidden px-0 py-0">
|
||||
<SidebarGroupLabel className={cn("sidebar-sticky-label", scrolled && "is-scrolled")} asChild>
|
||||
<CollapsibleTrigger className="cursor-pointer flex w-full items-center justify-between">
|
||||
Recents
|
||||
<ChevronDown className="size-3.5 transition-transform duration-200 data-[state=open]:rotate-0 [[data-state=closed]_&]:rotate-[-90deg]" />
|
||||
</CollapsibleTrigger>
|
||||
</SidebarGroupLabel>
|
||||
<CollapsibleContent>
|
||||
<SidebarGroupContent>
|
||||
<SidebarGroupContent className="px-2">
|
||||
<SidebarMenu>
|
||||
{chatItems.map((item) => (
|
||||
<SidebarMenuItem key={item.id} className="group/recent-item relative">
|
||||
<SidebarMenuButton
|
||||
isActive={activeThreadId === item.id}
|
||||
className="h-[32px] rounded-[10px] pl-2.5 pr-7 text-[14px] leading-[18px] tracking-[0.01em] font-medium text-[#383835] dark:text-[#c7c7c4] hover:bg-[#f0f0f0]! dark:hover:bg-[#2a2c2f]! hover:text-black! dark:hover:text-white! data-active:bg-[#f0f0f0]! dark:data-active:bg-[#2a2c2f]! data-active:text-black! dark:data-active:text-white!"
|
||||
className="sidebar-nav-btn h-[32px] rounded-[10px] pl-2.5 pr-2.5 group-hover/recent-item:pr-10 group-has-[.sidebar-row-action[data-state=open]]/recent-item:pr-10 text-[14.5px] leading-[19px] tracking-nav font-medium"
|
||||
onClick={() => {
|
||||
navigate({
|
||||
to: "/chat",
|
||||
|
|
@ -410,17 +542,38 @@ export function AppSidebar() {
|
|||
>
|
||||
<span className="truncate">{item.title}</span>
|
||||
</SidebarMenuButton>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDeleteThread(item);
|
||||
}}
|
||||
title="Delete"
|
||||
className="absolute right-1 top-1/2 -translate-y-1/2 flex size-5 scale-90 items-center justify-center rounded-[10px] text-sidebar-foreground/55 opacity-0 transition-all duration-150 hover:bg-destructive/12 hover:text-destructive group-hover/recent-item:scale-100 group-hover/recent-item:opacity-100"
|
||||
>
|
||||
<HugeiconsIcon icon={Delete02Icon} strokeWidth={2} className="size-3.5" />
|
||||
</button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
aria-label="Chat options"
|
||||
className="sidebar-row-action group-hover/recent-item:opacity-100 group-hover/recent-item:pointer-events-auto focus-visible:opacity-100 focus-visible:pointer-events-auto"
|
||||
>
|
||||
<span className="sidebar-row-action-glyph">
|
||||
<MoreHorizontalIcon strokeWidth={1.75} className="size-icon" />
|
||||
</span>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
side="bottom"
|
||||
align="end"
|
||||
sideOffset={4}
|
||||
className="app-user-menu menu-soft-surface menu-flat-destructive ring-0 w-44 py-2 font-heading rounded-[14px] border-0"
|
||||
>
|
||||
<DropdownMenuItem onSelect={() => openRenameChat(item)}>
|
||||
<HugeiconsIcon icon={Edit03Icon} strokeWidth={1.75} className="size-icon" />
|
||||
<span>Rename</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onSelect={() => setConfirmingDelete({ kind: "chat", item })}
|
||||
>
|
||||
<HugeiconsIcon icon={Delete02Icon} strokeWidth={1.75} className="size-icon" />
|
||||
<span>Delete</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
|
|
@ -433,15 +586,15 @@ export function AppSidebar() {
|
|||
{/* Recent Runs */}
|
||||
{isStudioRoute && runItems.length > 0 && !chatOnly && (
|
||||
<Collapsible open={runsOpen} onOpenChange={setRunsOpen} asChild>
|
||||
<SidebarGroup className="group-data-[collapsible=icon]:hidden overflow-hidden px-2 py-0">
|
||||
<SidebarGroupLabel className="pt-2 pb-1.5 pl-2.5 pr-2 text-[12.5px]! font-normal normal-case tracking-normal text-[#62605a] dark:text-[#9d9fa5] focus-visible:ring-0! focus-visible:outline-none" asChild>
|
||||
<SidebarGroup className="group-data-[collapsible=icon]:hidden px-0 py-0">
|
||||
<SidebarGroupLabel className={cn("sidebar-sticky-label", scrolled && "is-scrolled")} asChild>
|
||||
<CollapsibleTrigger className="cursor-pointer flex w-full items-center justify-between">
|
||||
Recents
|
||||
<ChevronDown className="size-3.5 transition-transform duration-200 data-[state=open]:rotate-0 [[data-state=closed]_&]:rotate-[-90deg]" />
|
||||
</CollapsibleTrigger>
|
||||
</SidebarGroupLabel>
|
||||
<CollapsibleContent>
|
||||
<SidebarGroupContent>
|
||||
<SidebarGroupContent className="px-2">
|
||||
<SidebarMenu>
|
||||
{runItems.map((run) => {
|
||||
const isActiveRun =
|
||||
|
|
@ -453,7 +606,7 @@ export function AppSidebar() {
|
|||
>
|
||||
<SidebarMenuButton
|
||||
isActive={isActiveRun}
|
||||
className="h-auto flex-col items-start gap-0.5 py-1.5 rounded-[10px] pl-2.5 pr-7 text-[14px] tracking-[0.01em] font-medium text-[#383835] dark:text-[#c7c7c4] hover:bg-[#f0f0f0]! dark:hover:bg-[#2a2c2f]! hover:text-black! dark:hover:text-white! data-active:bg-[#f0f0f0]! dark:data-active:bg-[#2a2c2f]! data-active:text-black! dark:data-active:text-white!"
|
||||
className="sidebar-nav-btn h-auto flex-col items-start gap-0.5 py-[5px] rounded-[10px] pl-2.5 pr-7 text-[14.5px] tracking-nav font-medium"
|
||||
onClick={() => {
|
||||
setSelectedHistoryRunId(run.id);
|
||||
closeMobileIfOpen();
|
||||
|
|
@ -468,7 +621,7 @@ export function AppSidebar() {
|
|||
aria-hidden
|
||||
/>
|
||||
<span className="truncate">
|
||||
{run.model_name}
|
||||
{run.display_name ?? run.model_name}
|
||||
</span>
|
||||
<span className="ml-auto shrink-0 text-[10px] text-muted-foreground">
|
||||
{formatRelativeShort(run.started_at)}
|
||||
|
|
@ -478,25 +631,41 @@ export function AppSidebar() {
|
|||
{run.dataset_name}
|
||||
</span>
|
||||
</SidebarMenuButton>
|
||||
<button
|
||||
type="button"
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
await deleteTrainingRun(run.id);
|
||||
if (selectedHistoryRunId === run.id) {
|
||||
setSelectedHistoryRunId(null);
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
aria-label="Run options"
|
||||
className="sidebar-row-action group-hover/run-item:opacity-100 group-hover/run-item:pointer-events-auto focus-visible:opacity-100 focus-visible:pointer-events-auto"
|
||||
>
|
||||
<span className="sidebar-row-action-glyph">
|
||||
<MoreHorizontalIcon strokeWidth={1.75} className="size-icon" />
|
||||
</span>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
side="bottom"
|
||||
align="end"
|
||||
sideOffset={4}
|
||||
className="app-user-menu menu-soft-surface menu-flat-destructive ring-0 w-44 py-2 font-heading rounded-[14px] border-0"
|
||||
>
|
||||
<DropdownMenuItem onSelect={() => openRenameRun(run)}>
|
||||
<HugeiconsIcon icon={Edit03Icon} strokeWidth={1.75} className="size-icon" />
|
||||
<span>Rename</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
disabled={run.status === "running"}
|
||||
onSelect={() =>
|
||||
setConfirmingDelete({ kind: "run", run })
|
||||
}
|
||||
await refreshRuns();
|
||||
} catch {
|
||||
// ignore — next refresh will reconcile
|
||||
}
|
||||
}}
|
||||
title="Delete"
|
||||
className="absolute right-1 top-1/2 -translate-y-1/2 flex size-5 scale-90 items-center justify-center rounded-[10px] text-sidebar-foreground/55 opacity-0 transition-all duration-150 hover:bg-destructive/12 hover:text-destructive group-hover/run-item:scale-100 group-hover/run-item:opacity-100"
|
||||
>
|
||||
<HugeiconsIcon icon={Delete02Icon} strokeWidth={2} className="size-3.5" />
|
||||
</button>
|
||||
>
|
||||
<HugeiconsIcon icon={Delete02Icon} strokeWidth={1.75} className="size-icon" />
|
||||
<span>Delete</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
})}
|
||||
|
|
@ -516,7 +685,7 @@ export function AppSidebar() {
|
|||
<SidebarMenuButton
|
||||
size="lg"
|
||||
aria-label={`${displayTitle} account menu`}
|
||||
className="!h-[50px] gap-[8px] rounded-[10px] text-[#383835] dark:text-[#c7c7c4] hover:bg-[#f0f0f0]! dark:hover:bg-[#2a2c2f]! hover:text-black! dark:hover:text-white! data-[state=open]:bg-[#f0f0f0]! dark:data-[state=open]:bg-[#2a2c2f]! data-[state=open]:text-black! dark:data-[state=open]:text-white!"
|
||||
className="sidebar-nav-btn !h-[50px] gap-[8px] px-2 py-[9px] rounded-[10px]"
|
||||
>
|
||||
<div className="shrink-0">
|
||||
<UserAvatar
|
||||
|
|
@ -527,8 +696,8 @@ export function AppSidebar() {
|
|||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5 leading-tight group-data-[collapsible=icon]:hidden">
|
||||
<span className="truncate font-heading text-[13px] tracking-[0.02em] font-semibold text-[#383835] dark:text-[#c7c7c4]">{displayTitle}</span>
|
||||
<span className="truncate text-[11px] tracking-[0.01em] text-muted-foreground">Unsloth</span>
|
||||
<span className="truncate font-heading text-[13.5px] tracking-[0.025em] dark:tracking-[0.04em] font-semibold text-nav-fg">{displayTitle}</span>
|
||||
<span className="truncate text-[11.5px] tracking-nav text-muted-foreground">Unsloth</span>
|
||||
</div>
|
||||
<ChevronsUpDown strokeWidth={1.25} className="ml-auto size-4 text-muted-foreground group-data-[collapsible=icon]:hidden" />
|
||||
</SidebarMenuButton>
|
||||
|
|
@ -536,13 +705,13 @@ export function AppSidebar() {
|
|||
<DropdownMenuContent
|
||||
side="top"
|
||||
align="start"
|
||||
className="w-[15rem] py-2.5 font-heading [&_[data-slot=dropdown-menu-group]]:flex [&_[data-slot=dropdown-menu-group]]:flex-col [&_[data-slot=dropdown-menu-group]]:gap-px [&_[data-slot=dropdown-menu-item]]:h-[32px] [&_[data-slot=dropdown-menu-item]]:px-2.5! [&_[data-slot=dropdown-menu-item]]:py-0! [&_[data-slot=dropdown-menu-item]]:gap-[8.5px]! [&_[data-slot=dropdown-menu-item]]:rounded-[10px] [&_[data-slot=dropdown-menu-item]]:font-medium [&_[data-slot=dropdown-menu-item]]:text-[14px] [&_[data-slot=dropdown-menu-item]]:leading-[18px] [&_[data-slot=dropdown-menu-item]]:tracking-[0.01em] [&_[data-slot=dropdown-menu-item]]:text-[#383835] dark:[&_[data-slot=dropdown-menu-item]]:text-[#c7c7c4] [&_[data-slot=dropdown-menu-item]_svg]:!size-[18px] [&_[data-slot=dropdown-menu-item]_svg]:shrink-0 [&_[data-slot=dropdown-menu-item]:focus]:bg-[#f0f0f0] dark:[&_[data-slot=dropdown-menu-item]:focus]:bg-[#2a2c2f] [&_[data-slot=dropdown-menu-item]:focus]:text-black dark:[&_[data-slot=dropdown-menu-item]:focus]:text-white [&_[data-slot=dropdown-menu-item]:focus_*]:text-black! dark:[&_[data-slot=dropdown-menu-item]:focus_*]:text-white!"
|
||||
className="app-user-menu menu-soft-surface-up ring-0 w-[15rem] py-2.5 font-heading rounded-[14px] border-0"
|
||||
>
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem
|
||||
onSelect={() => useSettingsDialogStore.getState().openDialog()}
|
||||
>
|
||||
<HugeiconsIcon icon={Settings02Icon} strokeWidth={1.75} className="size-[18px]" />
|
||||
<HugeiconsIcon icon={Settings02Icon} strokeWidth={1.75} className="size-icon" />
|
||||
<span>Settings</span>
|
||||
<DropdownMenuShortcut>⌘,</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
|
|
@ -559,7 +728,7 @@ export function AppSidebar() {
|
|||
ref={anchorRef as React.Ref<HTMLDivElement>}
|
||||
onSelect={(e) => { e.preventDefault(); toggleTheme(); }}
|
||||
>
|
||||
{isDark ? <Sun strokeWidth={1.75} className="size-[18px]" /> : <Moon strokeWidth={1.75} className="size-[18px]" />}
|
||||
{isDark ? <Sun strokeWidth={1.75} className="size-icon" /> : <Moon strokeWidth={1.75} className="size-icon" />}
|
||||
<span>{isDark ? "Light Mode" : "Dark Mode"}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
|
|
@ -574,7 +743,7 @@ export function AppSidebar() {
|
|||
);
|
||||
}}
|
||||
>
|
||||
<HugeiconsIcon icon={CursorInfo02Icon} strokeWidth={1.75} className="size-[18px]" />
|
||||
<HugeiconsIcon icon={CursorInfo02Icon} strokeWidth={1.75} className="size-icon" />
|
||||
<span>Guided Tour</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
|
|
@ -582,11 +751,11 @@ export function AppSidebar() {
|
|||
<DropdownMenuItem
|
||||
onSelect={() => useSettingsDialogStore.getState().openDialog("about")}
|
||||
>
|
||||
<HugeiconsIcon icon={HelpCircleIcon} strokeWidth={1.75} className="size-[18px]" />
|
||||
<HugeiconsIcon icon={HelpCircleIcon} strokeWidth={1.75} className="size-icon" />
|
||||
<span>Help</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => setShutdownOpen(true)}>
|
||||
<HugeiconsIcon icon={PowerIcon} strokeWidth={1.75} className="size-[18px]" />
|
||||
<HugeiconsIcon icon={PowerIcon} strokeWidth={1.75} className="size-icon" />
|
||||
<span>Shutdown</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
|
|
@ -601,6 +770,96 @@ export function AppSidebar() {
|
|||
onOpenChange={setShutdownOpen}
|
||||
onAfterShutdown={removeTrainingUnloadGuard}
|
||||
/>
|
||||
<Dialog
|
||||
open={confirmingDelete !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setConfirmingDelete(null);
|
||||
}}
|
||||
>
|
||||
<DialogContent className="menu-flat-destructive corner-squircle border border-border/60 bg-background/98 shadow-none sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{confirmingDelete?.kind === "run"
|
||||
? "Delete training run"
|
||||
: "Delete chat"}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{confirmingDelete?.kind === "run" ? (
|
||||
<>
|
||||
Are you sure you want to delete this run{" "}
|
||||
<em>{confirmingDelete.run.display_name ?? confirmingDelete.run.model_name}</em>?
|
||||
</>
|
||||
) : confirmingDelete?.kind === "chat" ? (
|
||||
<>
|
||||
Are you sure you want to delete this chat{" "}
|
||||
<em>{confirmingDelete.item.title}</em>?
|
||||
</>
|
||||
) : null}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="flex-wrap gap-2 sm:justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => setConfirmingDelete(null)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
onClick={() => void commitDelete()}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<Dialog
|
||||
open={renamingTarget !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setRenamingTarget(null);
|
||||
}}
|
||||
>
|
||||
<DialogContent className="corner-squircle border border-border/60 bg-background/98 shadow-none sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{renamingTarget?.kind === "run" ? "Rename run" : "Rename chat"}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<Input
|
||||
value={renameDraft}
|
||||
onChange={(event) => setRenameDraft(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
void commitRename();
|
||||
}
|
||||
}}
|
||||
autoFocus
|
||||
maxLength={120}
|
||||
placeholder={renamingTarget?.kind === "run" ? "Run name" : "Chat title"}
|
||||
aria-label={renamingTarget?.kind === "run" ? "Run name" : "Chat title"}
|
||||
className="focus-visible:border-input focus-visible:ring-0"
|
||||
/>
|
||||
<DialogFooter className="flex-wrap gap-2 sm:justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => setRenamingTarget(null)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => void commitRename()}
|
||||
disabled={!renameDirty}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -184,7 +184,7 @@ const AttachmentUI: FC = () => {
|
|||
</AttachmentPreviewDialog>
|
||||
{isComposer && <AttachmentRemove />}
|
||||
</AttachmentPrimitive.Root>
|
||||
<TooltipContent side="top">
|
||||
<TooltipContent side="top" className="tooltip-compact">
|
||||
<AttachmentPrimitive.Name />
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import { copyToClipboard } from "@/lib/copy-to-clipboard";
|
|||
import { preprocessLaTeX } from "@/lib/latex";
|
||||
import { openLink } from "@/lib/open-link";
|
||||
import { INTERNAL, useMessagePartText } from "@assistant-ui/react";
|
||||
import { Copy02Icon, Tick02Icon } from "@hugeicons/core-free-icons";
|
||||
import { Copy01Icon, Tick02Icon } from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { createCodePlugin } from "./code-plugin";
|
||||
import { createMathPlugin } from "@streamdown/math";
|
||||
|
|
@ -50,9 +50,9 @@ 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 dark:border-white/10 dark:bg-code-block dark:supports-[backdrop-filter]:bg-code-block";
|
||||
"pointer-events-auto flex shrink-0 items-center gap-1";
|
||||
const ACTION_BUTTON_CLASS =
|
||||
"cursor-pointer p-1 text-muted-foreground transition-all hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50";
|
||||
"flex size-8 cursor-pointer items-center justify-center rounded-[10px] text-chat-icon-fg transition-all hover:bg-chat-icon-bg-hover hover:text-chat-icon-fg-hover disabled:cursor-not-allowed disabled:opacity-50";
|
||||
|
||||
type CodeFence = {
|
||||
language: string | null;
|
||||
|
|
@ -289,8 +289,9 @@ function MermaidCopyButton({ source }: { source: string }) {
|
|||
}}
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={copied ? Tick02Icon : Copy02Icon}
|
||||
className="size-5"
|
||||
icon={copied ? Tick02Icon : Copy01Icon}
|
||||
strokeWidth={1.75}
|
||||
className="size-icon"
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
|
|
@ -308,7 +309,7 @@ function CodeBlockActions({
|
|||
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="pointer-events-none absolute top-3 right-3 z-20 flex items-center justify-end">
|
||||
<div className={ACTION_PANEL_CLASS}>
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -323,8 +324,9 @@ function CodeBlockActions({
|
|||
}}
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={copied ? Tick02Icon : Copy02Icon}
|
||||
className="size-3.5"
|
||||
icon={copied ? Tick02Icon : Copy01Icon}
|
||||
strokeWidth={1.75}
|
||||
className="size-icon"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
|
|
@ -336,7 +338,7 @@ function CodeBlockActions({
|
|||
downloadTextFile(getCodeFilename(language), source);
|
||||
}}
|
||||
>
|
||||
<DownloadIcon className="size-3.5" />
|
||||
<DownloadIcon className="size-icon" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ export const MessageTiming: FC<{
|
|||
data-slot="message-timing-trigger"
|
||||
aria-label="Message timing"
|
||||
className={cn(
|
||||
"flex items-center rounded-md p-1 font-mono text-muted-foreground text-xs tabular-nums transition-colors hover:bg-accent hover:text-accent-foreground",
|
||||
"flex items-center rounded-[10px] p-1 font-mono text-chat-icon-fg text-[13px] tabular-nums transition-colors hover:bg-chat-icon-bg-hover hover:text-chat-icon-fg-hover",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
|
|
@ -62,7 +62,8 @@ export const MessageTiming: FC<{
|
|||
side={side}
|
||||
sideOffset={8}
|
||||
data-slot="message-timing-popover"
|
||||
className="[&_span>svg]:hidden! rounded-lg border bg-popover px-3 py-2 text-popover-foreground shadow-md"
|
||||
variant="rich"
|
||||
className="[&_span>svg]:hidden!"
|
||||
>
|
||||
<div className="grid min-w-40 gap-1.5 text-xs">
|
||||
{st ? (
|
||||
|
|
|
|||
|
|
@ -78,9 +78,9 @@ function ModelSelectorTrigger({
|
|||
className={cn(
|
||||
"flex min-w-0 items-center gap-2 transition-colors",
|
||||
variant === "outline" &&
|
||||
"rounded-[8px] border border-border/60 hover:bg-[#ececec] dark:hover:bg-[#2e3035]",
|
||||
variant === "ghost" && "rounded-[8px] hover:bg-[#ececec] dark:hover:bg-[#2e3035]",
|
||||
variant === "muted" && "rounded-[8px] bg-muted hover:bg-muted/80",
|
||||
"rounded-[10px] border border-border/60 hover:bg-[#ececec] dark:hover:bg-[#2d2e32]",
|
||||
variant === "ghost" && "rounded-[10px] hover:bg-[#ececec] dark:hover:bg-[#2d2e32]",
|
||||
variant === "muted" && "rounded-[10px] bg-muted hover:bg-muted/80",
|
||||
size === "sm" && "h-8 px-3 text-xs",
|
||||
size === "default" && "h-9 px-3.5 text-sm",
|
||||
size === "lg" && "h-10 px-4 text-sm",
|
||||
|
|
@ -145,7 +145,7 @@ function ModelSelectorContent({
|
|||
align="start"
|
||||
data-tour={dataTour}
|
||||
className={cn(
|
||||
"w-[min(440px,calc(100vw-1rem))] max-w-[calc(100vw-1rem)] min-w-0 gap-0 p-2",
|
||||
"menu-soft-surface ring-0 w-[min(440px,calc(100vw-1rem))] max-w-[calc(100vw-1rem)] min-w-0 gap-0 p-2",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -175,7 +175,10 @@ function ModelRow({
|
|||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild={true}>{content}</TooltipTrigger>
|
||||
<TooltipContent side="left" className="max-w-xs break-all">
|
||||
<TooltipContent
|
||||
side="left"
|
||||
className="tooltip-compact max-w-xs break-all"
|
||||
>
|
||||
{label}
|
||||
<span className="block text-[10px] mt-1">{vramTooltipText}</span>
|
||||
</TooltipContent>
|
||||
|
|
@ -187,7 +190,10 @@ function ModelRow({
|
|||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild={true}>{content}</TooltipTrigger>
|
||||
<TooltipContent side="left" className="max-w-xs break-all">
|
||||
<TooltipContent
|
||||
side="left"
|
||||
className="tooltip-compact max-w-xs break-all"
|
||||
>
|
||||
{tooltipText}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
|
|
|||
|
|
@ -104,7 +104,7 @@ function Source({
|
|||
variant={variant}
|
||||
size={size}
|
||||
className={cn(
|
||||
"cursor-pointer outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
|
||||
"rounded-full cursor-pointer outline-none hover:bg-chat-icon-bg-hover! hover:text-chat-icon-fg-hover! focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
|
|
@ -137,7 +137,7 @@ const SourceBadge: FC<{ source: SourceData }> = ({ source }) => {
|
|||
const displayTitle = source.title || domain;
|
||||
|
||||
return (
|
||||
<HoverCard openDelay={300} closeDelay={100}>
|
||||
<HoverCard openDelay={0} closeDelay={0}>
|
||||
<HoverCardTrigger asChild>
|
||||
<span className="inline-block">
|
||||
<Source href={source.url}>
|
||||
|
|
@ -146,16 +146,21 @@ const SourceBadge: FC<{ source: SourceData }> = ({ source }) => {
|
|||
</Source>
|
||||
</span>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent side="top" align="start" className="w-72 p-3">
|
||||
<HoverCardContent
|
||||
side="top"
|
||||
align="start"
|
||||
className="!bg-black !text-white !w-72 !p-3 !rounded-2xl !shadow-md !ring-0 !duration-0"
|
||||
style={{ animation: "none" }}
|
||||
>
|
||||
<div className="flex gap-2.5">
|
||||
<SourceIcon url={source.url} size={4} className="mt-0.5 shrink-0" />
|
||||
<div className="min-w-0 space-y-1">
|
||||
<p className="text-sm font-semibold leading-tight truncate">
|
||||
{source.title || domain}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground truncate">{domain}</p>
|
||||
<p className="text-xs text-white/60 truncate">{domain}</p>
|
||||
{source.description && (
|
||||
<p className="text-xs text-muted-foreground leading-relaxed line-clamp-3">
|
||||
<p className="text-xs text-white/70 leading-relaxed line-clamp-3">
|
||||
{source.description}
|
||||
</p>
|
||||
)}
|
||||
|
|
@ -245,7 +250,7 @@ const SourcesGroup: FC = () => {
|
|||
const hiddenCount = sources.length - (visibleCount ?? sources.length);
|
||||
|
||||
return (
|
||||
<div className="relative mt-2">
|
||||
<div className="relative mt-2 mb-3">
|
||||
{/* Hidden measurement container — renders all badges to measure row positions */}
|
||||
<div
|
||||
ref={containerRef}
|
||||
|
|
@ -273,7 +278,7 @@ const SourcesGroup: FC = () => {
|
|||
onClick={() => setExpanded(true)}
|
||||
className={cn(
|
||||
badgeVariants({ variant: "outline", size: "default" }),
|
||||
"cursor-pointer text-muted-foreground hover:text-foreground",
|
||||
"rounded-full cursor-pointer text-muted-foreground hover:bg-chat-icon-bg-hover! hover:text-chat-icon-fg-hover!",
|
||||
)}
|
||||
>
|
||||
+{hiddenCount} more
|
||||
|
|
@ -285,7 +290,7 @@ const SourcesGroup: FC = () => {
|
|||
onClick={() => setExpanded(false)}
|
||||
className={cn(
|
||||
badgeVariants({ variant: "outline", size: "default" }),
|
||||
"cursor-pointer text-muted-foreground hover:text-foreground",
|
||||
"rounded-full cursor-pointer text-muted-foreground hover:bg-chat-icon-bg-hover! hover:text-chat-icon-fg-hover!",
|
||||
)}
|
||||
>
|
||||
Show less
|
||||
|
|
|
|||
|
|
@ -54,10 +54,8 @@ import {
|
|||
import {
|
||||
ArrowDownIcon,
|
||||
ArrowUpIcon,
|
||||
CheckIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
CopyIcon,
|
||||
DownloadIcon,
|
||||
GlobeIcon,
|
||||
HeadphonesIcon,
|
||||
|
|
@ -66,14 +64,13 @@ import {
|
|||
LoaderIcon,
|
||||
MicIcon,
|
||||
MoreHorizontalIcon,
|
||||
PencilIcon,
|
||||
RefreshCwIcon,
|
||||
SquareIcon,
|
||||
TerminalIcon,
|
||||
Trash2Icon,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
import { Copy01Icon, Delete02Icon, Edit03Icon, Tick02Icon } from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import {
|
||||
type FC,
|
||||
type FormEvent,
|
||||
|
|
@ -108,9 +105,9 @@ export const Thread: FC<{
|
|||
<ThreadPrimitive.Root
|
||||
className="aui-root aui-thread-root @container relative flex min-h-0 min-w-0 flex-1 basis-0 flex-col overflow-hidden"
|
||||
style={{
|
||||
["--thread-max-width" as string]: "44rem",
|
||||
["--thread-max-width" as string]: "48rem",
|
||||
["--thread-content-max-width" as string]:
|
||||
"calc(var(--thread-max-width) - 2.5rem)",
|
||||
"calc(var(--thread-max-width) - 1.5rem)",
|
||||
}}
|
||||
>
|
||||
<IntentAwareScrollProvider value={autoScrollContext}>
|
||||
|
|
@ -121,7 +118,7 @@ export const Thread: FC<{
|
|||
scrollToBottomOnInitialize={false}
|
||||
scrollToBottomOnThreadSwitch={false}
|
||||
className={cn(
|
||||
"aui-thread-viewport relative flex min-h-0 min-w-0 flex-1 basis-0 flex-col overflow-x-auto overflow-y-auto scroll-smooth px-5",
|
||||
"aui-thread-viewport aui-stream-viewport relative flex min-h-0 min-w-0 flex-1 basis-0 flex-col overflow-x-auto overflow-y-auto scroll-smooth px-5",
|
||||
hideComposer ? "pt-4" : "pt-[48px]",
|
||||
)}
|
||||
>
|
||||
|
|
@ -164,7 +161,7 @@ export const Thread: FC<{
|
|||
|
||||
{!hideComposer && (
|
||||
<AuiIf condition={({ thread }) => hideWelcome || !thread.isEmpty}>
|
||||
<div className="aui-thread-composer-dock pointer-events-none absolute bottom-0 left-0 right-0 md:right-2 z-20">
|
||||
<div className="aui-thread-composer-dock pointer-events-none absolute bottom-0 left-0 right-0 md:right-[10px] z-20">
|
||||
<div
|
||||
aria-hidden={true}
|
||||
className="absolute inset-x-0 bottom-0 top-[10px] bg-background"
|
||||
|
|
@ -173,8 +170,8 @@ export const Thread: FC<{
|
|||
<div className="pointer-events-auto mx-auto w-full max-w-(--thread-max-width)">
|
||||
<ComposerAnimated disabled={isComposerAttachPending} />
|
||||
</div>
|
||||
<p className="mt-1.5 text-center text-[11px] text-muted-foreground">
|
||||
LLMs can make mistakes. Double-check all responses.
|
||||
<p className="composer-footer-note">
|
||||
LLMs can make mistakes. Double-check responses.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -204,7 +201,7 @@ const ThreadScrollToBottom: FC = () => {
|
|||
isAtBottom && "invisible pointer-events-none",
|
||||
)}
|
||||
>
|
||||
<ArrowDownIcon />
|
||||
<ArrowDownIcon strokeWidth={1.75} className="size-icon" />
|
||||
</TooltipIconButton>
|
||||
);
|
||||
};
|
||||
|
|
@ -253,14 +250,9 @@ const GeneratingSpinner: FC = () => {
|
|||
const ComposerAnimated: FC<{ disabled?: boolean }> = ({ disabled }) => {
|
||||
return (
|
||||
<div className="relative mx-auto min-w-0 w-full max-w-(--thread-max-width)">
|
||||
<motion.div
|
||||
layout={true}
|
||||
layoutId="composer"
|
||||
transition={{ type: "spring", bounce: 0.15, duration: 0.5 }}
|
||||
className="relative z-10 w-full"
|
||||
>
|
||||
<div className="relative z-10 w-full">
|
||||
<Composer disabled={disabled} />
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -306,7 +298,7 @@ const Composer: FC<{ disabled?: boolean }> = ({ disabled }) => {
|
|||
<ToolStatusDisplay />
|
||||
<ComposerPrimitive.Input
|
||||
placeholder="Send a message..."
|
||||
className="aui-composer-input mb-1 min-h-12 w-full resize-none overflow-y-auto bg-transparent pl-5 pr-4 pt-2 pb-3 text-sm font-[450] outline-none placeholder:text-muted-foreground focus-visible:ring-0"
|
||||
className="aui-composer-input composer-input"
|
||||
minRows={1}
|
||||
maxRows={6}
|
||||
autoFocus={!disabled}
|
||||
|
|
@ -326,11 +318,11 @@ const Composer: FC<{ disabled?: boolean }> = ({ disabled }) => {
|
|||
{isTauri ? (
|
||||
// Phase 1 native model drops own Tauri local-path drops. Restore browser
|
||||
// attachment drops in Tauri when Phase 1d adds attachment-token bridging.
|
||||
<div className="aui-composer-attachment-dropzone chat-composer-surface flex w-full flex-col rounded-3xl bg-background dark:bg-card px-1 pt-2 outline-none transition-shadow">
|
||||
<div className="aui-composer-attachment-dropzone chat-composer-surface">
|
||||
{composerContent}
|
||||
</div>
|
||||
) : (
|
||||
<ComposerPrimitive.AttachmentDropzone className="aui-composer-attachment-dropzone chat-composer-surface flex w-full flex-col rounded-3xl bg-background dark:bg-card px-1 pt-2 outline-none transition-shadow data-[dragging=true]:border-ring data-[dragging=true]:bg-accent/50">
|
||||
<ComposerPrimitive.AttachmentDropzone className="aui-composer-attachment-dropzone chat-composer-surface data-[dragging=true]:border-ring data-[dragging=true]:bg-accent/50">
|
||||
{composerContent}
|
||||
</ComposerPrimitive.AttachmentDropzone>
|
||||
)}
|
||||
|
|
@ -455,14 +447,8 @@ const ReasoningToggle: FC = () => {
|
|||
setReasoningEnabled(next);
|
||||
applyQwenThinkingParams(next);
|
||||
}}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium transition-colors",
|
||||
disabled
|
||||
? "cursor-not-allowed opacity-40"
|
||||
: reasoningEnabled
|
||||
? "bg-primary/10 text-primary hover:bg-primary/20"
|
||||
: "bg-muted text-muted-foreground hover:bg-muted-foreground/15",
|
||||
)}
|
||||
className="composer-pill-btn"
|
||||
data-active={reasoningEnabled && !disabled ? "true" : "false"}
|
||||
aria-label={reasoningEnabled ? "Disable thinking" : "Enable thinking"}
|
||||
>
|
||||
{reasoningEnabled && !disabled ? (
|
||||
|
|
@ -527,14 +513,8 @@ const WebSearchToggle: FC = () => {
|
|||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => setToolsEnabled(!toolsEnabled)}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium transition-colors",
|
||||
disabled
|
||||
? "cursor-not-allowed opacity-40"
|
||||
: toolsEnabled
|
||||
? "bg-primary/10 text-primary hover:bg-primary/20"
|
||||
: "bg-muted text-muted-foreground hover:bg-muted-foreground/15",
|
||||
)}
|
||||
className="composer-pill-btn"
|
||||
data-active={toolsEnabled && !disabled ? "true" : "false"}
|
||||
aria-label={toolsEnabled ? "Disable web search" : "Enable web search"}
|
||||
>
|
||||
<GlobeIcon className="size-3.5" />
|
||||
|
|
@ -557,14 +537,8 @@ const CodeToolsToggle: FC = () => {
|
|||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => setCodeToolsEnabled(!codeToolsEnabled)}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium transition-colors",
|
||||
disabled
|
||||
? "cursor-not-allowed opacity-40"
|
||||
: codeToolsEnabled
|
||||
? "bg-primary/10 text-primary hover:bg-primary/20"
|
||||
: "bg-muted text-muted-foreground hover:bg-muted-foreground/15",
|
||||
)}
|
||||
className="composer-pill-btn"
|
||||
data-active={codeToolsEnabled && !disabled ? "true" : "false"}
|
||||
aria-label={
|
||||
codeToolsEnabled ? "Disable code execution" : "Enable code execution"
|
||||
}
|
||||
|
|
@ -635,7 +609,7 @@ const ToolStatusDisplay: FC = () => {
|
|||
|
||||
const ComposerAction: FC<{ disabled?: boolean }> = ({ disabled }) => {
|
||||
return (
|
||||
<div className="aui-composer-action-wrapper relative mx-2 mb-2 flex items-center justify-between">
|
||||
<div className="aui-composer-action-wrapper composer-action-wrapper">
|
||||
<div className="flex items-center gap-1">
|
||||
<ComposerAddAttachment />
|
||||
<ComposerAudioUpload />
|
||||
|
|
@ -725,10 +699,10 @@ const GeneratingIndicator: FC = () => {
|
|||
const AssistantMessage: FC = () => {
|
||||
return (
|
||||
<MessagePrimitive.Root
|
||||
className="aui-assistant-message-root fade-in slide-in-from-bottom-1 relative mx-auto min-w-0 w-full max-w-(--thread-content-max-width) animate-in py-0.5 text-[15.5px] font-[450] duration-150"
|
||||
className="aui-assistant-message-root relative mx-auto min-w-0 w-full max-w-(--thread-content-max-width) pt-0.5 pb-4 text-[15.5px] [font-weight:410] tracking-[0.01em] dark:tracking-[0.02em]"
|
||||
data-role="assistant"
|
||||
>
|
||||
<div className="aui-assistant-message-content wrap-break-word min-w-0 text-foreground leading-relaxed">
|
||||
<div className="aui-assistant-message-content wrap-break-word min-w-0 text-[#0d0d0d] dark:text-foreground leading-relaxed">
|
||||
<GeneratingIndicator />
|
||||
<MessagePrimitive.Parts
|
||||
components={{
|
||||
|
|
@ -751,8 +725,8 @@ const AssistantMessage: FC = () => {
|
|||
<MessageError />
|
||||
</div>
|
||||
|
||||
<div className="aui-assistant-message-footer mt-1 flex">
|
||||
<BranchPicker />
|
||||
<div className="aui-assistant-message-footer mt-1.5 -ml-[var(--icon-btn-inset)] flex min-h-8">
|
||||
<BranchPicker className="mr-0.5" />
|
||||
<AssistantActionBar />
|
||||
</div>
|
||||
</MessagePrimitive.Root>
|
||||
|
|
@ -789,9 +763,13 @@ const DeleteMessageButton: FC = () => {
|
|||
tooltip="Delete message"
|
||||
disabled={isRunning}
|
||||
onClick={handleDelete}
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
className="text-chat-icon-fg hover:text-destructive"
|
||||
>
|
||||
<Trash2Icon className="size-4" />
|
||||
<HugeiconsIcon
|
||||
icon={Delete02Icon}
|
||||
strokeWidth={1.75}
|
||||
className="size-icon"
|
||||
/>
|
||||
</TooltipIconButton>
|
||||
);
|
||||
};
|
||||
|
|
@ -817,7 +795,11 @@ const CopyButton: FC = () => {
|
|||
|
||||
return (
|
||||
<TooltipIconButton tooltip="Copy" onClick={handleCopy}>
|
||||
{copied ? <CheckIcon /> : <CopyIcon />}
|
||||
<HugeiconsIcon
|
||||
icon={copied ? Tick02Icon : Copy01Icon}
|
||||
strokeWidth={1.75}
|
||||
className="size-icon"
|
||||
/>
|
||||
</TooltipIconButton>
|
||||
);
|
||||
};
|
||||
|
|
@ -826,40 +808,39 @@ const AssistantActionBar: FC = () => {
|
|||
return (
|
||||
<ActionBarPrimitive.Root
|
||||
hideWhenRunning={true}
|
||||
autohide="always"
|
||||
autohideFloat="single-branch"
|
||||
className="aui-assistant-action-bar-root col-start-3 row-start-2 -ml-1 flex gap-1 text-muted-foreground data-floating:absolute"
|
||||
className="aui-assistant-action-bar-root col-start-3 row-start-2 flex items-center gap-1 text-chat-icon-fg [&_button:not([data-slot=message-timing-trigger])]:size-8 [&_button]:!rounded-[10px] [&_button:hover]:bg-chat-icon-bg-hover [&_button:hover]:text-chat-icon-fg-hover"
|
||||
>
|
||||
<CopyButton />
|
||||
<ActionBarPrimitive.Reload asChild={true}>
|
||||
<TooltipIconButton tooltip="Refresh">
|
||||
<RefreshCwIcon />
|
||||
<RefreshCwIcon strokeWidth={1.75} className="size-icon" />
|
||||
</TooltipIconButton>
|
||||
</ActionBarPrimitive.Reload>
|
||||
<DeleteMessageButton />
|
||||
<MessageTiming side="top" />
|
||||
<ActionBarMorePrimitive.Root>
|
||||
<ActionBarMorePrimitive.Trigger asChild={true}>
|
||||
<TooltipIconButton
|
||||
tooltip="More"
|
||||
className="data-[state=open]:bg-accent"
|
||||
>
|
||||
<MoreHorizontalIcon />
|
||||
<MoreHorizontalIcon strokeWidth={1.75} className="size-icon" />
|
||||
</TooltipIconButton>
|
||||
</ActionBarMorePrimitive.Trigger>
|
||||
<ActionBarMorePrimitive.Content
|
||||
side="bottom"
|
||||
align="start"
|
||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||
className="aui-action-bar-more-content z-50 min-w-32 overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md"
|
||||
>
|
||||
<ActionBarPrimitive.ExportMarkdown asChild={true}>
|
||||
<ActionBarMorePrimitive.Item className="aui-action-bar-more-item flex cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground">
|
||||
<DownloadIcon className="size-4" />
|
||||
<DownloadIcon strokeWidth={1.75} className="size-icon" />
|
||||
Export as Markdown
|
||||
</ActionBarMorePrimitive.Item>
|
||||
</ActionBarPrimitive.ExportMarkdown>
|
||||
</ActionBarMorePrimitive.Content>
|
||||
</ActionBarMorePrimitive.Root>
|
||||
<MessageTiming side="top" className="h-8 px-2" />
|
||||
</ActionBarPrimitive.Root>
|
||||
);
|
||||
};
|
||||
|
|
@ -884,22 +865,21 @@ const UserMessageAudio: FC = () => {
|
|||
const UserMessage: FC = () => {
|
||||
return (
|
||||
<MessagePrimitive.Root
|
||||
className="aui-user-message-root fade-in slide-in-from-bottom-1 mx-auto flex w-full max-w-(--thread-content-max-width) animate-in flex-col items-end gap-y-2 pt-6 pb-0.5 text-[15.5px] font-[450] duration-150"
|
||||
className="aui-user-message-root fade-in slide-in-from-bottom-1 mx-auto flex w-full max-w-(--thread-content-max-width) animate-in flex-col items-end gap-y-2 pt-6 pb-4 text-[15.5px] [font-weight:410] tracking-[0.01em] dark:tracking-[0.02em] duration-150"
|
||||
data-role="user"
|
||||
>
|
||||
<UserMessageAttachments />
|
||||
<UserMessageAudio />
|
||||
|
||||
<div className="aui-user-message-content-wrapper flex max-w-[80%] min-w-0 flex-col items-end">
|
||||
<div className="aui-user-message-content wrap-break-word w-fit rounded-[16px] rounded-tr-[4px] bg-[#f5f5f5] px-4 py-2.5 text-foreground dark:bg-card">
|
||||
<div className="aui-user-message-content wrap-break-word w-fit rounded-[24px] bg-[#f5f5f5] px-4 py-2.5 text-[#0d0d0d] dark:text-foreground dark:bg-card">
|
||||
<MessagePrimitive.Parts />
|
||||
</div>
|
||||
<div className="mt-1 flex min-h-6">
|
||||
<div className="mt-1 -mr-[var(--icon-btn-inset)] flex min-h-8 items-center">
|
||||
<UserActionBar />
|
||||
<BranchPicker className="aui-user-branch-picker ml-0.5" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BranchPicker className="aui-user-branch-picker -mr-1 justify-end" />
|
||||
</MessagePrimitive.Root>
|
||||
);
|
||||
};
|
||||
|
|
@ -908,12 +888,12 @@ const UserActionBar: FC = () => {
|
|||
return (
|
||||
<ActionBarPrimitive.Root
|
||||
autohide="always"
|
||||
className="aui-user-action-bar-root -mr-1 flex gap-1 text-muted-foreground"
|
||||
className="aui-user-action-bar-root flex gap-1 text-chat-icon-fg [&_button]:size-8 [&_button]:!rounded-[10px] [&_button:hover]:bg-chat-icon-bg-hover [&_button:hover]:text-chat-icon-fg-hover"
|
||||
>
|
||||
<CopyButton />
|
||||
<ActionBarPrimitive.Edit asChild={true}>
|
||||
<TooltipIconButton tooltip="Edit" className="aui-user-action-edit">
|
||||
<PencilIcon />
|
||||
<HugeiconsIcon icon={Edit03Icon} strokeWidth={1.75} className="size-icon" />
|
||||
</TooltipIconButton>
|
||||
</ActionBarPrimitive.Edit>
|
||||
<DeleteMessageButton />
|
||||
|
|
@ -981,23 +961,31 @@ const BranchPicker: FC<BranchPickerPrimitive.Root.Props> = ({
|
|||
<BranchPickerPrimitive.Root
|
||||
hideWhenSingleBranch={true}
|
||||
className={cn(
|
||||
"aui-branch-picker-root mr-2 -ml-2 inline-flex items-center text-muted-foreground text-xs",
|
||||
"aui-branch-picker-root inline-flex items-center text-chat-icon-fg text-[13px]",
|
||||
className,
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
<BranchPickerPrimitive.Previous asChild={true}>
|
||||
<TooltipIconButton tooltip="Previous">
|
||||
<ChevronLeftIcon />
|
||||
</TooltipIconButton>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Previous"
|
||||
className="aui-branch-chevron-btn"
|
||||
>
|
||||
<ChevronLeftIcon strokeWidth={1.25} className="size-[36px]" />
|
||||
</button>
|
||||
</BranchPickerPrimitive.Previous>
|
||||
<span className="aui-branch-picker-state font-medium">
|
||||
<BranchPickerPrimitive.Number /> / <BranchPickerPrimitive.Count />
|
||||
<span className="aui-branch-picker-state font-mono text-[13px] tabular-nums">
|
||||
<BranchPickerPrimitive.Number />/<BranchPickerPrimitive.Count />
|
||||
</span>
|
||||
<BranchPickerPrimitive.Next asChild={true}>
|
||||
<TooltipIconButton tooltip="Next">
|
||||
<ChevronRightIcon />
|
||||
</TooltipIconButton>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Next"
|
||||
className="aui-branch-chevron-btn"
|
||||
>
|
||||
<ChevronRightIcon strokeWidth={1.25} className="size-[36px]" />
|
||||
</button>
|
||||
</BranchPickerPrimitive.Next>
|
||||
</BranchPickerPrimitive.Root>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -37,7 +37,9 @@ export const TooltipIconButton = forwardRef<
|
|||
<span className="aui-sr-only sr-only">{tooltip}</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side={side}>{tooltip}</TooltipContent>
|
||||
<TooltipContent side={side} className="tooltip-compact">
|
||||
{tooltip}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -60,6 +60,16 @@ const UPWARD_DETACH_THRESHOLD_PX = 2;
|
|||
// keeps the viewport pinned as long as content keeps arriving; settles
|
||||
// this long after the last change.
|
||||
const FOLLOW_SETTLE_MS = 600;
|
||||
// Maximum stabilizer compensation. The stabilizer is meant to absorb
|
||||
// sub-frame transients (~5-15px shiki re-renders, ~8px action-bar
|
||||
// reservation drift). Anything larger is almost certainly an intentional
|
||||
// content removal — message delete, regenerate's old-content clear,
|
||||
// reasoning-panel collapse — and should *not* be silently padded over,
|
||||
// which would leave persistent empty space below the last message.
|
||||
// Above this threshold we release the stabilizer immediately and let
|
||||
// the autoscroll re-pin to the new content height, which is the natural
|
||||
// behavior the user expects for those actions.
|
||||
const STABILIZER_MAX_PX = 64;
|
||||
|
||||
export type ScrollToBottom = (behavior?: ScrollBehavior) => void;
|
||||
|
||||
|
|
@ -202,6 +212,21 @@ export function useIntentAwareAutoScroll(): {
|
|||
return false;
|
||||
};
|
||||
|
||||
// Stabilizer state — see `stabilize` below for the full
|
||||
// explanation. Lives in this closure so it resets naturally
|
||||
// whenever the viewport remounts (Compare-pane swap, thread
|
||||
// switch with remount, etc.).
|
||||
let stabilizerPx = 0;
|
||||
let maxContentHeight = 0;
|
||||
|
||||
const releaseStabilizer = (): void => {
|
||||
if (stabilizerPx === 0) {
|
||||
return;
|
||||
}
|
||||
stabilizerPx = 0;
|
||||
el.style.removeProperty("--aui-scroll-stabilizer");
|
||||
};
|
||||
|
||||
const extendFollow = (): void => {
|
||||
if (userDetachedRef.current) {
|
||||
return;
|
||||
|
|
@ -212,6 +237,13 @@ export function useIntentAwareAutoScroll(): {
|
|||
const detach = (): void => {
|
||||
userDetachedRef.current = true;
|
||||
followUntilRef.current = 0;
|
||||
// The stabilizer is only meaningful while we're actively
|
||||
// pinning to the bottom. Once the user scrolls up, drop any
|
||||
// residual padding so the bottom stays flush whenever they
|
||||
// come back. Safe here because the user is mid-content —
|
||||
// shrinking scrollHeight cannot cap their scrollTop.
|
||||
releaseStabilizer();
|
||||
maxContentHeight = el.scrollHeight;
|
||||
};
|
||||
|
||||
const requestTick = (): void => {
|
||||
|
|
@ -334,21 +366,116 @@ export function useIntentAwareAutoScroll(): {
|
|||
requestTick();
|
||||
};
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
extendFollow();
|
||||
requestTick();
|
||||
});
|
||||
// Scroll stabilizer.
|
||||
//
|
||||
// Problem: when a trailing code block finalizes at stream end
|
||||
// (Streamdown flips `isAnimating` → false, shiki re-renders the
|
||||
// <pre> with highlight spans), the block's rendered height
|
||||
// briefly dips and then recovers a frame later. That dip shrinks
|
||||
// `scrollHeight`, which the browser handles by *synchronously*
|
||||
// capping `scrollTop` to the new (smaller) `scrollHeight −
|
||||
// clientHeight`. The cap is visible as a one-frame upward jump;
|
||||
// the recovery a frame or two later is the "snap back" the user
|
||||
// perceives as a flicker. No amount of programmatic re-scrolling
|
||||
// can prevent this — once `scrollHeight` drops, the cap has
|
||||
// already happened and `scrollTop` cannot be pushed past the new
|
||||
// max.
|
||||
//
|
||||
// Fix: keep `scrollHeight` monotonic across the follow window.
|
||||
// We track the maximum *content* height (scrollHeight minus our
|
||||
// own padding contribution) seen during follow, and compensate
|
||||
// for any shortfall by writing the deficit into a CSS custom
|
||||
// property `--aui-scroll-stabilizer`, which the viewport's
|
||||
// `padding-bottom` reads. A 5px content shrink instantly grows
|
||||
// the padding by 5px, so the browser sees no scrollHeight change
|
||||
// and never caps scrollTop. As content naturally grows past its
|
||||
// prior high-water mark (e.g. the next message streams in), the
|
||||
// padding shrinks back toward zero.
|
||||
//
|
||||
// Self-contained: lives entirely on the viewport element via a
|
||||
// CSS variable. Doesn't touch the composer, the action bar, the
|
||||
// message footer, the spacer, or any other UI.
|
||||
//
|
||||
// Returns the post-adjustment scrollHeight so a single layout
|
||||
// read per observer callback can feed both stabilization and
|
||||
// pinning, avoiding a redundant flush.
|
||||
const stabilize = (): number => {
|
||||
const sh = el.scrollHeight;
|
||||
const currentContent = sh - stabilizerPx;
|
||||
const followActive =
|
||||
!userDetachedRef.current &&
|
||||
performance.now() < followUntilRef.current;
|
||||
if (!followActive) {
|
||||
// Outside the follow window we stop adjusting, but we keep
|
||||
// `maxContentHeight` aligned with reality so the next follow
|
||||
// session starts from the current content size, not stale.
|
||||
maxContentHeight = currentContent;
|
||||
return sh;
|
||||
}
|
||||
if (currentContent > maxContentHeight) {
|
||||
maxContentHeight = currentContent;
|
||||
}
|
||||
const shrink = maxContentHeight - currentContent;
|
||||
// Large shrinks (over STABILIZER_MAX_PX) are intentional content
|
||||
// removals — message delete, regenerate clearing the old
|
||||
// assistant turn, reasoning-panel collapse. Compensating for
|
||||
// those would leave persistent empty space at the bottom of the
|
||||
// viewport, which the user reads as "weird empty gap." Release
|
||||
// the stabilizer instead and rebase the high-water mark; the
|
||||
// pinIfFollowing call right after will smoothly re-anchor to
|
||||
// the new (smaller) bottom.
|
||||
if (shrink > STABILIZER_MAX_PX) {
|
||||
maxContentHeight = currentContent;
|
||||
if (stabilizerPx !== 0) {
|
||||
stabilizerPx = 0;
|
||||
el.style.removeProperty("--aui-scroll-stabilizer");
|
||||
}
|
||||
return currentContent;
|
||||
}
|
||||
const needed = Math.max(0, shrink);
|
||||
if (needed !== stabilizerPx) {
|
||||
stabilizerPx = needed;
|
||||
el.style.setProperty(
|
||||
"--aui-scroll-stabilizer",
|
||||
`${stabilizerPx}px`,
|
||||
);
|
||||
}
|
||||
return currentContent + stabilizerPx;
|
||||
};
|
||||
|
||||
const mutationObserver = new MutationObserver(() => {
|
||||
extendFollow();
|
||||
requestTick();
|
||||
});
|
||||
// Synchronous pin-to-bottom. Observer callbacks run in the event-
|
||||
// loop's "update the rendering" step (after layout, before paint),
|
||||
// so the scrollTo here is composited in the same frame as the
|
||||
// mutation that triggered the observer.
|
||||
const pinIfFollowing = (scrollHeight: number): void => {
|
||||
if (userDetachedRef.current) {
|
||||
return;
|
||||
}
|
||||
if (performance.now() >= followUntilRef.current) {
|
||||
return;
|
||||
}
|
||||
if (scrollHeight <= el.clientHeight) {
|
||||
return;
|
||||
}
|
||||
el.scrollTo({ top: scrollHeight, behavior: "instant" });
|
||||
};
|
||||
|
||||
const onViewportResize = () => {
|
||||
// All three layout-change signals fan in here so there's a
|
||||
// single place to understand "what runs when the viewport's
|
||||
// content shape changes". Order matters: extend first so the
|
||||
// stabilizer sees the follow window as active; stabilize before
|
||||
// pinning so we scroll to the post-adjustment scrollHeight.
|
||||
const onLayoutChange = (): void => {
|
||||
extendFollow();
|
||||
const scrollHeight = stabilize();
|
||||
pinIfFollowing(scrollHeight);
|
||||
requestTick();
|
||||
};
|
||||
|
||||
const resizeObserver = new ResizeObserver(onLayoutChange);
|
||||
const mutationObserver = new MutationObserver(onLayoutChange);
|
||||
const onViewportResize = onLayoutChange;
|
||||
|
||||
// Fresh attach always starts pinned. `userDetachedRef` survives
|
||||
// ref rebinds (it's hook-scoped), so if the viewport element is
|
||||
// ever unmounted and remounted without an AUI lifecycle event
|
||||
|
|
@ -366,7 +493,13 @@ export function useIntentAwareAutoScroll(): {
|
|||
setIsAtBottom(true);
|
||||
requestTick();
|
||||
|
||||
resizeObserver.observe(el);
|
||||
// Observe the border box, not the content box. The stabilizer
|
||||
// writes `padding-bottom`, which shrinks the content box; if we
|
||||
// observed that, every stabilizer adjustment would echo back as
|
||||
// a resize and re-enter onLayoutChange. Border-box stays put
|
||||
// through padding changes but still tracks parent-driven
|
||||
// resizes (window, sidebar toggle) — which is all we need.
|
||||
resizeObserver.observe(el, { box: "border-box" });
|
||||
mutationObserver.observe(el, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
|
|
|
|||
|
|
@ -1,69 +1,69 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
|
||||
import { type VariantProps, cva } from "class-variance-authority";
|
||||
import { Slot } from "radix-ui";
|
||||
import type * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export const buttonVariants = cva(
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-4xl border border-transparent bg-clip-padding text-sm font-medium focus-visible:ring-[3px] aria-invalid:ring-[3px] [&_svg:not([class*='size-'])]:size-4 inline-flex items-center justify-center whitespace-nowrap transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none shrink-0 [&_svg]:shrink-0 outline-none group/button select-none cursor-pointer",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/80",
|
||||
dark: "bg-foreground text-background hover:bg-foreground/85 dark:bg-foreground dark:text-background",
|
||||
outline:
|
||||
"border-border bg-input/30 hover:bg-input/50 hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
||||
ghost:
|
||||
"hover:bg-muted hover:text-foreground dark:hover:bg-muted/50 aria-expanded:bg-muted aria-expanded:text-foreground",
|
||||
destructive:
|
||||
"bg-destructive/10 hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/20 text-destructive focus-visible:border-destructive/40 dark:hover:bg-destructive/30",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default:
|
||||
"h-9 gap-1.5 px-3 has-data-[icon=inline-end]:pr-2.5 has-data-[icon=inline-start]:pl-2.5",
|
||||
xs: "h-6 gap-1 px-2.5 text-xs has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2 [&_svg:not([class*='size-'])]:size-3",
|
||||
sm: "h-8 gap-1 px-3 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
lg: "h-10 gap-1.5 px-4 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
|
||||
icon: "size-9",
|
||||
"icon-xs": "size-6 [&_svg:not([class*='size-'])]:size-3",
|
||||
"icon-sm": "size-8",
|
||||
"icon-lg": "size-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export function Button({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean;
|
||||
}): React.ReactElement {
|
||||
const Comp = asChild ? Slot.Root : "button";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
|
||||
import { type VariantProps, cva } from "class-variance-authority";
|
||||
import { Slot } from "radix-ui";
|
||||
import type * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export const buttonVariants = cva(
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-4xl border border-transparent text-sm font-medium focus-visible:ring-[3px] aria-invalid:ring-[3px] [&_svg:not([class*='size-'])]:size-4 inline-flex items-center justify-center whitespace-nowrap transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none shrink-0 [&_svg]:shrink-0 outline-none group/button select-none cursor-pointer",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/80",
|
||||
dark: "bg-foreground text-background hover:bg-foreground/85 dark:bg-foreground dark:text-background",
|
||||
outline:
|
||||
"border-border bg-input/30 hover:bg-input/50 hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
||||
ghost:
|
||||
"hover:bg-muted hover:text-foreground dark:hover:bg-muted/50 aria-expanded:bg-muted aria-expanded:text-foreground",
|
||||
destructive:
|
||||
"bg-destructive/10 hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/20 text-destructive focus-visible:border-destructive/40 dark:hover:bg-destructive/30",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default:
|
||||
"h-9 gap-1.5 px-3 has-data-[icon=inline-end]:pr-2.5 has-data-[icon=inline-start]:pl-2.5",
|
||||
xs: "h-6 gap-1 px-2.5 text-xs has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2 [&_svg:not([class*='size-'])]:size-3",
|
||||
sm: "h-8 gap-1 px-3 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
lg: "h-10 gap-1.5 px-4 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
|
||||
icon: "size-9",
|
||||
"icon-xs": "size-6 [&_svg:not([class*='size-'])]:size-3",
|
||||
"icon-sm": "size-8",
|
||||
"icon-lg": "size-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export function Button({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean;
|
||||
}): React.ReactElement {
|
||||
const Comp = asChild ? Slot.Root : "button";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,244 +1,257 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"use client";
|
||||
|
||||
import { Select as SelectPrimitive } from "radix-ui";
|
||||
import type * as React from "react";
|
||||
import { createContext, useContext, useState } from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useDialogPortalContainer } from "@/components/ui/dialog";
|
||||
import {
|
||||
ArrowDown01Icon,
|
||||
ArrowUp01Icon,
|
||||
Tick02Icon,
|
||||
UnfoldMoreIcon,
|
||||
} from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
|
||||
const SelectOpenContext = createContext(false);
|
||||
|
||||
function Select({
|
||||
onOpenChange,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
return (
|
||||
<SelectOpenContext.Provider value={isOpen}>
|
||||
<SelectPrimitive.Root
|
||||
data-slot="select"
|
||||
onOpenChange={(open) => {
|
||||
setIsOpen(open);
|
||||
onOpenChange?.(open);
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
</SelectOpenContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectGroup({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||
return (
|
||||
<SelectPrimitive.Group
|
||||
data-slot="select-group"
|
||||
className={cn("scroll-my-1 p-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectValue({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
className,
|
||||
size = "default",
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||
size?: "sm" | "default";
|
||||
}) {
|
||||
const isOpen = useContext(SelectOpenContext);
|
||||
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
style={{
|
||||
borderRadius: isOpen ? "12px" : undefined,
|
||||
transition: isOpen
|
||||
? "border-radius 0ms"
|
||||
: "border-radius 150ms cubic-bezier(0.645, 0.045, 0.355, 1)",
|
||||
}}
|
||||
className={cn(
|
||||
"border-input data-[placeholder]:text-muted-foreground bg-input/30 dark:hover:bg-input/50 focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 gap-1.5 rounded-4xl border px-3 py-2 text-sm transition-colors focus-visible:ring-[3px] aria-invalid:ring-[3px] data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:flex *:data-[slot=select-value]:gap-1.5 [&_svg:not([class*='size-'])]:size-4 flex w-fit items-center justify-between whitespace-nowrap outline-none disabled:cursor-not-allowed disabled:opacity-50 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center [&_svg]:pointer-events-none [&_svg]:shrink-0 cursor-pointer",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<HugeiconsIcon
|
||||
icon={UnfoldMoreIcon}
|
||||
strokeWidth={2}
|
||||
className="text-muted-foreground size-4 pointer-events-none"
|
||||
/>
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
position = "item-aligned",
|
||||
align = "center",
|
||||
container,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Content> & {
|
||||
container?: HTMLElement | null;
|
||||
}) {
|
||||
const dialogContainer = useDialogPortalContainer();
|
||||
return (
|
||||
<SelectPrimitive.Portal container={container ?? dialogContainer ?? undefined}>
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
data-align-trigger={position === "item-aligned"}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 shadow-border ring-1 ring-border min-w-36 rounded-xl p-1 corner-squircle duration-100 relative z-50 max-h-(--radix-select-content-available-height) origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto ",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className,
|
||||
)}
|
||||
position={position}
|
||||
align={align}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
data-position={position}
|
||||
className={cn(
|
||||
"data-[position=popper]:h-[var(--radix-select-trigger-height)] data-[position=popper]:w-full data-[position=popper]:min-w-[var(--radix-select-trigger-width)]",
|
||||
position === "popper" && "",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||
return (
|
||||
<SelectPrimitive.Label
|
||||
data-slot="select-label"
|
||||
className={cn("text-muted-foreground px-3 py-2.5 text-xs", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground gap-2.5 rounded-xl corner-squircle py-2 pr-8 pl-3 text-sm [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2 relative flex w-full cursor-pointer items-center outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<HugeiconsIcon
|
||||
icon={Tick02Icon}
|
||||
strokeWidth={2}
|
||||
className="pointer-events-none"
|
||||
/>
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn(
|
||||
"bg-border/50 -mx-1 my-1 h-px pointer-events-none",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn(
|
||||
"bg-popover z-10 flex cursor-default items-center justify-center py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<HugeiconsIcon icon={ArrowUp01Icon} strokeWidth={2} />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectScrollDownButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn(
|
||||
"bg-popover z-10 flex cursor-default items-center justify-center py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<HugeiconsIcon icon={ArrowDown01Icon} strokeWidth={2} />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
};
|
||||
"use client";
|
||||
|
||||
import { Select as SelectPrimitive } from "radix-ui";
|
||||
import type * as React from "react";
|
||||
import { createContext, useContext, useState } from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useDialogPortalContainer } from "@/components/ui/dialog";
|
||||
import {
|
||||
ArrowDown01Icon,
|
||||
ArrowUp01Icon,
|
||||
Tick02Icon,
|
||||
UnfoldMoreIcon,
|
||||
} from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
|
||||
const SelectOpenContext = createContext(false);
|
||||
|
||||
function Select({
|
||||
onOpenChange,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
return (
|
||||
<SelectOpenContext.Provider value={isOpen}>
|
||||
<SelectPrimitive.Root
|
||||
data-slot="select"
|
||||
onOpenChange={(open) => {
|
||||
setIsOpen(open);
|
||||
onOpenChange?.(open);
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
</SelectOpenContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectGroup({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||
return (
|
||||
<SelectPrimitive.Group
|
||||
data-slot="select-group"
|
||||
className={cn("scroll-my-1 p-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectValue({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
className,
|
||||
size = "default",
|
||||
children,
|
||||
icon,
|
||||
iconClassName,
|
||||
animateRadius = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||
size?: "sm" | "default";
|
||||
icon?: typeof UnfoldMoreIcon;
|
||||
iconClassName?: string;
|
||||
animateRadius?: boolean;
|
||||
}) {
|
||||
const isOpen = useContext(SelectOpenContext);
|
||||
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
style={
|
||||
animateRadius
|
||||
? {
|
||||
borderRadius: isOpen ? "12px" : undefined,
|
||||
transition: isOpen
|
||||
? "border-radius 0ms"
|
||||
: "border-radius 150ms cubic-bezier(0.645, 0.045, 0.355, 1)",
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
className={cn(
|
||||
"border-input data-[placeholder]:text-muted-foreground bg-input/30 dark:hover:bg-input/50 focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 gap-1.5 rounded-4xl border px-3 py-2 text-sm transition-colors focus-visible:ring-[3px] aria-invalid:ring-[3px] data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:flex *:data-[slot=select-value]:gap-1.5 [&_svg:not([class*='size-'])]:size-4 flex w-fit items-center justify-between whitespace-nowrap outline-none disabled:cursor-not-allowed disabled:opacity-50 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center [&_svg]:pointer-events-none [&_svg]:shrink-0 cursor-pointer",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<HugeiconsIcon
|
||||
icon={icon ?? UnfoldMoreIcon}
|
||||
strokeWidth={2}
|
||||
className={cn(
|
||||
"text-muted-foreground size-4 pointer-events-none",
|
||||
iconClassName,
|
||||
)}
|
||||
/>
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
position = "item-aligned",
|
||||
align = "center",
|
||||
container,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Content> & {
|
||||
container?: HTMLElement | null;
|
||||
}) {
|
||||
const dialogContainer = useDialogPortalContainer();
|
||||
return (
|
||||
<SelectPrimitive.Portal container={container ?? dialogContainer ?? undefined}>
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
data-align-trigger={position === "item-aligned"}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 shadow-border ring-1 ring-border min-w-36 rounded-xl p-1 corner-squircle duration-100 relative z-50 max-h-(--radix-select-content-available-height) origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto ",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className,
|
||||
)}
|
||||
position={position}
|
||||
align={align}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
data-position={position}
|
||||
className={cn(
|
||||
"data-[position=popper]:h-[var(--radix-select-trigger-height)] data-[position=popper]:w-full data-[position=popper]:min-w-[var(--radix-select-trigger-width)]",
|
||||
position === "popper" && "",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||
return (
|
||||
<SelectPrimitive.Label
|
||||
data-slot="select-label"
|
||||
className={cn("text-muted-foreground px-3 py-2.5 text-xs", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground gap-2.5 rounded-xl corner-squircle py-2 pr-8 pl-3 text-sm [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2 relative flex w-full cursor-pointer items-center outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<HugeiconsIcon
|
||||
icon={Tick02Icon}
|
||||
strokeWidth={2}
|
||||
className="pointer-events-none"
|
||||
/>
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn(
|
||||
"bg-border/50 -mx-1 my-1 h-px pointer-events-none",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn(
|
||||
"bg-popover z-10 flex cursor-default items-center justify-center py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<HugeiconsIcon icon={ArrowUp01Icon} strokeWidth={2} />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectScrollDownButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn(
|
||||
"bg-popover z-10 flex cursor-default items-center justify-center py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<HugeiconsIcon icon={ArrowDown01Icon} strokeWidth={2} />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -10,8 +10,12 @@ import { cn } from "@/lib/utils";
|
|||
type ToggleFn = () => void;
|
||||
const TooltipToggleCtx = createContext<ToggleFn | null>(null);
|
||||
|
||||
// Default to instant open (no hover delay). Most tooltips in the app —
|
||||
// chat-area icon labels, sidebar nav labels, the context/token
|
||||
// calculators — should feel snappy. Consumers that want a delay still
|
||||
// pass an explicit `delayDuration` prop.
|
||||
function TooltipProvider({
|
||||
delayDuration = 400,
|
||||
delayDuration = 0,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
|
||||
return (
|
||||
|
|
@ -81,25 +85,35 @@ function TooltipTrigger({
|
|||
);
|
||||
}
|
||||
|
||||
type TooltipVariant = "default" | "rich" | "none";
|
||||
|
||||
// `default` applies the compact black-pill styling shared with the
|
||||
// sidebar/chat icon labels. `rich` opts into the larger multi-row
|
||||
// popover surface used for timing/context breakdowns. `none` is an
|
||||
// escape hatch for tooltips that need to bring their own surface.
|
||||
function TooltipContent({
|
||||
variant = "default",
|
||||
className,
|
||||
sideOffset = 0,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Content> & {
|
||||
variant?: TooltipVariant;
|
||||
}) {
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
data-slot="tooltip-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 rounded-2xl corner-squircle px-3 py-1.5 text-xs **:data-[slot=kbd]:rounded-4xl bg-foreground text-background border border-foreground/40 shadow-lg z-[999999] w-fit max-w-xs origin-(--radix-tooltip-content-transform-origin)",
|
||||
"z-[999999] w-fit max-w-xs",
|
||||
variant === "default" && "tooltip-compact",
|
||||
variant === "rich" && "tooltip-rich",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<TooltipPrimitive.Arrow className="size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] data-[side=left]:translate-x-[-1.5px] data-[side=right]:translate-x-[1.5px] bg-foreground fill-foreground z-[999999] translate-y-[calc(-50%_-_2px)]" />
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import {
|
|||
} from "./chat-api";
|
||||
import { db } from "../db";
|
||||
import { useChatRuntimeStore } from "../stores/chat-runtime-store";
|
||||
import { isMultimodalResponse } from "../types/api";
|
||||
import type { ChatModelSummary } from "../types/runtime";
|
||||
import {
|
||||
hasClosedThinkTag,
|
||||
|
|
@ -396,6 +397,8 @@ async function autoLoadSmallestModel(): Promise<{
|
|||
loadedKvCacheDtype: loadResp.cache_type_kv ?? null,
|
||||
defaultChatTemplate: loadResp.chat_template ?? null,
|
||||
chatTemplateOverride: null,
|
||||
loadedChatTemplateOverride: null,
|
||||
loadedIsMultimodal: isMultimodalResponse(loadResp),
|
||||
});
|
||||
toast.success(`Loaded ${repo.repo_id} (${variant.quant})`, { id: toastId });
|
||||
return { loaded: true, blockedByTrustRemoteCode: false };
|
||||
|
|
@ -455,6 +458,9 @@ async function autoLoadSmallestModel(): Promise<{
|
|||
if (!store.models.some((m) => m.id === repo.repo_id)) {
|
||||
store.setModels([...store.models, sfModel]);
|
||||
}
|
||||
useChatRuntimeStore.setState({
|
||||
loadedIsMultimodal: isMultimodalResponse(sfLoadResp),
|
||||
});
|
||||
toast.success(`Loaded ${repo.repo_id}`, { id: toastId });
|
||||
return { loaded: true, blockedByTrustRemoteCode: false };
|
||||
} catch {
|
||||
|
|
@ -522,6 +528,7 @@ async function autoLoadSmallestModel(): Promise<{
|
|||
loadedKvCacheDtype: loadResp.cache_type_kv ?? null,
|
||||
defaultChatTemplate: loadResp.chat_template ?? null,
|
||||
chatTemplateOverride: null,
|
||||
loadedIsMultimodal: isMultimodalResponse(loadResp),
|
||||
});
|
||||
toast.success("Loaded Gemma-4-E2B-it (UD-Q4_K_XL)", { id: toastId });
|
||||
return { loaded: true, blockedByTrustRemoteCode: false };
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ import { isTauri } from "@/lib/api-base";
|
|||
import { cn } from "@/lib/utils";
|
||||
import { GuidedTour, useGuidedTourController } from "@/features/tour";
|
||||
import { useSidebar } from "@/components/ui/sidebar";
|
||||
import { Settings05Icon } from "@hugeicons/core-free-icons";
|
||||
import { CustomizeIcon } from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { Tooltip, TooltipContent } from "@/components/ui/tooltip";
|
||||
import { Tooltip as TooltipPrimitive } from "radix-ui";
|
||||
|
|
@ -272,10 +272,10 @@ function CompareShell({
|
|||
>
|
||||
{children}
|
||||
</div>
|
||||
<div className="shrink-0 bg-background px-5 pb-2 pt-1">
|
||||
<div className="mx-auto w-full max-w-[44rem]">{composer}</div>
|
||||
<p className="mt-1.5 text-center text-[11px] text-muted-foreground">
|
||||
LLMs can make mistakes. Double-check all responses.
|
||||
<div className="shrink-0 bg-background pl-5 pr-5 md:pr-[30px] pb-2 pt-1">
|
||||
<div className="mx-auto w-full max-w-[48rem]">{composer}</div>
|
||||
<p className="composer-footer-note">
|
||||
LLMs can make mistakes. Double-check responses.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1073,14 +1073,22 @@ export function ChatPage(): ReactElement {
|
|||
<button
|
||||
type="button"
|
||||
onClick={() => setSettingsOpen(true)}
|
||||
className="flex h-[34px] w-[34px] items-center justify-center rounded-[8px] text-[#383835] dark:text-[#c7c7c4] transition-colors hover:bg-[#ececec] dark:hover:bg-[#2e3035] hover:text-black dark:hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
className="flex h-[34px] w-[34px] items-center justify-center rounded-[12px] text-nav-fg transition-colors hover:bg-nav-surface-hover hover:text-black dark:hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
aria-label="Open configuration"
|
||||
data-tour="chat-settings"
|
||||
>
|
||||
<HugeiconsIcon icon={Settings05Icon} className="size-5" />
|
||||
<HugeiconsIcon
|
||||
icon={CustomizeIcon}
|
||||
strokeWidth={1.75}
|
||||
className="size-icon"
|
||||
/>
|
||||
</button>
|
||||
</TooltipPrimitive.Trigger>
|
||||
<TooltipContent side="bottom" sideOffset={6}>
|
||||
<TooltipContent
|
||||
side="bottom"
|
||||
sideOffset={6}
|
||||
className="tooltip-compact"
|
||||
>
|
||||
Open configuration
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
|
|
|||
|
|
@ -46,14 +46,14 @@ export const ContextUsageBar: FC<{
|
|||
type="button"
|
||||
aria-label={`Context usage: ${formatTokenCount(used)} of ${formatTokenCount(total)} tokens`}
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-md px-2 py-1 text-xs font-mono tabular-nums text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground",
|
||||
"flex items-center gap-2 rounded-[10px] px-2.5 py-1 font-mono text-chat-icon-fg text-[13px] tabular-nums transition-colors hover:bg-chat-icon-bg-hover hover:text-chat-icon-fg-hover",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span>
|
||||
{formatTokenCount(used)} / {formatTokenCount(total)}
|
||||
</span>
|
||||
<div className="h-1.5 w-16 rounded-full bg-muted overflow-hidden">
|
||||
<div className="h-1.5 w-16 rounded-full bg-black/10 dark:bg-white/15 overflow-hidden">
|
||||
<div
|
||||
className={cn("h-full rounded-full transition-all", severity.bar)}
|
||||
style={{ width: `${percent}%` }}
|
||||
|
|
@ -64,7 +64,8 @@ export const ContextUsageBar: FC<{
|
|||
<TooltipContent
|
||||
side="bottom"
|
||||
sideOffset={8}
|
||||
className="[&_span>svg]:hidden! rounded-lg border bg-popover px-3 py-2 text-popover-foreground shadow-md"
|
||||
variant="rich"
|
||||
className="[&_span>svg]:hidden!"
|
||||
>
|
||||
<div className="grid min-w-44 gap-1.5 text-xs">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
|
|
|
|||
|
|
@ -28,24 +28,14 @@ import {
|
|||
mergeBackendRecommendedInference,
|
||||
resolveLoadMaxSeqLength,
|
||||
} from "../presets/preset-policy";
|
||||
import {
|
||||
isMultimodalResponse,
|
||||
} from "../types/api";
|
||||
import type {
|
||||
ChatLoraSummary,
|
||||
ChatModelSummary,
|
||||
} from "../types/runtime";
|
||||
|
||||
// The simplified Speculative Decoding control surfaces "default" (which
|
||||
// maps to llama.cpp's --spec-default) and "off". A backend status / load
|
||||
// response can still report the older manual modes (ngram-mod,
|
||||
// ngram-simple) when a model is loaded via the API or carried over from an
|
||||
// older Studio version. The Select would render an empty trigger for those
|
||||
// values, so coerce them to "default" -- llama.cpp's own --spec-default
|
||||
// picks an equivalent strategy and keeps the dropdown coherent.
|
||||
function normalizeSpeculativeType(v: string | null | undefined): string | null {
|
||||
if (v == null) return null;
|
||||
if (v === "default" || v === "off") return v;
|
||||
return "default";
|
||||
}
|
||||
|
||||
type SelectedModelInput = {
|
||||
id: string;
|
||||
isLora?: boolean;
|
||||
|
|
@ -147,6 +137,12 @@ function getTrustRemoteCodeRequiredMessage(modelName: string): string {
|
|||
return `${modelName} needs custom code enabled to load. Turn on "Enable custom code" in Chat Settings, then try again.`;
|
||||
}
|
||||
|
||||
function normalizeSpeculativeType(v: string | null | undefined): string | null {
|
||||
if (v == null) return null;
|
||||
if (v === "default" || v === "off") return v;
|
||||
return "default";
|
||||
}
|
||||
|
||||
export function useChatModelRuntime() {
|
||||
const params = useChatRuntimeStore((state) => state.params);
|
||||
const models = useChatRuntimeStore((state) => state.models);
|
||||
|
|
@ -256,10 +252,19 @@ export function useChatModelRuntime() {
|
|||
const ggufNativeContextLength = statusRes.is_gguf
|
||||
? (statusRes.native_context_length ?? null)
|
||||
: null;
|
||||
const currentSpecType = normalizeSpeculativeType(statusRes.speculative_type);
|
||||
const currentSpecType = normalizeSpeculativeType(
|
||||
statusRes.speculative_type,
|
||||
);
|
||||
// Refresh runs both on F5 (fresh store needs hydration) AND right
|
||||
// after a fresh load (store was already set by the load path). For
|
||||
// the user-configurable model params we only hydrate when the shadow
|
||||
// `loaded*` field is still null -- that signals "not yet hydrated".
|
||||
// Otherwise we'd clobber the values the load path just applied and
|
||||
// the UI would appear to revert the user's changes.
|
||||
const prevState = useChatRuntimeStore.getState();
|
||||
const nextDefaultChatTemplate =
|
||||
statusRes.chat_template === undefined
|
||||
? useChatRuntimeStore.getState().defaultChatTemplate
|
||||
? prevState.defaultChatTemplate
|
||||
: statusRes.chat_template;
|
||||
useChatRuntimeStore.setState({
|
||||
supportsReasoning,
|
||||
|
|
@ -278,8 +283,22 @@ export function useChatModelRuntime() {
|
|||
modelRequiresTrustRemoteCode:
|
||||
statusRes.requires_trust_remote_code ?? false,
|
||||
defaultChatTemplate: nextDefaultChatTemplate,
|
||||
speculativeType: currentSpecType,
|
||||
loadedSpeculativeType: currentSpecType,
|
||||
loadedIsMultimodal: isMultimodalResponse(statusRes),
|
||||
...(prevState.loadedSpeculativeType === null && {
|
||||
speculativeType: currentSpecType,
|
||||
loadedSpeculativeType: currentSpecType,
|
||||
}),
|
||||
...(statusRes.cache_type_kv !== undefined &&
|
||||
prevState.loadedKvCacheDtype === null && {
|
||||
kvCacheDtype: statusRes.cache_type_kv,
|
||||
loadedKvCacheDtype: statusRes.cache_type_kv,
|
||||
}),
|
||||
...(statusRes.chat_template_override !== undefined &&
|
||||
prevState.loadedChatTemplateOverride === null &&
|
||||
prevState.chatTemplateOverride === null && {
|
||||
chatTemplateOverride: statusRes.chat_template_override,
|
||||
loadedChatTemplateOverride: statusRes.chat_template_override,
|
||||
}),
|
||||
});
|
||||
|
||||
// Set reasoning default for Qwen3.5/3.6 small models
|
||||
|
|
@ -297,6 +316,7 @@ export function useChatModelRuntime() {
|
|||
} else {
|
||||
useChatRuntimeStore.setState({
|
||||
modelRequiresTrustRemoteCode: false,
|
||||
loadedIsMultimodal: false,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
@ -492,6 +512,8 @@ export function useChatModelRuntime() {
|
|||
maxSeqLength,
|
||||
presetSource: activePresetSource,
|
||||
});
|
||||
const effectiveChatTemplateOverride =
|
||||
chatTemplateOverride?.trim() ? chatTemplateOverride : null;
|
||||
const loadResponse = await loadModel({
|
||||
model_path: modelId,
|
||||
nativePathLease: loadNativePathLease,
|
||||
|
|
@ -501,7 +523,7 @@ export function useChatModelRuntime() {
|
|||
is_lora: isLora,
|
||||
gguf_variant: ggufVariant ?? null,
|
||||
trust_remote_code: trustRemoteCode,
|
||||
chat_template_override: chatTemplateOverride,
|
||||
chat_template_override: effectiveChatTemplateOverride,
|
||||
cache_type_kv: kvCacheDtype,
|
||||
speculative_type: speculativeType,
|
||||
});
|
||||
|
|
@ -531,7 +553,9 @@ export function useChatModelRuntime() {
|
|||
}
|
||||
}
|
||||
const loadedKv = loadResponse.cache_type_kv ?? null;
|
||||
const loadedSpec = normalizeSpeculativeType(loadResponse.speculative_type);
|
||||
const loadedSpec = normalizeSpeculativeType(
|
||||
loadResponse.speculative_type,
|
||||
);
|
||||
const nativeCtx = loadResponse.is_gguf
|
||||
? (loadResponse.context_length ?? 131072)
|
||||
: null;
|
||||
|
|
@ -566,11 +590,16 @@ export function useChatModelRuntime() {
|
|||
loadedSpeculativeType: loadedSpec,
|
||||
customContextLength: keepCustomCtx,
|
||||
defaultChatTemplate: loadResponse.chat_template ?? null,
|
||||
chatTemplateOverride: null,
|
||||
chatTemplateOverride: effectiveChatTemplateOverride,
|
||||
loadedChatTemplateOverride: effectiveChatTemplateOverride,
|
||||
loadedIsMultimodal: isMultimodalResponse(loadResponse),
|
||||
activeNativePathToken: nativePathToken ?? null,
|
||||
});
|
||||
// Qwen3/3.5/3.6: apply thinking-mode-specific params after load
|
||||
if (modelId.toLowerCase().includes("qwen3") && (loadResponse.supports_reasoning ?? false)) {
|
||||
if (
|
||||
modelId.toLowerCase().includes("qwen3") &&
|
||||
(loadResponse.supports_reasoning ?? false)
|
||||
) {
|
||||
const store = useChatRuntimeStore.getState();
|
||||
if (store.activePresetSource === "builtin-default") {
|
||||
const mid = modelId.toLowerCase();
|
||||
|
|
|
|||
|
|
@ -66,6 +66,29 @@ function cancelIfRunning(threadId: string): void {
|
|||
cancelByThreadId[threadId]?.();
|
||||
}
|
||||
|
||||
export async function renameChatItem(
|
||||
item: SidebarItem,
|
||||
nextTitle: string,
|
||||
): Promise<void> {
|
||||
const trimmed = nextTitle.trim();
|
||||
if (!trimmed || trimmed === item.title) return;
|
||||
|
||||
if (item.type === "single") {
|
||||
await db.threads.update(item.id, { title: trimmed });
|
||||
return;
|
||||
}
|
||||
|
||||
const pairThreads = await db.threads
|
||||
.where("pairId")
|
||||
.equals(item.id)
|
||||
.toArray();
|
||||
await db.transaction("rw", db.threads, async () => {
|
||||
for (const t of pairThreads) {
|
||||
await db.threads.update(t.id, { title: trimmed });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteChatItem(
|
||||
item: SidebarItem,
|
||||
activeId: string | undefined,
|
||||
|
|
|
|||
|
|
@ -9,5 +9,13 @@ export {
|
|||
type Preset,
|
||||
} from "./chat-settings-sheet";
|
||||
export { useChatRuntimeStore } from "./stores/chat-runtime-store";
|
||||
export { useChatSearchStore } from "./stores/chat-search-store";
|
||||
export { useChatModelRuntime } from "./hooks/use-chat-model-runtime";
|
||||
export { ChatSearchDialog } from "./components/chat-search-dialog";
|
||||
export { setTrainingCompareHandoff } from "./lib/training-compare-handoff";
|
||||
export {
|
||||
deleteChatItem,
|
||||
renameChatItem,
|
||||
useChatSidebarItems,
|
||||
type SidebarItem,
|
||||
} from "./hooks/use-chat-sidebar-items";
|
||||
|
|
|
|||
|
|
@ -27,44 +27,16 @@ export type PresetOwnedParams = Pick<
|
|||
|
||||
export const BUILTIN_PRESETS: Preset[] = [
|
||||
{ name: "Default", params: { ...defaultInferenceParams } },
|
||||
{
|
||||
name: "Creative",
|
||||
params: {
|
||||
...defaultInferenceParams,
|
||||
temperature: 1.5,
|
||||
topP: 1.0,
|
||||
topK: 0,
|
||||
minP: 0.1,
|
||||
repetitionPenalty: 1.0,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Precise",
|
||||
params: {
|
||||
...defaultInferenceParams,
|
||||
temperature: 0.1,
|
||||
topP: 0.95,
|
||||
topK: 80,
|
||||
minP: 0.01,
|
||||
repetitionPenalty: 1.0,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export const BUILTIN_PRESET_NAMES = new Set(
|
||||
BUILTIN_PRESETS.map((preset) => preset.name),
|
||||
);
|
||||
|
||||
export type ChatPresetSource =
|
||||
| "builtin-default"
|
||||
| "builtin-fixed"
|
||||
| "custom"
|
||||
| "modified";
|
||||
export type ChatPresetSource = "builtin-default" | "custom" | "modified";
|
||||
|
||||
export function getPresetSource(name: string): ChatPresetSource {
|
||||
if (name === "Default") return "builtin-default";
|
||||
if (BUILTIN_PRESET_NAMES.has(name)) return "builtin-fixed";
|
||||
return "custom";
|
||||
return name === "Default" ? "builtin-default" : "custom";
|
||||
}
|
||||
|
||||
export function getUniquePresetName(
|
||||
|
|
@ -102,7 +74,9 @@ export function normalizeCustomPresets(presets: Preset[]): Preset[] {
|
|||
return presets
|
||||
.map((preset): Preset | null => {
|
||||
const trimmedName = preset.name.trim();
|
||||
if (!trimmedName) return null;
|
||||
if (!trimmedName) {
|
||||
return null;
|
||||
}
|
||||
const name = usedNames.has(trimmedName)
|
||||
? getBuiltinVariantName(trimmedName, usedNames)
|
||||
: trimmedName;
|
||||
|
|
@ -119,6 +93,21 @@ export function getOrderedPresets(customPresets: Preset[]): Preset[] {
|
|||
return [...BUILTIN_PRESETS, ...normalizeCustomPresets(customPresets)];
|
||||
}
|
||||
|
||||
export function getPresetOwnedParams(
|
||||
params: InferenceParams,
|
||||
): PresetOwnedParams {
|
||||
return {
|
||||
temperature: params.temperature,
|
||||
topP: params.topP,
|
||||
topK: params.topK,
|
||||
minP: params.minP,
|
||||
repetitionPenalty: params.repetitionPenalty,
|
||||
presencePenalty: params.presencePenalty,
|
||||
maxTokens: params.maxTokens,
|
||||
systemPrompt: params.systemPrompt,
|
||||
};
|
||||
}
|
||||
|
||||
export function isSamePresetConfig(
|
||||
a: InferenceParams,
|
||||
b: InferenceParams,
|
||||
|
|
@ -137,21 +126,6 @@ export function isSamePresetConfig(
|
|||
);
|
||||
}
|
||||
|
||||
export function getPresetOwnedParams(
|
||||
params: InferenceParams,
|
||||
): PresetOwnedParams {
|
||||
return {
|
||||
temperature: params.temperature,
|
||||
topP: params.topP,
|
||||
topK: params.topK,
|
||||
minP: params.minP,
|
||||
repetitionPenalty: params.repetitionPenalty,
|
||||
presencePenalty: params.presencePenalty,
|
||||
maxTokens: params.maxTokens,
|
||||
systemPrompt: params.systemPrompt,
|
||||
};
|
||||
}
|
||||
|
||||
export function getPresetOwnedConfigKey(params: InferenceParams): string {
|
||||
return JSON.stringify(getPresetOwnedParams(params));
|
||||
}
|
||||
|
|
@ -211,7 +185,10 @@ export function getPresetSaveState({
|
|||
}
|
||||
|
||||
if (BUILTIN_PRESET_NAMES.has(trimmedName)) {
|
||||
const variantName = getBuiltinVariantName(trimmedName, new Set(presets.map((preset) => preset.name)));
|
||||
const variantName = getBuiltinVariantName(
|
||||
trimmedName,
|
||||
new Set(presets.map((preset) => preset.name)),
|
||||
);
|
||||
return {
|
||||
mode: "copy-builtin",
|
||||
canSubmit: activePreset !== trimmedName || hasUnsavedPresetChanges,
|
||||
|
|
@ -234,8 +211,7 @@ export function getPresetSaveState({
|
|||
mode: isActiveMatch ? "overwrite-active" : "overwrite-other",
|
||||
canSubmit: !isActiveMatch || hasUnsavedPresetChanges,
|
||||
isSaveReady: !isActiveMatch || hasUnsavedPresetChanges,
|
||||
buttonLabel:
|
||||
isActiveMatch && !hasUnsavedPresetChanges ? "Saved" : "Save",
|
||||
buttonLabel: isActiveMatch && !hasUnsavedPresetChanges ? "Saved" : "Save",
|
||||
title: isActiveMatch
|
||||
? hasUnsavedPresetChanges
|
||||
? "Save current settings to this preset"
|
||||
|
|
@ -307,7 +283,8 @@ export function mergeBackendRecommendedInference({
|
|||
...next,
|
||||
maxTokens: defaultMaxTokens,
|
||||
temperature:
|
||||
toFiniteNumber(inference?.temperature) ?? defaultInferenceParams.temperature,
|
||||
toFiniteNumber(inference?.temperature) ??
|
||||
defaultInferenceParams.temperature,
|
||||
topP: toFiniteNumber(inference?.top_p) ?? defaultInferenceParams.topP,
|
||||
topK: toFiniteNumber(inference?.top_k) ?? defaultInferenceParams.topK,
|
||||
minP: toFiniteNumber(inference?.min_p) ?? defaultInferenceParams.minP,
|
||||
|
|
@ -343,9 +320,17 @@ export function resolveLoadMaxSeqLength({
|
|||
currentCheckpoint === modelId &&
|
||||
(ggufVariant ?? null) === (activeGgufVariant ?? null);
|
||||
|
||||
if (customContextLength != null) return customContextLength;
|
||||
if (isGgufLoad && presetSource === "builtin-default") return 0;
|
||||
if (isReloadingCurrentGguf) return ggufContextLength ?? 0;
|
||||
if (isGgufLoad) return 0;
|
||||
if (customContextLength != null) {
|
||||
return customContextLength;
|
||||
}
|
||||
if (isGgufLoad && presetSource === "builtin-default") {
|
||||
return 0;
|
||||
}
|
||||
if (isReloadingCurrentGguf) {
|
||||
return ggufContextLength ?? 0;
|
||||
}
|
||||
if (isGgufLoad) {
|
||||
return 0;
|
||||
}
|
||||
return maxSeqLength;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
|
||||
import { CodeToggleIcon } from "@/components/assistant-ui/code-toggle-icon";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
|
|
@ -14,7 +15,6 @@ import { applyQwenThinkingParams } from "@/features/chat/utils/qwen-params";
|
|||
import { AUDIO_ACCEPT, MAX_AUDIO_SIZE, fileToBase64 } from "@/lib/audio-utils";
|
||||
import { isTauri } from "@/lib/api-base";
|
||||
import { useAui } from "@assistant-ui/react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ArrowUpIcon, GlobeIcon, HeadphonesIcon, LightbulbIcon, LightbulbOffIcon, MicIcon, PlusIcon, SquareIcon, XIcon } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { loadModel, validateModel } from "./api/chat-api";
|
||||
|
|
@ -358,6 +358,8 @@ export function SharedComposer({
|
|||
const maxSeqLength = store.params.maxSeqLength;
|
||||
const trustRemoteCode = store.params.trustRemoteCode ?? false;
|
||||
const chatTemplateOverride = store.chatTemplateOverride;
|
||||
const effectiveChatTemplateOverride =
|
||||
chatTemplateOverride?.trim() ? chatTemplateOverride : null;
|
||||
|
||||
function modelDisplayName(id: string): string {
|
||||
const parts = id.split("/");
|
||||
|
|
@ -379,7 +381,7 @@ export function SharedComposer({
|
|||
is_lora: sel.isLora,
|
||||
gguf_variant: sel.ggufVariant ?? null,
|
||||
trust_remote_code: trustRemoteCode,
|
||||
chat_template_override: chatTemplateOverride,
|
||||
chat_template_override: effectiveChatTemplateOverride,
|
||||
});
|
||||
if (validation.requires_trust_remote_code && !trustRemoteCode) {
|
||||
throw new Error(
|
||||
|
|
@ -395,7 +397,7 @@ export function SharedComposer({
|
|||
is_lora: sel.isLora,
|
||||
gguf_variant: sel.ggufVariant ?? null,
|
||||
trust_remote_code: trustRemoteCode,
|
||||
chat_template_override: chatTemplateOverride,
|
||||
chat_template_override: effectiveChatTemplateOverride,
|
||||
});
|
||||
const store = useChatRuntimeStore.getState();
|
||||
store.setCheckpoint(
|
||||
|
|
@ -492,7 +494,7 @@ export function SharedComposer({
|
|||
|
||||
return (
|
||||
<div
|
||||
className={`chat-composer-surface relative flex w-full flex-col rounded-3xl bg-background dark:bg-card px-1 pt-2 transition-shadow outline-none ${dragging ? "border-ring bg-accent/50" : ""}`}
|
||||
className={`chat-composer-surface ${dragging ? "border-ring bg-accent/50" : ""}`}
|
||||
onDragOver={(e) => {
|
||||
if (isTauri) return;
|
||||
e.preventDefault();
|
||||
|
|
@ -539,10 +541,10 @@ export function SharedComposer({
|
|||
onChange={(e) => setText(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder="Send to both models..."
|
||||
className="mb-1 min-h-12 w-full resize-none overflow-y-hidden bg-transparent pl-5 pr-4 pt-2 pb-3 text-sm font-[450] outline-none placeholder:text-muted-foreground focus-visible:ring-0"
|
||||
className="composer-input"
|
||||
rows={1}
|
||||
/>
|
||||
<div className="relative mx-2 mb-2 flex items-center justify-between">
|
||||
<div className="composer-action-wrapper">
|
||||
<div className="flex items-center gap-1">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
|
|
@ -682,14 +684,8 @@ export function SharedComposer({
|
|||
type="button"
|
||||
disabled={toolsDisabled}
|
||||
onClick={() => setToolsEnabled(!toolsEnabled)}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium transition-colors",
|
||||
toolsDisabled
|
||||
? "cursor-not-allowed opacity-40"
|
||||
: toolsEnabled
|
||||
? "bg-primary/10 text-primary hover:bg-primary/20"
|
||||
: "bg-muted text-muted-foreground hover:bg-muted-foreground/15",
|
||||
)}
|
||||
className="composer-pill-btn"
|
||||
data-active={toolsEnabled && !toolsDisabled ? "true" : "false"}
|
||||
aria-label={toolsEnabled ? "Disable web search" : "Enable web search"}
|
||||
>
|
||||
<GlobeIcon className="size-3.5" />
|
||||
|
|
@ -699,14 +695,8 @@ export function SharedComposer({
|
|||
type="button"
|
||||
disabled={toolsDisabled}
|
||||
onClick={() => setCodeToolsEnabled(!codeToolsEnabled)}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium transition-colors",
|
||||
toolsDisabled
|
||||
? "cursor-not-allowed opacity-40"
|
||||
: codeToolsEnabled
|
||||
? "bg-primary/10 text-primary hover:bg-primary/20"
|
||||
: "bg-muted text-muted-foreground hover:bg-muted-foreground/15",
|
||||
)}
|
||||
className="composer-pill-btn"
|
||||
data-active={codeToolsEnabled && !toolsDisabled ? "true" : "false"}
|
||||
aria-label={codeToolsEnabled ? "Disable code execution" : "Enable code execution"}
|
||||
>
|
||||
<CodeToggleIcon className="size-3.5" />
|
||||
|
|
|
|||
|
|
@ -212,9 +212,11 @@ type ChatRuntimeStore = {
|
|||
loadedKvCacheDtype: string | null;
|
||||
speculativeType: string | null;
|
||||
loadedSpeculativeType: string | null;
|
||||
loadedIsMultimodal: boolean;
|
||||
customContextLength: number | null;
|
||||
defaultChatTemplate: string | null;
|
||||
chatTemplateOverride: string | null;
|
||||
loadedChatTemplateOverride: string | null;
|
||||
activeThreadId: string | null;
|
||||
settingsPanelOpen: boolean;
|
||||
pendingAudioBase64: string | null;
|
||||
|
|
@ -297,9 +299,11 @@ export const useChatRuntimeStore = create<ChatRuntimeStore>((set) => ({
|
|||
loadedKvCacheDtype: null,
|
||||
speculativeType: "default",
|
||||
loadedSpeculativeType: null,
|
||||
loadedIsMultimodal: false,
|
||||
customContextLength: null,
|
||||
defaultChatTemplate: null,
|
||||
chatTemplateOverride: null,
|
||||
loadedChatTemplateOverride: null,
|
||||
activeThreadId: null,
|
||||
settingsPanelOpen: false,
|
||||
pendingAudioBase64: null,
|
||||
|
|
@ -399,9 +403,11 @@ export const useChatRuntimeStore = create<ChatRuntimeStore>((set) => ({
|
|||
loadedKvCacheDtype: null,
|
||||
speculativeType: "default",
|
||||
loadedSpeculativeType: null,
|
||||
loadedIsMultimodal: false,
|
||||
customContextLength: null,
|
||||
defaultChatTemplate: null,
|
||||
chatTemplateOverride: null,
|
||||
loadedChatTemplateOverride: null,
|
||||
})),
|
||||
setReasoningEnabled: (reasoningEnabled) => set({ reasoningEnabled }),
|
||||
setReasoningStyle: (reasoningStyle) => set({ reasoningStyle }),
|
||||
|
|
|
|||
|
|
@ -70,6 +70,25 @@ export interface GgufVariantsResponse {
|
|||
default_variant: string | null;
|
||||
}
|
||||
|
||||
export function isMultimodalResponse(
|
||||
response:
|
||||
| {
|
||||
is_vision?: boolean;
|
||||
is_audio?: boolean;
|
||||
audio_type?: string | null;
|
||||
has_audio_input?: boolean;
|
||||
}
|
||||
| null
|
||||
| undefined,
|
||||
): boolean {
|
||||
return (
|
||||
Boolean(response?.is_vision) ||
|
||||
Boolean(response?.is_audio) ||
|
||||
Boolean(response?.has_audio_input) ||
|
||||
response?.audio_type === "audio_vlm"
|
||||
);
|
||||
}
|
||||
|
||||
export interface LoadModelResponse {
|
||||
status: string;
|
||||
model: string;
|
||||
|
|
@ -134,6 +153,8 @@ export interface InferenceStatusResponse {
|
|||
context_length?: number | null;
|
||||
max_context_length?: number | null;
|
||||
native_context_length?: number | null;
|
||||
cache_type_kv?: string | null;
|
||||
chat_template_override?: string | null;
|
||||
speculative_type?: string | null;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -958,7 +958,7 @@ export function ExportPage() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
<div className="rounded-xl bg-muted/50 p-3">
|
||||
<div className="rounded-xl bg-foreground/[0.04] p-3">
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Direct model exports currently support GGUF only.
|
||||
</p>
|
||||
|
|
@ -968,7 +968,7 @@ export function ExportPage() {
|
|||
</AnimatePresence>
|
||||
|
||||
{sourceMode === "checkpoint" && (
|
||||
<div className="rounded-xl bg-muted/50 p-3 flex flex-col gap-2">
|
||||
<div className="rounded-xl bg-foreground/[0.04] p-3 flex flex-col gap-2">
|
||||
<span className="text-[11px] font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Training Info
|
||||
</span>
|
||||
|
|
@ -1010,7 +1010,7 @@ export function ExportPage() {
|
|||
key={step}
|
||||
className="flex items-start gap-2 text-xs text-muted-foreground"
|
||||
>
|
||||
<span className="flex size-5 shrink-0 items-center justify-center rounded-full bg-muted text-[10px] font-semibold">
|
||||
<span className="flex size-5 shrink-0 items-center justify-center rounded-full bg-foreground/10 text-[10px] font-semibold">
|
||||
{i + 1}
|
||||
</span>
|
||||
{step}
|
||||
|
|
|
|||
|
|
@ -96,11 +96,11 @@ export function UsageExamples() {
|
|||
};
|
||||
|
||||
return (
|
||||
<section className="flex flex-col">
|
||||
<section className="flex min-w-0 max-w-full flex-col">
|
||||
<h2 className="mb-2 text-sm font-semibold text-foreground">Usage examples</h2>
|
||||
<div className="overflow-hidden rounded-lg border border-border bg-muted/20">
|
||||
<div className="flex items-center justify-between border-b border-border px-2 py-1.5">
|
||||
<div className="flex items-center gap-0.5">
|
||||
<div className="min-w-0 max-w-full overflow-hidden rounded-lg border border-border bg-muted/20">
|
||||
<div className="flex min-w-0 items-center justify-between gap-2 border-b border-border px-2 py-1.5">
|
||||
<div className="flex min-w-0 items-center gap-0.5">
|
||||
{TABS.map((t) => {
|
||||
const active = lang === t.id;
|
||||
return (
|
||||
|
|
@ -134,7 +134,7 @@ export function UsageExamples() {
|
|||
{copied ? "Copied" : "Copy"}
|
||||
</button>
|
||||
</div>
|
||||
<pre className="overflow-x-auto p-3 font-mono text-[11px] leading-relaxed text-foreground">
|
||||
<pre className="max-w-full overflow-x-auto whitespace-pre-wrap break-words p-3 font-mono text-[11px] leading-relaxed text-foreground">
|
||||
{snippets[lang]}
|
||||
</pre>
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 border-t border-border px-3 py-2 text-[11px] text-muted-foreground">
|
||||
|
|
|
|||
|
|
@ -114,17 +114,17 @@ export function SettingsDialog() {
|
|||
type="button"
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={cn(
|
||||
"relative flex h-[30px] items-center gap-2.5 rounded-[8px] px-2.5 text-sm font-medium transition-colors",
|
||||
"relative flex h-[32px] items-center gap-2.5 rounded-[8px] px-2.5 text-[14.5px] leading-[19px] tracking-nav font-medium transition-colors",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
|
||||
active
|
||||
? "text-black dark:text-white"
|
||||
: "text-[#383835] dark:text-[#c7c7c4] hover:bg-[#ececec] dark:hover:bg-[#2e3035] hover:text-black dark:hover:text-white",
|
||||
: "text-[#383835] dark:text-[#c7c7c4] hover:bg-[#ececec] dark:hover:bg-[#2d2f33] hover:text-black dark:hover:text-white",
|
||||
)}
|
||||
>
|
||||
{active && (
|
||||
<motion.span
|
||||
layoutId="settings-active-pill"
|
||||
className="absolute inset-0 rounded-[8px] bg-[#ececec] dark:bg-[#2e3035]"
|
||||
className="absolute inset-0 rounded-[8px] bg-[#ececec] dark:bg-[#2d2f33]"
|
||||
transition={
|
||||
reduced
|
||||
? { duration: 0 }
|
||||
|
|
@ -139,8 +139,8 @@ export function SettingsDialog() {
|
|||
)}
|
||||
<HugeiconsIcon
|
||||
icon={tab.icon}
|
||||
strokeWidth={1.5}
|
||||
className="relative z-10 size-[18px]"
|
||||
strokeWidth={1.75}
|
||||
className="relative z-10 size-icon"
|
||||
/>
|
||||
<span className="relative z-10 min-w-0 truncate">{tab.label}</span>
|
||||
{tab.badge ? (
|
||||
|
|
@ -158,12 +158,12 @@ export function SettingsDialog() {
|
|||
<button
|
||||
type="button"
|
||||
onClick={closeDialog}
|
||||
className="absolute top-3 right-3 z-10 flex size-7 items-center justify-center rounded-[8px] text-[#383835] dark:text-[#c7c7c4] transition-colors hover:bg-[#ececec] dark:hover:bg-[#2e3035] hover:text-black dark:hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
className="absolute top-3 right-3 z-10 flex size-7 items-center justify-center rounded-[8px] text-[#383835] dark:text-[#c7c7c4] transition-colors hover:bg-[#ececec] dark:hover:bg-[#2d2f33] hover:text-black dark:hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
aria-label="Close settings"
|
||||
>
|
||||
<HugeiconsIcon icon={Cancel01Icon} className="size-4" />
|
||||
</button>
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto p-6">
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-y-auto p-6">
|
||||
{renderTab(activeTab)}
|
||||
</div>
|
||||
</main>
|
||||
|
|
|
|||
|
|
@ -61,8 +61,8 @@ export function ApiKeysTab() {
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<header className="flex flex-col gap-1">
|
||||
<div className="flex min-w-0 max-w-full flex-col gap-6">
|
||||
<header className="flex min-w-0 flex-col gap-1">
|
||||
<h1 className="text-lg font-semibold font-heading">API</h1>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Access Unsloth programmatically via the OpenAI-compatible API.{" "}
|
||||
|
|
@ -111,7 +111,7 @@ export function ApiKeysTab() {
|
|||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<section className="flex flex-col">
|
||||
<section className="flex min-w-0 flex-col">
|
||||
<h2 className="mb-2 text-sm font-semibold text-foreground">Access tokens</h2>
|
||||
{error ? (
|
||||
<div className="rounded-md border border-destructive/20 bg-destructive/5 p-3 text-xs text-destructive">
|
||||
|
|
@ -131,7 +131,7 @@ export function ApiKeysTab() {
|
|||
No API access yet.
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col">
|
||||
<div className="flex min-w-0 flex-col">
|
||||
{keys.map((k) => (
|
||||
<ApiKeyRow key={k.id} apiKey={k} onRevoke={setRevokeTarget} />
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
import type { TrainingViewData } from "@/features/training";
|
||||
import { getTrainingRun } from "@/features/training";
|
||||
import { getTrainingRun, onTrainingRunUpdated } from "@/features/training";
|
||||
import type { TrainingRunDetailResponse } from "@/features/training";
|
||||
import { parseBackendTrainingMethod } from "@/features/training/lib/training-methods";
|
||||
import { type ReactElement, useEffect, useState } from "react";
|
||||
|
|
@ -70,7 +70,7 @@ function mapToViewData(detail: TrainingRunDetailResponse): TrainingViewData {
|
|||
: run.error_message ?? "Training errored",
|
||||
error: run.status === "error" ? run.error_message : null,
|
||||
isTrainingRunning: false,
|
||||
modelName: run.model_name,
|
||||
modelName: run.display_name ?? run.model_name,
|
||||
trainingMethod: parseBackendTrainingMethod(
|
||||
detail.config?.training_type,
|
||||
detail.config?.load_in_4bit,
|
||||
|
|
@ -109,6 +109,14 @@ export function HistoricalTrainingView({
|
|||
};
|
||||
}, [runId]);
|
||||
|
||||
useEffect(() => {
|
||||
const offUpdated = onTrainingRunUpdated((updated) => {
|
||||
if (updated.id !== runId) return;
|
||||
setDetail((prev) => (prev ? { ...prev, run: updated } : prev));
|
||||
});
|
||||
return offUpdated;
|
||||
}, [runId]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="rounded-xl border bg-card p-8 text-sm text-muted-foreground">
|
||||
|
|
|
|||
|
|
@ -15,7 +15,11 @@ import { Button } from "@/components/ui/button";
|
|||
import type { TrainingRunSummary } from "@/features/training";
|
||||
import {
|
||||
deleteTrainingRun,
|
||||
emitTrainingRunDeleted,
|
||||
listTrainingRuns,
|
||||
onTrainingRunDeleted,
|
||||
onTrainingRunsChanged,
|
||||
onTrainingRunUpdated,
|
||||
useTrainingActions,
|
||||
useTrainingRuntimeStore,
|
||||
} from "@/features/training";
|
||||
|
|
@ -180,6 +184,11 @@ export function HistoryCardGrid({
|
|||
const pollControllerRef = useRef<AbortController | null>(null);
|
||||
const fetchIdRef = useRef(0);
|
||||
const pollIdRef = useRef(0);
|
||||
const runsLengthRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
runsLengthRef.current = runs.length;
|
||||
}, [runs.length]);
|
||||
|
||||
const fetchRuns = useCallback(async (offset = 0, append = false, limit = PAGE_SIZE) => {
|
||||
// Cancel any in-flight poll so its stale response can't clobber this fresher fetch
|
||||
|
|
@ -216,6 +225,27 @@ export function HistoryCardGrid({
|
|||
};
|
||||
}, [fetchRuns]);
|
||||
|
||||
useEffect(() => {
|
||||
const offUpdated = onTrainingRunUpdated((updated) => {
|
||||
setRuns((prev) =>
|
||||
prev.map((run) => (run.id === updated.id ? updated : run)),
|
||||
);
|
||||
});
|
||||
const offDeleted = onTrainingRunDeleted((runId) => {
|
||||
setRuns((prev) => prev.filter((run) => run.id !== runId));
|
||||
setTotal((prev) => Math.max(0, prev - 1));
|
||||
});
|
||||
const offChanged = onTrainingRunsChanged(() => {
|
||||
const limit = Math.max(PAGE_SIZE, runsLengthRef.current);
|
||||
void fetchRuns(0, false, limit);
|
||||
});
|
||||
return () => {
|
||||
offUpdated();
|
||||
offDeleted();
|
||||
offChanged();
|
||||
};
|
||||
}, [fetchRuns]);
|
||||
|
||||
// Poll while any run is still "running" so the card shows live progress
|
||||
const hasRunningRun = runs.some((r) => r.status === "running");
|
||||
const visibleCount = runs.length;
|
||||
|
|
@ -248,9 +278,7 @@ export function HistoryCardGrid({
|
|||
setDeleteError(null);
|
||||
try {
|
||||
await deleteTrainingRun(deleteTarget);
|
||||
// Optimistically remove the card so it disappears immediately
|
||||
setRuns((prev) => prev.filter((r) => r.id !== deleteTarget));
|
||||
setTotal((prev) => Math.max(0, prev - 1));
|
||||
emitTrainingRunDeleted(deleteTarget);
|
||||
// Re-fetch preserving visible count so offsets stay consistent for "Load more"
|
||||
const currentCount = runs.length - 1;
|
||||
const limit = Math.max(PAGE_SIZE, currentCount);
|
||||
|
|
@ -366,11 +394,22 @@ export function HistoryCardGrid({
|
|||
<div className="min-w-0">
|
||||
<p
|
||||
className="truncate text-sm font-medium"
|
||||
title={run.model_name}
|
||||
title={run.display_name ?? run.model_name}
|
||||
>
|
||||
{run.model_name}
|
||||
{run.display_name ?? run.model_name}
|
||||
</p>
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{run.display_name && (
|
||||
<p
|
||||
className="truncate text-xs text-muted-foreground"
|
||||
title={run.model_name}
|
||||
>
|
||||
{run.model_name}
|
||||
</p>
|
||||
)}
|
||||
<p
|
||||
className="truncate text-xs text-muted-foreground"
|
||||
title={run.dataset_name}
|
||||
>
|
||||
{run.dataset_name}
|
||||
</p>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import type {
|
|||
TrainingRunDeleteResponse,
|
||||
TrainingRunDetailResponse,
|
||||
TrainingRunListResponse,
|
||||
TrainingRunSummary,
|
||||
} from "../types/history";
|
||||
|
||||
async function readError(response: Response): Promise<string> {
|
||||
|
|
@ -57,3 +58,20 @@ export async function deleteTrainingRun(
|
|||
);
|
||||
return parseJson<TrainingRunDeleteResponse>(response);
|
||||
}
|
||||
|
||||
export async function renameTrainingRun(
|
||||
runId: string,
|
||||
displayName: string | null,
|
||||
signal?: AbortSignal,
|
||||
): Promise<TrainingRunSummary> {
|
||||
const response = await authFetch(
|
||||
`/api/train/runs/${encodeURIComponent(runId)}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ display_name: displayName }),
|
||||
signal,
|
||||
},
|
||||
);
|
||||
return parseJson<TrainingRunSummary>(response);
|
||||
}
|
||||
|
|
|
|||
51
studio/frontend/src/features/training/events.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
import type { TrainingRunSummary } from "./types/history";
|
||||
|
||||
type UpdateListener = (run: TrainingRunSummary) => void;
|
||||
type DeleteListener = (runId: string) => void;
|
||||
type ChangedListener = () => void;
|
||||
|
||||
const updateListeners = new Set<UpdateListener>();
|
||||
const deleteListeners = new Set<DeleteListener>();
|
||||
const changedListeners = new Set<ChangedListener>();
|
||||
|
||||
export function onTrainingRunUpdated(fn: UpdateListener): () => void {
|
||||
updateListeners.add(fn);
|
||||
return () => {
|
||||
updateListeners.delete(fn);
|
||||
};
|
||||
}
|
||||
|
||||
export function onTrainingRunDeleted(fn: DeleteListener): () => void {
|
||||
deleteListeners.add(fn);
|
||||
return () => {
|
||||
deleteListeners.delete(fn);
|
||||
};
|
||||
}
|
||||
|
||||
export function onTrainingRunsChanged(fn: ChangedListener): () => void {
|
||||
changedListeners.add(fn);
|
||||
return () => {
|
||||
changedListeners.delete(fn);
|
||||
};
|
||||
}
|
||||
|
||||
export function emitTrainingRunUpdated(run: TrainingRunSummary): void {
|
||||
for (const fn of updateListeners) {
|
||||
fn(run);
|
||||
}
|
||||
}
|
||||
|
||||
export function emitTrainingRunDeleted(runId: string): void {
|
||||
for (const fn of deleteListeners) {
|
||||
fn(runId);
|
||||
}
|
||||
}
|
||||
|
||||
export function emitTrainingRunsChanged(): void {
|
||||
for (const fn of changedListeners) {
|
||||
fn();
|
||||
}
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ import { primeNativeNotificationPermission } from "@/lib/native-notifications";
|
|||
import { useCallback } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { checkDatasetFormat } from "../api/datasets-api";
|
||||
import { emitTrainingRunsChanged } from "../events";
|
||||
import { getTrainingRun } from "../api/history-api";
|
||||
import { buildTrainingStartPayload } from "../api/mappers";
|
||||
import { resetTraining, startTraining, stopTraining } from "../api/train-api";
|
||||
|
|
@ -140,6 +141,7 @@ export function useTrainingActions() {
|
|||
}
|
||||
|
||||
runtimeStore.setStartQueued(response.job_id, response.message);
|
||||
emitTrainingRunsChanged();
|
||||
await syncTrainingRuntimeFromBackend();
|
||||
return true;
|
||||
} catch (error) {
|
||||
|
|
@ -203,6 +205,7 @@ export function useTrainingActions() {
|
|||
}
|
||||
|
||||
runtimeStore.setStartQueued(response.job_id, response.message);
|
||||
emitTrainingRunsChanged();
|
||||
await syncTrainingRuntimeFromBackend();
|
||||
return true;
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -2,55 +2,195 @@
|
|||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { listTrainingRuns } from "../api/history-api";
|
||||
import {
|
||||
onTrainingRunDeleted,
|
||||
onTrainingRunsChanged,
|
||||
onTrainingRunUpdated,
|
||||
} from "../events";
|
||||
import type { TrainingRunSummary } from "../types/history";
|
||||
|
||||
const SIDEBAR_LIMIT = 20;
|
||||
const RUNNING_POLL_MS = 5000;
|
||||
const INITIAL_RETRY_DELAYS_MS = [500, 1500, 3500];
|
||||
const LOAD_FAILURE_TOAST_ID = "training-history-load-failure";
|
||||
|
||||
function isAbortError(err: unknown): boolean {
|
||||
return err instanceof DOMException && err.name === "AbortError";
|
||||
}
|
||||
|
||||
export function useTrainingHistorySidebarItems(enabled: boolean) {
|
||||
const [items, setItems] = useState<TrainingRunSummary[]>([]);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const controllerRef = useRef<AbortController | null>(null);
|
||||
const inFlightRef = useRef(false);
|
||||
|
||||
const fetchRuns = useCallback(async () => {
|
||||
if (inFlightRef.current) {
|
||||
const fetchRuns = useCallback(async (): Promise<void> => {
|
||||
if (controllerRef.current && !controllerRef.current.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
controllerRef.current = controller;
|
||||
inFlightRef.current = true;
|
||||
try {
|
||||
const result = await listTrainingRuns(SIDEBAR_LIMIT, 0, controller.signal);
|
||||
const result = await listTrainingRuns(
|
||||
SIDEBAR_LIMIT,
|
||||
0,
|
||||
controller.signal,
|
||||
);
|
||||
if (controller.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
setItems(result.runs);
|
||||
setLoaded(true);
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === "AbortError") return;
|
||||
toast.dismiss(LOAD_FAILURE_TOAST_ID);
|
||||
} finally {
|
||||
if (controllerRef.current === controller) {
|
||||
controllerRef.current = null;
|
||||
}
|
||||
inFlightRef.current = false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Background refresh (rename/delete sync, polling): swallow errors so
|
||||
// transient failures don't spam toasts; the next successful fetch heals.
|
||||
const refresh = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
await fetchRuns();
|
||||
} catch {
|
||||
// intentionally ignored
|
||||
}
|
||||
}, [fetchRuns]);
|
||||
|
||||
// Initial load: bounded retry-with-backoff, then surface a toast on
|
||||
// final failure with a Retry action so the user isn't stuck staring
|
||||
// at an empty sidebar after F5 if the backend was slow to come up.
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
void fetchRuns();
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const showFailureToast = (err: unknown): void => {
|
||||
toast.error("Couldn't load training runs", {
|
||||
id: LOAD_FAILURE_TOAST_ID,
|
||||
description: err instanceof Error ? err.message : undefined,
|
||||
action: {
|
||||
label: "Retry",
|
||||
onClick: () => {
|
||||
void (async () => {
|
||||
try {
|
||||
await fetchRuns();
|
||||
} catch (retryErr) {
|
||||
if (isAbortError(retryErr)) {
|
||||
return;
|
||||
}
|
||||
showFailureToast(retryErr);
|
||||
}
|
||||
})();
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const attempt = async (index: number): Promise<void> => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await fetchRuns();
|
||||
} catch (err) {
|
||||
if (cancelled || isAbortError(err)) {
|
||||
return;
|
||||
}
|
||||
if (index < INITIAL_RETRY_DELAYS_MS.length) {
|
||||
timer = setTimeout(
|
||||
() => void attempt(index + 1),
|
||||
INITIAL_RETRY_DELAYS_MS[index],
|
||||
);
|
||||
return;
|
||||
}
|
||||
showFailureToast(err);
|
||||
}
|
||||
};
|
||||
|
||||
void attempt(0);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
controllerRef.current?.abort();
|
||||
};
|
||||
}, [enabled, fetchRuns]);
|
||||
|
||||
// Poll while there's a running run, but only when the tab is visible.
|
||||
// Browsers throttle background timers but don't pause them — gating on
|
||||
// visibility avoids hammering the API for tabs left open in the
|
||||
// background, which is common during long training runs.
|
||||
const hasRunning = items.some((r) => r.status === "running");
|
||||
useEffect(() => {
|
||||
if (!enabled || !hasRunning) return;
|
||||
const timer = setInterval(() => {
|
||||
void fetchRuns();
|
||||
}, RUNNING_POLL_MS);
|
||||
return () => clearInterval(timer);
|
||||
}, [enabled, hasRunning, fetchRuns]);
|
||||
if (!enabled || !hasRunning) {
|
||||
return;
|
||||
}
|
||||
|
||||
return { items, loaded, refresh: fetchRuns };
|
||||
let timer: ReturnType<typeof setInterval> | null = null;
|
||||
const start = () => {
|
||||
if (timer !== null) {
|
||||
return;
|
||||
}
|
||||
timer = setInterval(() => void refresh(), RUNNING_POLL_MS);
|
||||
};
|
||||
const stop = () => {
|
||||
if (timer === null) {
|
||||
return;
|
||||
}
|
||||
clearInterval(timer);
|
||||
timer = null;
|
||||
};
|
||||
|
||||
const onVisibilityChange = () => {
|
||||
if (document.visibilityState === "visible") {
|
||||
void refresh();
|
||||
start();
|
||||
} else {
|
||||
stop();
|
||||
}
|
||||
};
|
||||
|
||||
if (document.visibilityState === "visible") {
|
||||
start();
|
||||
}
|
||||
document.addEventListener("visibilitychange", onVisibilityChange);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("visibilitychange", onVisibilityChange);
|
||||
stop();
|
||||
controllerRef.current?.abort();
|
||||
};
|
||||
}, [enabled, hasRunning, refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
const offUpdated = onTrainingRunUpdated((updated) => {
|
||||
controllerRef.current?.abort();
|
||||
setItems((prev) =>
|
||||
prev.map((run) => (run.id === updated.id ? updated : run)),
|
||||
);
|
||||
});
|
||||
const offDeleted = onTrainingRunDeleted((runId) => {
|
||||
controllerRef.current?.abort();
|
||||
setItems((prev) => prev.filter((run) => run.id !== runId));
|
||||
});
|
||||
const offChanged = onTrainingRunsChanged(() => {
|
||||
controllerRef.current?.abort();
|
||||
void refresh();
|
||||
});
|
||||
return () => {
|
||||
offUpdated();
|
||||
offDeleted();
|
||||
offChanged();
|
||||
};
|
||||
}, [refresh]);
|
||||
|
||||
return { items, loaded, refresh };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ export {
|
|||
export { useTrainingActions } from "./hooks/use-training-actions";
|
||||
export { useTrainingHistorySidebarItems } from "./hooks/use-training-history-sidebar";
|
||||
export { useTrainingRuntimeLifecycle } from "./hooks/use-training-runtime-lifecycle";
|
||||
export { removeTrainingUnloadGuard } from "./hooks/use-training-unload-guard";
|
||||
export { useMaxStepsEpochsToggle } from "./hooks/use-max-steps-epochs-toggle";
|
||||
export { HfDatasetSubsetSplitSelectors } from "./components/hf-dataset-subset-split-selectors";
|
||||
export { useDatasetPreviewDialogStore } from "./stores/dataset-preview-dialog-store";
|
||||
|
|
@ -23,6 +24,19 @@ export type {
|
|||
TrainingRunDetailResponse,
|
||||
TrainingRunDeleteResponse,
|
||||
} from "./types/history";
|
||||
export { listTrainingRuns, getTrainingRun, deleteTrainingRun } from "./api/history-api";
|
||||
export {
|
||||
listTrainingRuns,
|
||||
getTrainingRun,
|
||||
deleteTrainingRun,
|
||||
renameTrainingRun,
|
||||
} from "./api/history-api";
|
||||
export {
|
||||
onTrainingRunUpdated,
|
||||
onTrainingRunDeleted,
|
||||
onTrainingRunsChanged,
|
||||
emitTrainingRunUpdated,
|
||||
emitTrainingRunDeleted,
|
||||
emitTrainingRunsChanged,
|
||||
} from "./events";
|
||||
export { parseYamlConfig, serializeConfigToYaml } from "./lib/yaml-config";
|
||||
export { validateTrainingConfig } from "./lib/validation";
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ export interface TrainingRunSummary {
|
|||
status: "running" | "completed" | "stopped" | "error";
|
||||
model_name: string;
|
||||
dataset_name: string;
|
||||
display_name: string | null;
|
||||
started_at: string;
|
||||
ended_at: string | null;
|
||||
total_steps: number | null;
|
||||
|
|
|
|||
|
|
@ -63,7 +63,7 @@
|
|||
--card-foreground: oklch(0.1281 0.0179 169.2764);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.1281 0.0179 169.2764);
|
||||
--primary: oklch(0.6929 0.1396 166.5513);
|
||||
--primary: #17b88b;
|
||||
--primary-foreground: oklch(1 0 0);
|
||||
--secondary: oklch(0.9596 0.0275 167.8295);
|
||||
--secondary-foreground: oklch(0.2868 0.0649 159.9823);
|
||||
|
|
@ -74,21 +74,21 @@
|
|||
--destructive: oklch(0.6368 0.2078 25.3313);
|
||||
--border: oklch(0.9208 0.0101 164.8536);
|
||||
--input: oklch(0.9208 0.0101 164.8536);
|
||||
--ring: oklch(0.6929 0.1396 166.5513);
|
||||
--chart-1: oklch(0.6929 0.1396 166.5513);
|
||||
--ring: #17b88b;
|
||||
--chart-1: #17b88b;
|
||||
--chart-2: oklch(0.694 0.1395 136.6059);
|
||||
--chart-3: oklch(0.7014 0.1193 197.5897);
|
||||
--chart-4: oklch(0.6926 0.1112 346.5775);
|
||||
--chart-5: oklch(0.7497 0.1003 85.0057);
|
||||
--radius: 1.1rem;
|
||||
--sidebar: oklch(0.99 0 0);
|
||||
--sidebar: #f9faf9;
|
||||
--sidebar-foreground: oklch(0.1281 0.0179 169.2764);
|
||||
--sidebar-primary: oklch(0.6929 0.1396 166.5513);
|
||||
--sidebar-primary: #17b88b;
|
||||
--sidebar-primary-foreground: oklch(1 0 0);
|
||||
--sidebar-accent: oklch(0.96 0.0279 166.55);
|
||||
--sidebar-accent-foreground: oklch(0.2868 0.0649 159.9823);
|
||||
--sidebar-border: oklch(0.9208 0.0101 164.8536);
|
||||
--sidebar-ring: oklch(0.6929 0.1396 166.5513);
|
||||
--sidebar-border: oklch(0.945 0.0101 164.8536);
|
||||
--sidebar-ring: #17b88b;
|
||||
--destructive-foreground: oklch(1 0 0);
|
||||
--font-sans: "Inter Variable", ui-sans-serif, sans-serif, system-ui;
|
||||
--font-heading: "Hellix", "Space Grotesk Variable", var(--font-sans);
|
||||
|
|
@ -121,48 +121,97 @@
|
|||
/* 0px 8px 10px 0px hsl(0 0% 0% / 0);*/
|
||||
/*--shadow-2xl: 0px 0px 0px 0px hsl(0 0% 0% / 0);*/
|
||||
--tracking-normal: -0.01em;
|
||||
|
||||
/* Hex (not OKLCH) so the rendered surface matches the design mockup pixel-for-pixel. */
|
||||
--nav-fg: #383835;
|
||||
--nav-fg-muted: #858279;
|
||||
--nav-surface-hover: #f0f0f0;
|
||||
--nav-icon-idle: #8f8f8f;
|
||||
--nav-beta-border: #e0ded6;
|
||||
--panel-surface-hover: #ebebeb;
|
||||
/* Right-side chat-parameters panel: matches the chat content
|
||||
surface in both themes — distinction from the left sidebar
|
||||
(#f9faf9) is handled by the left border alone. Tracks
|
||||
`--background` so any future tweaks to the chat surface flow
|
||||
through automatically. */
|
||||
--panel-surface: var(--background);
|
||||
--panel-surface-fg: var(--foreground);
|
||||
--panel-input-surface: #f5f5f5;
|
||||
--panel-input-surface-hover: #efefef;
|
||||
/* Muted gray with a one-step warmer-blue last channel (#779 vs flat #777)
|
||||
so the tone has a faint hue rather than pure neutral — keeps text and
|
||||
sliders quiet but not lifeless. */
|
||||
--panel-surface-fg-muted: #777779;
|
||||
/* Slider track-fill / thumb / hover halo. Decoupled from
|
||||
--panel-surface-fg-muted so the slider can be tuned independently
|
||||
from muted text. Light mode: lighter than the muted-text gray for
|
||||
a softer feel. Dark mode (further down) keeps parity with the
|
||||
muted-text token. */
|
||||
--panel-slider-fg: #9a9a9c;
|
||||
/* Chat-message action icons (assistant action bar, branch picker
|
||||
chevrons + numbers, message-timing token counter, code-block
|
||||
copy/download, user action bar, delete button). One token drives
|
||||
all of them so the row reads as a single coherent control strip.
|
||||
Mid-dark gray on the light surface — visible enough to read as
|
||||
active controls, not so dark that they compete with message
|
||||
text. */
|
||||
--chat-icon-fg: #555555;
|
||||
--chat-icon-fg-hover: var(--foreground);
|
||||
--chat-icon-bg-hover: #ededec;
|
||||
|
||||
/* Standard interactive-icon size for nav, menus, action bars, and
|
||||
in-message code-block actions. Sized one step above body text so
|
||||
icons read as minimally larger than adjacent labels (~14px text).
|
||||
Theme-independent — declared once in :root. */
|
||||
--icon-size: 18px;
|
||||
/* Inset of a centered .size-icon glyph within a 2rem (size-8) action
|
||||
button — i.e. (32px − icon-size) / 2. Use as a negative margin on a
|
||||
chat-message action bar so the leftmost icon's visual edge aligns
|
||||
with the message text edge. Auto-tracks --icon-size. */
|
||||
--icon-btn-inset: calc((2rem - var(--icon-size)) / 2);
|
||||
}
|
||||
|
||||
.dark {
|
||||
/* Exact palette from apps/studio/index_chat.html mockup. Using hex so the
|
||||
rendered surface matches the mockup pixel-for-pixel — OKLCH conversion
|
||||
drifted ~3% darker and shifted the neutral hue. */
|
||||
--background: #1a1b1e;
|
||||
--foreground: #d4d4d4;
|
||||
--card: #222427;
|
||||
--card-foreground: #d4d4d4;
|
||||
--popover: #222427;
|
||||
--popover-foreground: #d4d4d4;
|
||||
--primary: oklch(0.6929 0.1396 166.5513);
|
||||
--background: #1f2023;
|
||||
--foreground: #ececee;
|
||||
--card: #2d2e32;
|
||||
--card-foreground: #ececee;
|
||||
--popover: #2d2e32;
|
||||
--popover-foreground: #ececee;
|
||||
--primary: #17b88b;
|
||||
--primary-foreground: oklch(1 0 0);
|
||||
--secondary: #2e3035;
|
||||
--secondary-foreground: #d4d4d4;
|
||||
--secondary-foreground: #ececee;
|
||||
--muted: #2e3035;
|
||||
--muted-foreground: #999999;
|
||||
--accent: #2e3035;
|
||||
--accent-foreground: #d4d4d4;
|
||||
--accent-foreground: #ececee;
|
||||
--destructive: oklch(0.6368 0.2078 25.3313);
|
||||
--border: #2e3035;
|
||||
/* --input one step lighter than --muted so form borders stay visible on
|
||||
muted surfaces (right config panel) and keep subtle contrast on card. */
|
||||
/* --border / --input one step lighter than --muted so outlines and form
|
||||
borders stay visible on muted surfaces (right config panel, export
|
||||
tiles, quant chips) and keep subtle contrast on card. */
|
||||
--border: #3a3d42;
|
||||
--input: #3a3d42;
|
||||
--ring: oklch(0.6929 0.1396 166.5513);
|
||||
--ring: #17b88b;
|
||||
--chart-1: oklch(0.7511 0.1407 166.2284);
|
||||
--chart-2: oklch(0.75 0.14 136.5572);
|
||||
--chart-3: oklch(0.7554 0.1285 197.339);
|
||||
--chart-4: oklch(0.7503 0.1199 346.7805);
|
||||
--chart-5: oklch(0.799 0.1196 84.6633);
|
||||
--sidebar: #222427;
|
||||
--sidebar-foreground: #d4d4d4;
|
||||
--sidebar-primary: oklch(0.6929 0.1396 166.5513);
|
||||
--sidebar: #18181a;
|
||||
--sidebar-foreground: #ececee;
|
||||
--sidebar-primary: #17b88b;
|
||||
--sidebar-primary-foreground: oklch(1 0 0);
|
||||
--sidebar-accent: #2e3035;
|
||||
--sidebar-accent-foreground: #d4d4d4;
|
||||
--sidebar-border: #2e3035;
|
||||
--sidebar-ring: oklch(0.6929 0.1396 166.5513);
|
||||
--sidebar-accent: #2f2f31;
|
||||
--sidebar-accent-foreground: #ececee;
|
||||
--sidebar-border: #2d2d2f;
|
||||
--sidebar-ring: #17b88b;
|
||||
--destructive-foreground: oklch(1 0 0);
|
||||
--radius: 0.625rem;
|
||||
--font-sans: Geist, ui-sans-serif, sans-serif, system-ui;
|
||||
--font-sans: "Inter Variable", ui-sans-serif, sans-serif, system-ui;
|
||||
--font-serif: Source Serif 4, serif;
|
||||
--font-mono: JetBrains Mono, monospace;
|
||||
--shadow-color: hsl(0 0% 0%);
|
||||
|
|
@ -181,6 +230,34 @@
|
|||
--shadow-lg: 0px 0px 0px 0px hsl(0 0% 0% / 0), 0px 4px 6px 0px hsl(0 0% 0% / 0);
|
||||
--shadow-xl: 0px 0px 0px 0px hsl(0 0% 0% / 0), 0px 8px 10px 0px hsl(0 0% 0% / 0);
|
||||
--shadow-2xl: 0px 0px 0px 0px hsl(0 0% 0% / 0);
|
||||
|
||||
--nav-fg: #c7c7c4;
|
||||
--nav-fg-muted: #96979b;
|
||||
--nav-surface-hover: #2e2e30;
|
||||
--nav-icon-idle: #5c5c5c;
|
||||
--nav-beta-border: #3a3c3f;
|
||||
--panel-surface-hover: #3a3c42;
|
||||
/* Right-side chat-parameters panel: matches the chat content
|
||||
surface in both themes — distinction from the left sidebar
|
||||
(#18181a, the deepest surface) is handled by the left border
|
||||
alone. Tracks `--background` so any future tweaks to the chat
|
||||
surface flow through automatically. */
|
||||
--panel-surface: var(--background);
|
||||
--panel-surface-fg: var(--foreground);
|
||||
--panel-input-surface: #2a2b2e;
|
||||
--panel-input-surface-hover: #2e3033;
|
||||
/* Soft neutral gray for muted text and sliders. Pure-ish #ababab
|
||||
reads as quiet on the dark panel without going colored. */
|
||||
--panel-surface-fg-muted: #ababab;
|
||||
/* Dark-mode slider tone matches muted text — user wants the dark
|
||||
theme slider unchanged from the previous behavior. */
|
||||
--panel-slider-fg: #ababab;
|
||||
/* Chat-message action icons. A touch lighter than the previous
|
||||
#b8b8b8 so the icons read clearly without going near pure white;
|
||||
hover restores full --foreground for affordance. */
|
||||
--chat-icon-fg: #d8d8d8;
|
||||
--chat-icon-fg-hover: var(--foreground);
|
||||
--chat-icon-bg-hover: #2d2e32;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
|
|
@ -251,6 +328,20 @@
|
|||
/*--shadow-opacity: var(--shadow-opacity);*/
|
||||
/*--color-shadow-color: var(--shadow-color);*/
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
|
||||
--color-nav-fg: var(--nav-fg);
|
||||
--color-nav-fg-muted: var(--nav-fg-muted);
|
||||
--color-nav-surface-hover: var(--nav-surface-hover);
|
||||
--color-nav-icon-idle: var(--nav-icon-idle);
|
||||
--color-nav-beta-border: var(--nav-beta-border);
|
||||
--color-panel-surface-hover: var(--panel-surface-hover);
|
||||
--color-panel-surface: var(--panel-surface);
|
||||
--color-panel-surface-fg: var(--panel-surface-fg);
|
||||
--color-panel-surface-fg-muted: var(--panel-surface-fg-muted);
|
||||
--color-chat-icon-fg: var(--chat-icon-fg);
|
||||
--color-chat-icon-fg-hover: var(--chat-icon-fg-hover);
|
||||
--color-chat-icon-bg-hover: var(--chat-icon-bg-hover);
|
||||
|
||||
--animate-pulse: pulse var(--duration) ease-out infinite;
|
||||
|
||||
@keyframes pulse {
|
||||
|
|
@ -362,6 +453,300 @@
|
|||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
/* Dark mode loosens tracking to offset optical bloom on dark surfaces. */
|
||||
.tracking-nav {
|
||||
letter-spacing: 0.015em;
|
||||
}
|
||||
.dark .tracking-nav {
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.nav-icon-btn {
|
||||
@apply inline-flex h-7 w-7 items-center justify-center rounded-[10px] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring;
|
||||
}
|
||||
|
||||
/* Standard icon size — drives every nav/menu/action-bar icon
|
||||
(left sidebar, app-user menu, settings tabs, chat config toggle,
|
||||
right-panel close, chat message action bars, code-block actions).
|
||||
Pulls from --icon-size so a single edit retunes them all. */
|
||||
.size-icon {
|
||||
width: var(--icon-size);
|
||||
height: var(--icon-size);
|
||||
}
|
||||
|
||||
/* Pins Inter across themes; parent `font-heading` resolves to Geist in dark. */
|
||||
.nav-badge {
|
||||
font-family: "Inter Variable", ui-sans-serif, system-ui, sans-serif;
|
||||
}
|
||||
|
||||
.sidebar-nav-btn {
|
||||
color: var(--nav-fg);
|
||||
}
|
||||
.sidebar-nav-btn:hover,
|
||||
.sidebar-nav-btn[data-active="true"],
|
||||
.sidebar-nav-btn[data-state="open"],
|
||||
.group\/recent-item:hover .sidebar-nav-btn,
|
||||
.group\/recent-item:has(.sidebar-row-action[data-state="open"]) .sidebar-nav-btn,
|
||||
.group\/run-item:hover .sidebar-nav-btn,
|
||||
.group\/run-item:has(.sidebar-row-action[data-state="open"]) .sidebar-nav-btn {
|
||||
background-color: var(--nav-surface-hover) !important;
|
||||
color: #000 !important;
|
||||
}
|
||||
.dark .sidebar-nav-btn:hover,
|
||||
.dark .sidebar-nav-btn[data-active="true"],
|
||||
.dark .sidebar-nav-btn[data-state="open"],
|
||||
.dark .group\/recent-item:hover .sidebar-nav-btn,
|
||||
.dark .group\/recent-item:has(.sidebar-row-action[data-state="open"]) .sidebar-nav-btn,
|
||||
.dark .group\/run-item:hover .sidebar-nav-btn,
|
||||
.dark .group\/run-item:has(.sidebar-row-action[data-state="open"]) .sidebar-nav-btn {
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
.sidebar-row-action {
|
||||
@apply absolute top-0 bottom-0 right-0 inline-flex items-center justify-end pl-2 pr-1.5 opacity-0 pointer-events-none outline-none;
|
||||
}
|
||||
.sidebar-row-action[data-state="open"] {
|
||||
@apply opacity-100 pointer-events-auto;
|
||||
}
|
||||
.sidebar-row-action-glyph {
|
||||
@apply inline-flex size-6 items-center justify-center rounded-[10px] text-sidebar-foreground/55;
|
||||
}
|
||||
|
||||
/* Branch picker chevron buttons sit beside action bar icon buttons
|
||||
(size-8, rounded-[10px]). Height + radius match for visual
|
||||
alignment, but width is tighter so the small chevron glyph reads
|
||||
as a compact control rather than a full-size icon button. */
|
||||
.aui-branch-chevron-btn {
|
||||
@apply inline-flex h-8 w-6 cursor-pointer items-center justify-center rounded-[10px] p-0 text-chat-icon-fg transition-colors hover:bg-chat-icon-bg-hover hover:text-chat-icon-fg-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent;
|
||||
}
|
||||
.sidebar-row-action:hover .sidebar-row-action-glyph,
|
||||
.sidebar-row-action[data-state="open"] .sidebar-row-action-glyph {
|
||||
@apply bg-nav-surface-hover text-nav-fg;
|
||||
}
|
||||
.dark .sidebar-row-action:hover .sidebar-row-action-glyph,
|
||||
.dark .sidebar-row-action[data-state="open"] .sidebar-row-action-glyph {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.sidebar-sticky-label {
|
||||
@apply sticky top-0 z-20 rounded-none bg-sidebar pt-0 pb-1.5 pl-[18px] pr-4 text-[13px]! font-medium normal-case tracking-[0.04em] text-nav-fg-muted focus-visible:ring-0! focus-visible:outline-none shadow-[0_-8px_0_0_var(--sidebar)] transition-shadow duration-150;
|
||||
}
|
||||
.sidebar-sticky-label.is-scrolled {
|
||||
@apply shadow-[0_-8px_0_0_var(--sidebar),0_0.5px_0_0_var(--sidebar-border)];
|
||||
}
|
||||
|
||||
/* Neutral panel input surface — sidesteps the green cast on
|
||||
`--input` / `--border` (both have a small chroma at hue ~165 in
|
||||
light mode). Same value drives the preset input pill, the system
|
||||
prompt button, and the chat-template textarea so all three read
|
||||
as one quiet gray family, matching the focused-edit-number tint. */
|
||||
.panel-input-group {
|
||||
@apply !h-9 min-h-9 min-w-0 items-stretch gap-0 rounded-[10px] pr-0 transition-colors focus-within:ring-0 focus-within:shadow-none;
|
||||
border: 0 !important;
|
||||
background-color: var(--panel-input-surface);
|
||||
}
|
||||
.panel-input-group:has([data-slot="input-group-control"]:focus-visible) {
|
||||
border: 0 !important;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* Neutral surface for the larger panel text containers — system
|
||||
prompt preview button and chat-template textarea — so they share
|
||||
the same gray as the preset input pill and don't pick up the
|
||||
theme's slight green-cast `--input`. */
|
||||
.panel-text-surface {
|
||||
@apply rounded-[20px] border-0 transition-colors;
|
||||
background-color: var(--panel-input-surface);
|
||||
}
|
||||
.panel-text-surface:hover {
|
||||
background-color: var(--panel-input-surface-hover);
|
||||
}
|
||||
|
||||
/* Sidebar sliders: soft neutral grays — active fill and thumb stay
|
||||
in the same neutral family as the panel surface so the controls
|
||||
read as quiet, modern, and uncluttered. Flat (no shadow), small
|
||||
thumb, no ring. The track's translucent neutral adapts to either
|
||||
theme; the fill/thumb pick a mid-gray with enough contrast to
|
||||
read on the panel without competing with text. */
|
||||
/* Inactive track: barely-there alpha so it reads as a faint hint
|
||||
rather than a visible bar — the active fill carries the value,
|
||||
the track just suggests the slider's extent. Same alpha both
|
||||
themes; the black/white base flips automatically per theme. */
|
||||
.panel-slider [data-slot="slider-track"] {
|
||||
height: 0.25rem !important;
|
||||
background-color: rgb(0 0 0 / 0.025) !important;
|
||||
}
|
||||
.dark .panel-slider [data-slot="slider-track"] {
|
||||
background-color: rgb(255 255 255 / 0.025) !important;
|
||||
}
|
||||
/* Sliders in the right-side parameters panel.
|
||||
*
|
||||
* Color: every interactive surface (active fill, thumb body, thumb
|
||||
* border, hover/press halo) resolves through a single token —
|
||||
* `--panel-surface-fg-muted` — so the slider always belongs to the
|
||||
* same gray family as the panel's muted text. Theme switching and
|
||||
* future tone tweaks happen in one place.
|
||||
*
|
||||
* Pressure feedback: only the halo expresses interaction. The
|
||||
* shared <Slider /> component (components/ui/slider.tsx) applies
|
||||
* `hover:scale-110`, `active:scale-95`, `hover:ring-4`, and
|
||||
* `shadow-sm` to the thumb via Tailwind utilities. Suppressing
|
||||
* `transform` and `box-shadow` on the base rule prevents those from
|
||||
* competing with the halo's transition — without that, two
|
||||
* animations run on different durations/curves and the interaction
|
||||
* reads as jittery.
|
||||
*
|
||||
* Track-press detection: Radix's slider thumb only exposes
|
||||
* `data-disabled` and `data-orientation` (verified against the
|
||||
* package source) — there is no active-state attribute on the
|
||||
* thumb. We anchor the press selector on the slider root
|
||||
* (`.panel-slider:active`), which receives `:active` for any
|
||||
* pointer-down inside the slider, including presses that start on
|
||||
* the track. This is the only DOM-faithful way to show the halo
|
||||
* during a track-press without patching the shared component.
|
||||
*
|
||||
* Halo lifecycle: deliberately no `:focus` — pointer focus persists
|
||||
* after release and would leave a stale halo. `:focus-visible`
|
||||
* keeps keyboard navigation accessible (Tab + arrows). */
|
||||
.panel-slider .bg-primary {
|
||||
background-color: var(--panel-slider-fg) !important;
|
||||
}
|
||||
.panel-slider [data-slot="slider-thumb"] {
|
||||
width: 0.875rem !important;
|
||||
height: 0.875rem !important;
|
||||
background-color: var(--panel-slider-fg) !important;
|
||||
border-color: var(--panel-slider-fg) !important;
|
||||
transform: none !important;
|
||||
box-shadow: none !important;
|
||||
transition: box-shadow 140ms ease-out !important;
|
||||
}
|
||||
.panel-slider [data-slot="slider-thumb"]:hover,
|
||||
.panel-slider [data-slot="slider-thumb"]:focus-visible,
|
||||
.panel-slider:active [data-slot="slider-thumb"] {
|
||||
box-shadow: 0 0 0 10px
|
||||
color-mix(in srgb, var(--panel-slider-fg) 18%, transparent) !important;
|
||||
}
|
||||
/* Active press: slightly larger / more opaque ring than passive
|
||||
hover, so the haptic reads stronger when the user is actively
|
||||
manipulating the value. */
|
||||
.panel-slider:active [data-slot="slider-thumb"] {
|
||||
box-shadow: 0 0 0 12px
|
||||
color-mix(in srgb, var(--panel-slider-fg) 22%, transparent) !important;
|
||||
}
|
||||
|
||||
/* Inline numeric input — used for slider values and Context Length.
|
||||
Designed to read as *editable text* rather than a pill button so
|
||||
it doesn't mirror the Select/dropdown components on the panel.
|
||||
Default: transparent box, no border, no ring, sized by the
|
||||
`size` HTML attribute (each consumer picks 6 / 8 / etc. chars).
|
||||
Hover/focus: very light bg fade-in to signal editability — just
|
||||
enough to read as interactive, not enough to compete with the
|
||||
slider row's quiet aesthetic. */
|
||||
.panel-number-input {
|
||||
@apply h-7 rounded-md border-0 bg-transparent px-1.5 text-right text-[13px]! font-medium tabular-nums text-nav-fg shadow-none transition-colors hover:bg-black/[0.04] focus:bg-black/[0.05] focus-visible:ring-0! focus-visible:outline-none md:text-[13px]!;
|
||||
}
|
||||
.dark .panel-number-input {
|
||||
@apply hover:bg-white/[0.04] focus:bg-white/[0.06];
|
||||
}
|
||||
|
||||
/* Switch — unsloth-green track when active, slider-gray thumb in
|
||||
both states. Keeps the on-state recognizable as an "engaged"
|
||||
primary control while the moving thumb sits in the same neutral
|
||||
palette as the panel sliders, tying every control in the panel
|
||||
to a single gray family. Unchecked track keeps shadcn's default
|
||||
bg-input for the standard off affordance. */
|
||||
.panel-switch[data-state="checked"] {
|
||||
background-color: var(--primary) !important;
|
||||
}
|
||||
.panel-switch [data-slot="switch-thumb"] {
|
||||
background-color: var(--panel-slider-fg) !important;
|
||||
}
|
||||
.panel-switch[data-state="checked"] [data-slot="switch-thumb"] {
|
||||
background-color: #ffffff !important;
|
||||
}
|
||||
|
||||
/* Compact tooltip — small black pill with white text. Used for short
|
||||
hover labels on chat-area icon buttons (Copy, Edit, Delete,
|
||||
Refresh, More, code-block actions) and the panel's info-icon
|
||||
hints. Corner radius is fixed at 8px so it tracks with the
|
||||
underlying icon button corners (also 8px) — keeps the tooltip
|
||||
visually anchored to its trigger rather than reading as a much
|
||||
larger floating pill. */
|
||||
.tooltip-compact {
|
||||
@apply rounded-[10px] border-transparent bg-black px-2 py-1.5 text-[11px] font-medium leading-snug text-white shadow-md;
|
||||
}
|
||||
|
||||
/* Rich tooltip — used for the context-usage and token-counter
|
||||
popups (multi-row metric breakdowns). Same black surface as the
|
||||
compact tooltips so chat-area popovers feel like one family.
|
||||
Corner radius matches the user-profile dropdown in the left
|
||||
sidebar (14px) so panel-level menu surfaces share a single
|
||||
roundness. Uses the heading font with tracking for the structured
|
||||
content. Same in both themes. */
|
||||
.tooltip-rich {
|
||||
@apply rounded-[16px] border-transparent bg-black px-4 py-3 font-heading tracking-wide text-white shadow-[0_8px_28px_-6px_rgba(0,0,0,0.32)];
|
||||
}
|
||||
/* Row-label color override — the popups reuse the existing prose
|
||||
`text-muted-foreground` class. Fixed light gray on the black
|
||||
surface keeps the label clearly legible while staying distinct
|
||||
from the values (full white). */
|
||||
.tooltip-rich .text-muted-foreground {
|
||||
color: #b1b1b1 !important;
|
||||
}
|
||||
.tooltip-rich .border-border\/40 {
|
||||
border-color: rgb(255 255 255 / 0.12) !important;
|
||||
}
|
||||
|
||||
.app-user-menu [data-slot="dropdown-menu-item"] {
|
||||
height: 32px;
|
||||
padding: 0 0.625rem !important;
|
||||
gap: 8.5px !important;
|
||||
border-radius: 10px;
|
||||
font-weight: 500;
|
||||
font-size: 14.5px;
|
||||
line-height: 19px;
|
||||
letter-spacing: 0.015em;
|
||||
color: var(--nav-fg);
|
||||
}
|
||||
.dark .app-user-menu [data-slot="dropdown-menu-item"] {
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
.app-user-menu [data-slot="dropdown-menu-item"] svg {
|
||||
width: 19px !important;
|
||||
height: 19px !important;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.app-user-menu [data-slot="dropdown-menu-item"]:focus {
|
||||
background-color: var(--nav-surface-hover);
|
||||
color: #000;
|
||||
}
|
||||
.dark .app-user-menu [data-slot="dropdown-menu-item"]:focus {
|
||||
color: #fff;
|
||||
}
|
||||
.app-user-menu [data-slot="dropdown-menu-item"]:focus * {
|
||||
color: #000 !important;
|
||||
}
|
||||
.dark .app-user-menu [data-slot="dropdown-menu-item"]:focus * {
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
.menu-flat-destructive {
|
||||
--destructive: #dc4848;
|
||||
}
|
||||
.dark .menu-flat-destructive {
|
||||
--destructive: #ed7878;
|
||||
}
|
||||
.app-user-menu [data-slot="dropdown-menu-item"][data-variant="destructive"],
|
||||
.app-user-menu [data-slot="dropdown-menu-item"][data-variant="destructive"]:focus {
|
||||
color: var(--destructive);
|
||||
}
|
||||
.app-user-menu [data-slot="dropdown-menu-item"][data-variant="destructive"]:focus {
|
||||
background-color: color-mix(in oklab, var(--destructive) 10%, transparent);
|
||||
}
|
||||
.app-user-menu [data-slot="dropdown-menu-item"][data-variant="destructive"]:focus * {
|
||||
color: var(--destructive) !important;
|
||||
}
|
||||
|
||||
/* Elevated surface shadow (use ring-* for borders) */
|
||||
.shadow-border {
|
||||
--tw-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
|
||||
|
|
@ -374,7 +759,32 @@
|
|||
--tw-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.menu-soft-surface,
|
||||
.menu-soft-surface-up {
|
||||
--menu-soft-edge: rgba(0, 0, 0, 0.14);
|
||||
--menu-soft-shadow: rgba(0, 0, 0, 0.18);
|
||||
--menu-soft-offset-y: 8px;
|
||||
--menu-soft-blur: 28px;
|
||||
--menu-soft-spread: -6px;
|
||||
@apply bg-popover text-popover-foreground;
|
||||
box-shadow:
|
||||
inset 0 0 0 1px var(--menu-soft-edge),
|
||||
0 var(--menu-soft-offset-y) var(--menu-soft-blur)
|
||||
var(--menu-soft-spread) var(--menu-soft-shadow);
|
||||
}
|
||||
.menu-soft-surface-up {
|
||||
--menu-soft-offset-y: -6px;
|
||||
--menu-soft-spread: -8px;
|
||||
}
|
||||
.dark .menu-soft-surface,
|
||||
.dark .menu-soft-surface-up {
|
||||
--menu-soft-edge: rgba(255, 255, 255, 0.07);
|
||||
--menu-soft-shadow: rgba(0, 0, 0, 0.28);
|
||||
}
|
||||
|
||||
.chat-composer-surface {
|
||||
@apply relative flex w-full flex-col rounded-[24px] bg-background dark:bg-card px-1 pt-2 outline-none transition-shadow;
|
||||
font-family: var(--font-sans);
|
||||
border: 1px solid oklch(0.93 0 0 / 1);
|
||||
background-clip: padding-box;
|
||||
box-shadow:
|
||||
|
|
@ -384,8 +794,28 @@
|
|||
}
|
||||
|
||||
.dark .chat-composer-surface {
|
||||
border-color: #2e3035;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.2);
|
||||
border-color: #34363a;
|
||||
box-shadow: 0 -6px 36px -14px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.composer-pill-btn {
|
||||
@apply flex items-center gap-1.5 rounded-full px-3 py-1.5 text-[13px] font-medium text-muted-foreground/70 transition-colors hover:bg-primary/10 dark:hover:bg-white/[0.08] disabled:cursor-not-allowed disabled:opacity-40;
|
||||
}
|
||||
.composer-pill-btn[data-active="true"] {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.composer-input {
|
||||
@apply mb-1 min-h-12 w-full resize-none overflow-y-auto bg-transparent pl-5 pr-4 pt-2 pb-3 text-sm font-[450] outline-none placeholder:text-muted-foreground focus-visible:ring-0;
|
||||
}
|
||||
|
||||
.composer-action-wrapper {
|
||||
@apply relative mx-2 mb-2 flex items-center justify-between;
|
||||
}
|
||||
|
||||
.composer-footer-note {
|
||||
@apply mt-1.5 text-center text-[11px] tracking-[0.04em] text-muted-foreground;
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
/* Fine-tuning Studio: equal default height, expandable when needed (md+) */
|
||||
|
|
@ -426,6 +856,7 @@
|
|||
[data-streamdown="code-block"] {
|
||||
gap: 0.25rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 1.5rem;
|
||||
/* Wide lines must scroll inside the thread column, not widen past the composer (flex min-width:auto). */
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
|
|
@ -469,6 +900,45 @@
|
|||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
/* Normalize the trailing margin of the last element inside an
|
||||
assistant message so the gap above the action bar is the same
|
||||
regardless of whether the response ends with a paragraph
|
||||
(margin-bottom: 0 by Tailwind preflight) or a streamdown block
|
||||
like a code fence (margin-bottom: 1rem from `my-4`). Browser
|
||||
block layout doesn't collapse trailing margin into a sibling
|
||||
container, so we zero it explicitly along the deepest
|
||||
`:last-child` path. Streamdown wraps content in several
|
||||
nested divs, so code blocks land 4–5 levels deep — the chain
|
||||
walks that depth without using a generic descendant `:last-child`
|
||||
(which would also zero last-paragraph-in-list margins). The
|
||||
visible gap is then driven solely by the footer's own `mt-*`. */
|
||||
.aui-assistant-message-content > *:last-child,
|
||||
.aui-assistant-message-content > *:last-child > *:last-child,
|
||||
.aui-assistant-message-content > *:last-child > *:last-child > *:last-child,
|
||||
.aui-assistant-message-content > *:last-child > *:last-child > *:last-child > *:last-child,
|
||||
.aui-assistant-message-content > *:last-child > *:last-child > *:last-child > *:last-child > *:last-child,
|
||||
.aui-assistant-message-content > *:last-child > *:last-child > *:last-child > *:last-child > *:last-child > *:last-child {
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
/* The streamdown code-block wrapper carries `my-4` (16px top + 16px
|
||||
bottom margin). The bottom margin is what stretches the gap
|
||||
between the code-block box and the action-bar below it on a
|
||||
trailing code block. We zero `margin-bottom` on every code block
|
||||
inside an assistant message body. CSS margin collapsing handles
|
||||
the non-trailing case correctly: when a code block is followed by
|
||||
a paragraph (or any block with `my-4` mt), the rendered gap is
|
||||
`max(prev.mb, next.mt)` — so removing the code block's `mb` still
|
||||
leaves the next element's `mt-4` as the visible spacer. The only
|
||||
case actually affected is the trailing position (no next element),
|
||||
where `mb=0` collapses the gap to just the footer's `mt-2`,
|
||||
matching a text-trailing message. The wrapper's own
|
||||
`padding-bottom` is preserved, so the last line of code keeps its
|
||||
natural breathing room inside the box. */
|
||||
.aui-assistant-message-content [data-streamdown="code-block"] {
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
/* Keep monospace for code fences and inline code (not KaTeX). */
|
||||
.aui-thread-root [data-streamdown="code-block"] pre,
|
||||
.aui-thread-root [data-streamdown="code-block"] code {
|
||||
|
|
@ -491,7 +961,38 @@
|
|||
/* Streamdown `pre` uses `dark:bg-[var(--shiki-dark-bg,...)]`; keep one surface on the outer shell. */
|
||||
--shiki-dark-bg: transparent;
|
||||
background: var(--color-code-block);
|
||||
border: 1px solid oklch(1 0 0 / 0.07);
|
||||
border: 1px solid oklch(1 0 0 / 0.08);
|
||||
}
|
||||
|
||||
/* Streamdown code-block stability hardening.
|
||||
*
|
||||
* Two streamdown internals cause a visible "reload"-style flicker on
|
||||
* trailing code blocks the moment streaming ends. Both are addressable
|
||||
* purely in CSS without patching the library:
|
||||
*
|
||||
* 1. `content-visibility: auto` + `contain-intrinsic-size: auto 200px`
|
||||
* (set inline by streamdown's <ot> wrapper). The IntersectionObserver
|
||||
* that gates content-visibility flips rendered height between the
|
||||
* 200px placeholder and the actual code-block height as the block
|
||||
* sits near the viewport edge during stream finalization. The
|
||||
* height jump is small but visible, and the rendering optimization
|
||||
* is unnecessary for chat content (thread length is bounded). We
|
||||
* force `visible` to keep the rendered height of code blocks fully
|
||||
* determined by their actual content at all times.
|
||||
*
|
||||
* 2. `[data-sd-animate]` (`sd-fadeIn`, 150ms). Streamdown wraps each
|
||||
* streaming text segment in a span carrying this attribute. When
|
||||
* shiki re-renders the code body at stream end, those wrapper
|
||||
* spans get re-keyed and the fade animation replays across the
|
||||
* whole block at once — exactly the visual that reads as the chat
|
||||
* area "reloading for a frame." We disable the animation only
|
||||
* inside code blocks; prose token fade-in elsewhere is untouched. */
|
||||
.aui-thread-root [data-streamdown="code-block"] {
|
||||
content-visibility: visible !important;
|
||||
contain-intrinsic-size: none !important;
|
||||
}
|
||||
.aui-thread-root [data-streamdown="code-block"] [data-sd-animate] {
|
||||
animation: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -509,15 +1010,34 @@
|
|||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track-piece {
|
||||
background: transparent;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: oklch(0.5 0 0 / 0.54);
|
||||
border-radius: 9999px;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-corner {
|
||||
background: transparent;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-button {
|
||||
|
|
@ -539,20 +1059,51 @@
|
|||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
/* Marker class applied only to actual streaming-thread viewports
|
||||
(see ThreadPrimitive.Viewport in components/assistant-ui/thread.tsx).
|
||||
Scoped separately from `.aui-thread-viewport` because that class is
|
||||
reused elsewhere for shared scrollbar styling on non-streaming scroll
|
||||
areas; the stabilizer must only attach to viewports the
|
||||
useIntentAwareAutoScroll hook actually drives. */
|
||||
.aui-stream-viewport {
|
||||
/* Scroll stabilizer: compensates for transient scrollHeight shrinks
|
||||
(most visibly, shiki re-highlighting a trailing code block the
|
||||
instant streaming ends). The useIntentAwareAutoScroll hook sets
|
||||
this variable to the exact pixel amount of any content shrink
|
||||
observed while the follow window is active; that padding keeps
|
||||
scrollHeight monotonic, so the browser never auto-clamps scrollTop,
|
||||
so no jump is ever painted. Released back to 0 as content
|
||||
genuinely grows past its prior high-water mark, and on user
|
||||
detach so the bottom stays flush when they come back. */
|
||||
padding-bottom: var(--aui-scroll-stabilizer, 0px);
|
||||
}
|
||||
|
||||
.dark .aui-thread-viewport {
|
||||
scrollbar-color: oklch(0.67 0 0 / 0.5) var(--sidebar);
|
||||
scrollbar-color: oklch(0.72 0 0 / 0.25) #23252a;
|
||||
}
|
||||
|
||||
.dark .aui-thread-viewport::-webkit-scrollbar-thumb {
|
||||
background: oklch(0.72 0 0 / 0.25);
|
||||
}
|
||||
|
||||
.aui-thread-viewport::-webkit-scrollbar-track {
|
||||
background: var(--sidebar);
|
||||
}
|
||||
|
||||
.dark .aui-thread-viewport::-webkit-scrollbar-track {
|
||||
background: #23252a;
|
||||
}
|
||||
|
||||
[data-sidebar="content"] {
|
||||
scrollbar-color: oklch(0.5 0 0 / 0.22) transparent;
|
||||
scrollbar-color: oklch(0.5 0 0 / 0.22) var(--sidebar);
|
||||
}
|
||||
|
||||
.dark [data-sidebar="content"] {
|
||||
scrollbar-color: oklch(0.72 0 0 / 0.25) transparent;
|
||||
scrollbar-color: oklch(0.72 0 0 / 0.25) var(--sidebar);
|
||||
}
|
||||
|
||||
[data-sidebar="content"]::-webkit-scrollbar-track {
|
||||
background: var(--sidebar);
|
||||
}
|
||||
|
||||
[data-sidebar="content"]::-webkit-scrollbar-thumb {
|
||||
|
|
|
|||
421
tests/studio/install/smoke_test_parallel_studio_home.py
Normal file
|
|
@ -0,0 +1,421 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Smoke test: N parallel install.sh runs with distinct UNSLOTH_STUDIO_HOME
|
||||
values must produce N fully isolated installs whose backends can run
|
||||
side by side without clashing.
|
||||
|
||||
Covers the env-override path added in #5190:
|
||||
|
||||
install-time
|
||||
* N concurrent ``install.sh --local --no-torch`` runs against
|
||||
this checkout, each pinned to its own UNSLOTH_STUDIO_HOME and
|
||||
a redirected HOME, all exit 0.
|
||||
* Each STUDIO_HOME contains its own bin/, share/, llama.cpp/
|
||||
and unsloth_studio/ venv, with no cross-install absolute
|
||||
paths.
|
||||
* share/studio_install_id is unique across the N installs.
|
||||
* share/studio.conf exports UNSLOTH_EXE, UNSLOTH_STUDIO_HOME
|
||||
and UNSLOTH_LLAMA_CPP_PATH, all pointing inside this install.
|
||||
* share/launch-studio.sh has @@DATA_DIR@@ substituted to its
|
||||
own share/ at install time.
|
||||
* bin/unsloth is a symlink that resolves into its own venv.
|
||||
* The redirected HOME is left clean: no shell-rc append, no
|
||||
.desktop file, no Studio.app stub, no shared marker.
|
||||
|
||||
runtime
|
||||
* N concurrent ``bin/unsloth studio`` launches each bind their
|
||||
own dynamically allocated free port and stay healthy.
|
||||
* /api/health is 200, status is healthy, chat_only is true
|
||||
under --no-torch.
|
||||
* The studio_root_id reported by /api/health on each backend
|
||||
equals that install's share/studio_install_id, so the
|
||||
runtime resolver agrees with the install-time write.
|
||||
* studio_root_id values are pairwise distinct.
|
||||
* GET / and GET /api/chat are 200 on every backend.
|
||||
* The Python interpreter behind each PID is the install's own
|
||||
venv python (the bin/unsloth shim does not cross-resolve).
|
||||
|
||||
This is an integration smoke runner, not a pytest unit test. It does
|
||||
real installs (~1 minute end to end on a warm uv cache) and is meant
|
||||
to be invoked explicitly:
|
||||
|
||||
python tests/studio/install/smoke_test_parallel_studio_home.py
|
||||
python tests/studio/install/smoke_test_parallel_studio_home.py --n 6 --keep
|
||||
|
||||
Exits 0 on PASS, 1 on FAIL, 2 on infrastructure error. Artifacts land
|
||||
under a temporary directory and are removed on PASS unless --keep is
|
||||
set; on FAIL or ERROR they are kept regardless so logs can be
|
||||
inspected.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
PACKAGE_ROOT = Path(__file__).resolve().parents[3]
|
||||
INSTALL_TIMEOUT_S = 600
|
||||
HEALTH_TIMEOUT_S = 120
|
||||
HEALTH_POLL_INTERVAL_S = 1.0
|
||||
|
||||
|
||||
class TestFailure(AssertionError):
|
||||
pass
|
||||
|
||||
|
||||
def _log(msg: str) -> None:
|
||||
ts = datetime.now().strftime("%H:%M:%S")
|
||||
print(f"[smoke {ts}] {msg}", flush = True)
|
||||
|
||||
|
||||
def _free_port() -> int:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
s.bind(("127.0.0.1", 0))
|
||||
return s.getsockname()[1]
|
||||
|
||||
|
||||
def _run_one_install(
|
||||
label: str,
|
||||
repo: Path,
|
||||
studio_home: Path,
|
||||
fake_home: Path,
|
||||
uv_cache: Path,
|
||||
log_path: Path,
|
||||
) -> tuple[str, int]:
|
||||
studio_home.mkdir(parents = True, exist_ok = True)
|
||||
fake_home.mkdir(parents = True, exist_ok = True)
|
||||
uv_cache.mkdir(parents = True, exist_ok = True)
|
||||
log_path.parent.mkdir(parents = True, exist_ok = True)
|
||||
env = os.environ.copy()
|
||||
env["HOME"] = str(fake_home)
|
||||
env["UNSLOTH_STUDIO_HOME"] = str(studio_home)
|
||||
env["UV_CACHE_DIR"] = str(uv_cache)
|
||||
env["NO_COLOR"] = "1"
|
||||
with log_path.open("w") as fh:
|
||||
proc = subprocess.run(
|
||||
["bash", "install.sh", "--local", "--no-torch"],
|
||||
cwd = str(repo),
|
||||
env = env,
|
||||
stdout = fh,
|
||||
stderr = subprocess.STDOUT,
|
||||
timeout = INSTALL_TIMEOUT_S,
|
||||
)
|
||||
return label, proc.returncode
|
||||
|
||||
|
||||
def _launch_backend(
|
||||
studio_home: Path, fake_home: Path, port: int, log_path: Path
|
||||
) -> subprocess.Popen:
|
||||
log_path.parent.mkdir(parents = True, exist_ok = True)
|
||||
env = os.environ.copy()
|
||||
env["HOME"] = str(fake_home)
|
||||
# Pin UNSLOTH_STUDIO_HOME (and clear the alias) so the child cannot
|
||||
# inherit a Studio root from the caller's shell. Without this, a shell
|
||||
# that already exports either var would override the per-label sys.prefix
|
||||
# inference and every backend would resolve to the caller's install.
|
||||
env["UNSLOTH_STUDIO_HOME"] = str(studio_home)
|
||||
env.pop("STUDIO_HOME", None)
|
||||
# The child process inherits a dup of stdout via Popen, so closing the
|
||||
# parent's handle when this function returns is safe and avoids relying
|
||||
# on GC timing to release the fd.
|
||||
with log_path.open("w") as fh:
|
||||
return subprocess.Popen(
|
||||
[
|
||||
str(studio_home / "bin" / "unsloth"),
|
||||
"studio",
|
||||
"-H",
|
||||
"127.0.0.1",
|
||||
"-p",
|
||||
str(port),
|
||||
"--silent",
|
||||
],
|
||||
env = env,
|
||||
stdout = fh,
|
||||
stderr = subprocess.STDOUT,
|
||||
start_new_session = True,
|
||||
)
|
||||
|
||||
|
||||
def _wait_for_health(port: int, timeout: float) -> dict:
|
||||
deadline = time.time() + timeout
|
||||
last_err: Exception | None = None
|
||||
url = f"http://127.0.0.1:{port}/api/health"
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout = 2) as r:
|
||||
if r.status == 200:
|
||||
return json.loads(r.read().decode())
|
||||
except (urllib.error.URLError, ConnectionError, OSError) as e:
|
||||
last_err = e
|
||||
time.sleep(HEALTH_POLL_INTERVAL_S)
|
||||
raise TestFailure(
|
||||
f"port {port}: /api/health never returned 200 (last_err={last_err})"
|
||||
)
|
||||
|
||||
|
||||
def _http_status(port: int, path: str, timeout: float = 5.0) -> int:
|
||||
url = f"http://127.0.0.1:{port}{path}"
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout = timeout) as r:
|
||||
return r.status
|
||||
except urllib.error.HTTPError as e:
|
||||
return e.code
|
||||
|
||||
|
||||
def _check_install_layout(label: str, studio_home: Path) -> dict:
|
||||
for d in ("bin", "share", "llama.cpp", "unsloth_studio"):
|
||||
if not (studio_home / d).is_dir():
|
||||
raise TestFailure(f"[{label}] missing {studio_home / d}")
|
||||
|
||||
shim = studio_home / "bin" / "unsloth"
|
||||
if not shim.is_symlink():
|
||||
raise TestFailure(f"[{label}] {shim} is not a symlink")
|
||||
expected_target = (studio_home / "unsloth_studio" / "bin" / "unsloth").resolve()
|
||||
if shim.resolve() != expected_target:
|
||||
raise TestFailure(
|
||||
f"[{label}] shim resolves to {shim.resolve()}, expected {expected_target}"
|
||||
)
|
||||
|
||||
install_id_path = studio_home / "share" / "studio_install_id"
|
||||
if not install_id_path.is_file():
|
||||
raise TestFailure(f"[{label}] missing {install_id_path}")
|
||||
install_id = install_id_path.read_text().strip()
|
||||
if len(install_id) < 32:
|
||||
raise TestFailure(f"[{label}] studio_install_id too short: {install_id!r}")
|
||||
|
||||
conf = (studio_home / "share" / "studio.conf").read_text()
|
||||
must_contain = [
|
||||
f"UNSLOTH_EXE='{studio_home}/unsloth_studio/bin/unsloth'",
|
||||
f"export UNSLOTH_STUDIO_HOME='{studio_home}'",
|
||||
f"export UNSLOTH_LLAMA_CPP_PATH='{studio_home}/llama.cpp'",
|
||||
]
|
||||
for needle in must_contain:
|
||||
if needle not in conf:
|
||||
raise TestFailure(
|
||||
f"[{label}] studio.conf missing line:\n {needle}\n" f"actual:\n{conf}"
|
||||
)
|
||||
|
||||
launcher = (studio_home / "share" / "launch-studio.sh").read_text()
|
||||
if "@@DATA_DIR@@" in launcher:
|
||||
raise TestFailure(f"[{label}] launch-studio.sh kept @@DATA_DIR@@ placeholder")
|
||||
expected_data_dir_line = f"DATA_DIR='{studio_home}/share'"
|
||||
if expected_data_dir_line not in launcher:
|
||||
raise TestFailure(
|
||||
f"[{label}] launch-studio.sh missing {expected_data_dir_line!r}"
|
||||
)
|
||||
|
||||
return {"label": label, "studio_home": str(studio_home), "install_id": install_id}
|
||||
|
||||
|
||||
def _check_fake_home_clean(fake_home: Path) -> None:
|
||||
forbidden = [
|
||||
".bashrc",
|
||||
".zshrc",
|
||||
".profile",
|
||||
".unsloth",
|
||||
Path(".local") / "share" / "applications" / "unsloth-studio.desktop",
|
||||
Path("Desktop") / "unsloth-studio.desktop",
|
||||
Path("Applications") / "Unsloth Studio.app",
|
||||
]
|
||||
leaked = [str(p) for p in forbidden if (fake_home / p).exists()]
|
||||
if leaked:
|
||||
raise TestFailure(
|
||||
f"redirected HOME picked up persistent install pollution: {leaked}"
|
||||
)
|
||||
|
||||
|
||||
def _backend_pid_python(pid: int) -> Path | None:
|
||||
"""Resolve the binary backing a running PID. Linux exposes this at
|
||||
/proc/PID/exe; on platforms without /proc (macOS, BSD, Windows) we
|
||||
skip this check and rely on the install-time symlink + studio.conf
|
||||
invariants to catch cross-resolution. Returns None when /proc is
|
||||
unavailable so the caller can skip cleanly."""
|
||||
if sys.platform != "linux":
|
||||
return None
|
||||
proc_exe = Path(f"/proc/{pid}/exe")
|
||||
if not proc_exe.exists():
|
||||
return None
|
||||
return proc_exe.resolve()
|
||||
|
||||
|
||||
def run(n_installs: int, keep: bool) -> int:
|
||||
if n_installs < 2:
|
||||
raise TestFailure("--n must be >= 2 to test for clashes")
|
||||
labels = [chr(ord("a") + i) for i in range(n_installs)]
|
||||
|
||||
repo = PACKAGE_ROOT
|
||||
if not (repo / "install.sh").is_file():
|
||||
raise TestFailure(
|
||||
f"install.sh not found at {repo}; " "run from a clone of unslothai/unsloth"
|
||||
)
|
||||
|
||||
test_root = Path(tempfile.mkdtemp(prefix = "unsloth_studio_clash_"))
|
||||
_log(f"test root: {test_root}")
|
||||
_log(f"repo: {repo}")
|
||||
|
||||
backends: list[tuple[str, Path, Path, int, subprocess.Popen]] = []
|
||||
failed = False
|
||||
try:
|
||||
# ---- parallel installs --------------------------------------------
|
||||
_log(f"launching {n_installs} parallel installs (--local --no-torch)")
|
||||
with ThreadPoolExecutor(max_workers = n_installs) as pool:
|
||||
futures = []
|
||||
for label in labels:
|
||||
futures.append(
|
||||
pool.submit(
|
||||
_run_one_install,
|
||||
label,
|
||||
repo,
|
||||
test_root / "installs" / label,
|
||||
test_root / "fake_homes" / label,
|
||||
test_root / "uv_caches" / label,
|
||||
test_root / "logs" / f"install_{label}.log",
|
||||
)
|
||||
)
|
||||
for fut in as_completed(futures):
|
||||
label, rc = fut.result()
|
||||
_log(f" install {label}: exit {rc}")
|
||||
if rc != 0:
|
||||
raise TestFailure(
|
||||
f"install {label} failed (rc={rc}); see "
|
||||
f"{test_root / 'logs' / f'install_{label}.log'}"
|
||||
)
|
||||
|
||||
# ---- install-layout invariants ------------------------------------
|
||||
_log("verifying install-time invariants")
|
||||
observed = []
|
||||
for label in labels:
|
||||
studio_home = test_root / "installs" / label
|
||||
obs = _check_install_layout(label, studio_home)
|
||||
observed.append(obs)
|
||||
_check_fake_home_clean(test_root / "fake_homes" / label)
|
||||
ids = [o["install_id"] for o in observed]
|
||||
if len(set(ids)) != len(ids):
|
||||
raise TestFailure(f"studio_install_id collision: {ids}")
|
||||
_log(f" {len(ids)} unique studio_install_ids, all redirected HOMEs clean")
|
||||
|
||||
# ---- parallel backend launches ------------------------------------
|
||||
_log(f"launching {n_installs} backends in parallel")
|
||||
for label in labels:
|
||||
port = _free_port()
|
||||
studio_home = test_root / "installs" / label
|
||||
fake_home = test_root / "fake_homes" / label
|
||||
log_path = test_root / "logs" / f"run_{label}.log"
|
||||
proc = _launch_backend(studio_home, fake_home, port, log_path)
|
||||
backends.append((label, studio_home, fake_home, port, proc))
|
||||
_log(f" {label} -> port {port} (pid {proc.pid})")
|
||||
|
||||
# ---- wait for health ----------------------------------------------
|
||||
_log("waiting for /api/health on each backend")
|
||||
health_payloads: dict[str, dict] = {}
|
||||
with ThreadPoolExecutor(max_workers = n_installs) as pool:
|
||||
fut_to_label = {
|
||||
pool.submit(_wait_for_health, port, HEALTH_TIMEOUT_S): label
|
||||
for (label, _sh, _fh, port, _p) in backends
|
||||
}
|
||||
for fut in as_completed(fut_to_label):
|
||||
label = fut_to_label[fut]
|
||||
health_payloads[label] = fut.result()
|
||||
_log(f" {label}: healthy")
|
||||
|
||||
# ---- runtime invariants -------------------------------------------
|
||||
_log("checking runtime invariants")
|
||||
seen_root_ids: set[str] = set()
|
||||
for (label, studio_home, _fh, port, proc), obs in zip(backends, observed):
|
||||
health = health_payloads[label]
|
||||
if health.get("status") != "healthy":
|
||||
raise TestFailure(f"[{label}] health status != healthy: {health}")
|
||||
if health.get("studio_root_id") != obs["install_id"]:
|
||||
raise TestFailure(
|
||||
f"[{label}] runtime studio_root_id "
|
||||
f"{health.get('studio_root_id')!r} != install_id "
|
||||
f"{obs['install_id']!r}"
|
||||
)
|
||||
if not health.get("chat_only"):
|
||||
raise TestFailure(f"[{label}] chat_only is not true under --no-torch")
|
||||
if health["studio_root_id"] in seen_root_ids:
|
||||
raise TestFailure(
|
||||
f"[{label}] studio_root_id collision at runtime: "
|
||||
f"{health['studio_root_id']}"
|
||||
)
|
||||
seen_root_ids.add(health["studio_root_id"])
|
||||
|
||||
for path in ("/", "/api/chat"):
|
||||
code = _http_status(port, path)
|
||||
if code != 200:
|
||||
raise TestFailure(f"[{label}] GET {path} -> {code}")
|
||||
|
||||
exe = _backend_pid_python(proc.pid)
|
||||
if exe is not None:
|
||||
expected_python = (
|
||||
studio_home / "unsloth_studio" / "bin" / "python"
|
||||
).resolve()
|
||||
if exe != expected_python:
|
||||
raise TestFailure(
|
||||
f"[{label}] PID {proc.pid} exe={exe}, expected {expected_python}"
|
||||
)
|
||||
|
||||
versions = {h.get("version") for h in health_payloads.values()}
|
||||
if len(versions) != 1:
|
||||
raise TestFailure(f"version mismatch across installs: {versions}")
|
||||
|
||||
_log(
|
||||
f"PASS: all install + runtime invariants hold "
|
||||
f"(version={next(iter(versions))})"
|
||||
)
|
||||
return 0
|
||||
|
||||
except TestFailure as e:
|
||||
_log(f"FAIL: {e}")
|
||||
failed = True
|
||||
return 1
|
||||
except Exception as e:
|
||||
_log(f"ERROR: {type(e).__name__}: {e}")
|
||||
failed = True
|
||||
return 2
|
||||
finally:
|
||||
for _lbl, _sh, _fh, _port, proc in backends:
|
||||
if proc.poll() is None:
|
||||
try:
|
||||
proc.terminate()
|
||||
proc.wait(timeout = 10)
|
||||
except Exception:
|
||||
proc.kill()
|
||||
|
||||
if keep or failed:
|
||||
_log(f"artifacts kept at {test_root}")
|
||||
else:
|
||||
shutil.rmtree(test_root, ignore_errors = True)
|
||||
_log(f"cleaned up {test_root}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description = __doc__)
|
||||
ap.add_argument(
|
||||
"--n",
|
||||
type = int,
|
||||
default = 4,
|
||||
help = "number of parallel installs (default 4, must be >= 2)",
|
||||
)
|
||||
ap.add_argument(
|
||||
"--keep",
|
||||
action = "store_true",
|
||||
help = "leave the temp test root on disk even on PASS",
|
||||
)
|
||||
args = ap.parse_args()
|
||||
return run(args.n, args.keep)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -8,8 +8,19 @@ from pathlib import Path
|
|||
import pytest
|
||||
|
||||
WORKDIR = Path(__file__).resolve().parents[2]
|
||||
PRESET_POLICY = WORKDIR / "studio/frontend/src/features/chat/presets/preset-policy.ts"
|
||||
RUNTIME_TYPES = WORKDIR / "studio/frontend/src/features/chat/types/runtime.ts"
|
||||
|
||||
|
||||
def _source_path(relative_path: str) -> Path:
|
||||
direct = WORKDIR / relative_path
|
||||
if direct.exists():
|
||||
return direct
|
||||
return WORKDIR / "unsloth_repo" / relative_path
|
||||
|
||||
|
||||
PRESET_POLICY = _source_path(
|
||||
"studio/frontend/src/features/chat/presets/preset-policy.ts"
|
||||
)
|
||||
RUNTIME_TYPES = _source_path("studio/frontend/src/features/chat/types/runtime.ts")
|
||||
TEMP = WORKDIR / "temp" / "chat_preset_builtin_invariants"
|
||||
|
||||
|
||||
|
|
@ -18,6 +29,14 @@ def _require_node():
|
|||
pytest.skip("node not available")
|
||||
if not PRESET_POLICY.exists() or not RUNTIME_TYPES.exists():
|
||||
pytest.skip("studio chat sources not present")
|
||||
result = subprocess.run(
|
||||
["node", "--experimental-strip-types", "--version"],
|
||||
capture_output = True,
|
||||
text = True,
|
||||
timeout = 5,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
pytest.skip("node --experimental-strip-types not available")
|
||||
|
||||
|
||||
def _ensure_harness():
|
||||
|
|
@ -213,8 +232,20 @@ def test_apply_preset_params_preserves_model_owned_fields():
|
|||
textwrap.dedent(
|
||||
f"""
|
||||
// @ts-nocheck
|
||||
import {{ BUILTIN_PRESETS, applyPresetParams }} from "{_policy_path()}";
|
||||
const creative = BUILTIN_PRESETS.find((p) => p.name === "Creative");
|
||||
import {{ applyPresetParams }} from "{_policy_path()}";
|
||||
const samplingPreset = {{
|
||||
temperature: 1.5,
|
||||
topP: 1,
|
||||
topK: 0,
|
||||
minP: 0.1,
|
||||
repetitionPenalty: 1,
|
||||
presencePenalty: 0,
|
||||
maxSeqLength: 4096,
|
||||
maxTokens: 2048,
|
||||
systemPrompt: "",
|
||||
checkpoint: "",
|
||||
trustRemoteCode: false,
|
||||
}};
|
||||
const applied = applyPresetParams(
|
||||
{{
|
||||
temperature: 0.6,
|
||||
|
|
@ -229,7 +260,7 @@ def test_apply_preset_params_preserves_model_owned_fields():
|
|||
checkpoint: "foo/bar",
|
||||
trustRemoteCode: true,
|
||||
}},
|
||||
creative.params,
|
||||
samplingPreset,
|
||||
);
|
||||
console.log(JSON.stringify({{
|
||||
checkpoint: applied.checkpoint,
|
||||
|
|
@ -248,21 +279,16 @@ def test_apply_preset_params_preserves_model_owned_fields():
|
|||
assert out["topK"] == 0
|
||||
|
||||
|
||||
def test_creative_and_precise_builtins_differ_from_default():
|
||||
def test_default_is_only_builtin_preset():
|
||||
out = _run(
|
||||
textwrap.dedent(
|
||||
f"""
|
||||
// @ts-nocheck
|
||||
import {{ BUILTIN_PRESETS, isSamePresetConfig }} from "{_policy_path()}";
|
||||
const def = BUILTIN_PRESETS.find((p) => p.name === "Default");
|
||||
const creative = BUILTIN_PRESETS.find((p) => p.name === "Creative");
|
||||
const precise = BUILTIN_PRESETS.find((p) => p.name === "Precise");
|
||||
import {{ BUILTIN_PRESETS }} from "{_policy_path()}";
|
||||
console.log(JSON.stringify({{
|
||||
creativeDiffers: !isSamePresetConfig(def.params, creative.params),
|
||||
preciseDiffers: !isSamePresetConfig(def.params, precise.params),
|
||||
names: BUILTIN_PRESETS.map((p) => p.name),
|
||||
}}));
|
||||
"""
|
||||
)
|
||||
)
|
||||
assert out["creativeDiffers"] is True
|
||||
assert out["preciseDiffers"] is True
|
||||
assert out["names"] == ["Default"]
|
||||
|
|
|
|||