diff --git a/COPYING b/COPYING index cb7331bd35..4213fd700d 100644 --- a/COPYING +++ b/COPYING @@ -661,4 +661,4 @@ For more information on this, and how to apply and follow the GNU AGPL, see . Files under unsloth/*, tests/*, scripts/* are Apache 2.0 licensed. -Files under studio/*, cli/* which is optional to install are AGPLv3 licensed. \ No newline at end of file +Files under studio/*, unsloth_cli/* which is optional to install are AGPLv3 licensed. \ No newline at end of file diff --git a/LICENSE b/LICENSE index 8c3c43e374..e1600b0c03 100644 --- a/LICENSE +++ b/LICENSE @@ -188,7 +188,7 @@ Copyright [2024-] [Unsloth AI. Inc team, Daniel Han-Chen & Michael Han-Chen] Files under unsloth/*, tests/*, scripts/* are Apache 2.0 licensed. - Files under studio/*, cli/* which is optional to install are AGPLv3 licensed. + Files under studio/*, unsloth_cli/* which is optional to install are AGPLv3 licensed. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/README.md b/README.md index 16ec872b64..12c12d58aa 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,8 @@ Run and train AI models with a unified local interface. Documentation β€’ Discord

-unsloth studio ui homepage + +unsloth studio ui homepage Unsloth Studio lets you run and train models for text, [audio](https://unsloth.ai/docs/basics/text-to-speech-tts-fine-tuning), [embedding](https://unsloth.ai/docs/new/embedding-finetuning), [vision](https://unsloth.ai/docs/basics/vision-fine-tuning) and more. Available on Windows, Linux and macOS. ## ⭐ Features @@ -43,38 +44,100 @@ Unsloth can be used in two ways: through **[Unsloth Studio](https://unsloth.ai/d Unsloth Studio works on **Windows, Linux, WSL** and **macOS**. * **CPU:** Supported for **chat inference only** -* **NVIDIA GPUs:** Training works on RTX 30/40/50, Blackwell, DGX Spark, DGX Station and more +* **NVIDIA:** Training works on RTX 30/40/50, Blackwell, DGX Spark, Station and more * **macOS:** Currently supports chat only; **MLX training** is coming very soon +* **AMD:** Chat works. Train with [Unsloth Core](#unsloth-core-code-based). Studio support is coming soon. +* **Coming soon:** Training support for Apple MLX, AMD, and Intel. * **Multi-GPU:** Available now, with a major upgrade on the way -#### Windows, MacOS Linux or WSL: -``` -git clone https://github.com/unslothai/unsloth.git -cd unsloth -pip install -e . +#### MacOS, Linux or WSL Setup (One time): +```bash +pip install -U pip uv +uv venv unsloth_studio +source unsloth_studio/bin/activate +uv pip install unsloth --torch-backend=auto unsloth studio setup unsloth studio -H 0.0.0.0 -p 8888 ``` -Use our [Docker image](https://hub.docker.com/r/unsloth/unsloth) ```unsloth/unsloth``` container. Read our [Docker Guide](https://unsloth.ai/docs/get-started/install/docker). -#### Git from source +Then to launch every time: +```bash +source unsloth_studio/bin/activate +unsloth studio -H 0.0.0.0 -p 8888 ``` -git clone https://github.com/unslothai/unsloth.git -cd unsloth -pip install -e . + +#### Windows PowerShell (One time): +```bash +pip install -U pip uv +uv venv unsloth_studio +.\unsloth_studio\Scripts\activate +uv pip install unsloth --torch-backend=auto unsloth studio setup unsloth studio -H 0.0.0.0 -p 8888 ``` +Then to launch every time: +```bash +.\unsloth_studio\Scripts\activate +unsloth studio -H 0.0.0.0 -p 8888 +``` + +Use our [Docker image](https://hub.docker.com/r/unsloth/unsloth) ```unsloth/unsloth``` container. Read our [Docker Guide](https://unsloth.ai/docs/get-started/install/docker). + +#### Nightly Installation - MacOS, Linux or WSL Setup (One time): +```bash +pip install -U pip uv +git clone --filter=blob:none https://github.com/unslothai/unsloth.git unsloth_studio +cd unsloth_studio +uv venv +source .venv/bin/activate +uv pip install -e . --torch-backend=auto +unsloth studio setup +unsloth studio -H 0.0.0.0 -p 8888 +``` +Then to launch every time: +```bash +cd unsloth_studio +source .venv/bin/activate +unsloth studio -H 0.0.0.0 -p 8888 +``` + +#### Nightly Installation - Windows Powershell (One time): +```bash +pip install -U pip uv +git clone --filter=blob:none https://github.com/unslothai/unsloth.git unsloth_studio +cd unsloth_studio +uv venv +.\.venv\Scripts\activate +uv pip install -e . --torch-backend=auto +unsloth studio setup +unsloth studio -H 0.0.0.0 -p 8888 +``` +Then to launch every time: +```bash +cd unsloth_studio +.\.venv\Scripts\activate +unsloth studio -H 0.0.0.0 -p 8888 +``` ### Unsloth Core (code-based) -#### Windows, Linux, WSL +#### Linux, WSL ```bash -pip install unsloth +pip install -U pip uv +uv venv unsloth_env +source unsloth_env/bin/activate +uv pip install unsloth --torch-backend=auto +``` +#### Windows Powershell +```bash +pip install -U pip uv +uv venv unsloth_env +.\unsloth_env\Scripts\activate +uv pip install unsloth --torch-backend=auto ``` For Windows, `pip install unsloth` works only if you have Pytorch installed. Read our [Windows Guide](https://unsloth.ai/docs/get-started/install/windows-installation). You can use the same Docker image as Unsloth Studio. #### AMD, Intel -For RTX 50x, B200, 6000 GPUs: `pip install unsloth`. Read our guides for: [Blackwell](https://unsloth.ai/docs/blog/fine-tuning-llms-with-blackwell-rtx-50-series-and-unsloth) and [DGX Spark](https://unsloth.ai/docs/blog/fine-tuning-llms-with-nvidia-dgx-spark-and-unsloth).
+For RTX 50x, B200, 6000 GPUs: `uv pip install unsloth --torch-backend=auto`. Read our guides for: [Blackwell](https://unsloth.ai/docs/blog/fine-tuning-llms-with-blackwell-rtx-50-series-and-unsloth) and [DGX Spark](https://unsloth.ai/docs/blog/fine-tuning-llms-with-nvidia-dgx-spark-and-unsloth).
To install Unsloth on **AMD** and **Intel** GPUs, follow our [AMD Guide](https://unsloth.ai/docs/get-started/install/amd) and [Intel Guide](https://unsloth.ai/docs/get-started/install/intel). ## ✨ Free Notebooks @@ -132,6 +195,11 @@ You can cite the Unsloth repo as follows: ``` If you trained a model with πŸ¦₯Unsloth, you can use this cool sticker! Β  +### License +Unsloth uses a dual-licensing model of Apache 2.0 and AGPL-3.0. The core Unsloth package remains licensed under **[Apache 2.0](https://github.com/unslothai/unsloth?tab=Apache-2.0-1-ov-file)**, while certain optional components, such as the Unsloth Studio UI are licensed under the open-source license **[AGPL-3.0](https://github.com/unslothai/unsloth?tab=AGPL-3.0-2-ov-file)**. + +This structure helps support ongoing Unsloth development while keeping the project open source and enabling the broader ecosystem to continue growing. + ### Thank You to - The [llama.cpp library](https://github.com/ggml-org/llama.cpp) that lets users run and save models with Unsloth - The Hugging Face team and their libraries: [transformers](https://github.com/huggingface/transformers) and [TRL](https://github.com/huggingface/trl) diff --git a/cli.py b/cli.py index 0965b424e8..534babed2c 100644 --- a/cli.py +++ b/cli.py @@ -1,7 +1,7 @@ # SPDX-License-Identifier: AGPL-3.0-only # Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 -from cli import app +from unsloth_cli import app if __name__ == "__main__": app() diff --git a/pyproject.toml b/pyproject.toml index 5a75495c56..eaa7d84f71 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -55,7 +55,7 @@ dependencies = [ ] [project.scripts] -unsloth = "cli:app" +unsloth = "unsloth_cli:app" [tool.setuptools.dynamic] version = {attr = "unsloth.models._utils.__version__"} @@ -737,6 +737,6 @@ cu130-ampere-torch2100 = [ ] [project.urls] -homepage = "http://www.unsloth.ai" -documentation = "https://github.com/unslothai/unsloth" +homepage = "https://unsloth.ai" +documentation = "https://unsloth.ai/docs" repository = "https://github.com/unslothai/unsloth" diff --git a/studio/Unsloth_Studio_Colab.ipynb b/studio/Unsloth_Studio_Colab.ipynb index 0338fc3a47..c6a5378b85 100644 --- a/studio/Unsloth_Studio_Colab.ipynb +++ b/studio/Unsloth_Studio_Colab.ipynb @@ -15,11 +15,14 @@ "To install Unsloth Studio on your local device, follow [our guide](https://unsloth.ai/docs/new/unsloth-studio/install). Unsloth Studio is licensed [AGPL-3.0](https://github.com/unslothai/unsloth/blob/main/studio/LICENSE.AGPL-3.0).\n", "\n", "### Unsloth Studio\n", - "\n", - "Train and run open models with [**Unsloth Studio**](https://unsloth.ai/docs/new/unsloth-studio/start). Installation may take 10 mins.\n", - "\n", - "[Features](https://unsloth.ai/docs/new/unsloth-studio#features) β€’ [Quickstart](https://unsloth.ai/docs/new/unsloth-studio/start) β€’ [Data Recipes](https://unsloth.ai/docs/new/unsloth-studio/data-recipe) β€’ [Studio Chat](https://unsloth.ai/docs/new/unsloth-studio/chat) β€’ [Export](https://unsloth.ai/docs/new/unsloth-studio/export)" - ] + "\n", + "Train and run open models with [**Unsloth Studio**](https://unsloth.ai/docs/new/unsloth-studio/start). Currently, installation may take 30+ mins so use a newer GPU.\n", + "\n", + "\n", + "We are actively working on making Unsloth Studio install on Colab T4 GPUs faster.\n", + "\n", + "[Features](https://unsloth.ai/docs/new/unsloth-studio#features) β€’ [Quickstart](https://unsloth.ai/docs/new/unsloth-studio/start) β€’ [Data Recipes](https://unsloth.ai/docs/new/unsloth-studio/data-recipe) β€’ [Studio Chat](https://unsloth.ai/docs/new/unsloth-studio/chat) β€’ [Export](https://unsloth.ai/docs/new/unsloth-studio/export)" + ] }, { "cell_type": "markdown", diff --git a/studio/backend/colab.py b/studio/backend/colab.py index 7162b6d4c2..f2f56b7f17 100644 --- a/studio/backend/colab.py +++ b/studio/backend/colab.py @@ -9,6 +9,32 @@ Uses Colab's built-in proxy - no external tunneling needed! from pathlib import Path import sys + +def _bootstrap_studio_venv() -> None: + """Expose the Studio venv's site-packages to the current interpreter. + + On Colab, notebook cells run outside the venv subshell. Instead of + installing the full stack into system Python, we prepend the venv's + site-packages so that packages like structlog, fastapi, etc. are + importable from notebook cells and take priority over system copies. + """ + venv_lib = Path.home() / ".unsloth" / "studio" / ".venv" / "lib" + if not venv_lib.exists(): + import warnings + + warnings.warn( + f"Studio venv not found at {venv_lib.parent} -- run 'unsloth studio setup' first", + stacklevel = 2, + ) + return + for sp in venv_lib.glob("python*/site-packages"): + sp_str = str(sp) + if sp_str not in sys.path: + sys.path.insert(0, sp_str) + + +_bootstrap_studio_venv() + # Add backend to path early so local modules like loggers can be imported backend_path = str(Path(__file__).parent) if backend_path not in sys.path: diff --git a/studio/backend/core/__init__.py b/studio/backend/core/__init__.py index 051f6615a7..d8d95e2f1a 100644 --- a/studio/backend/core/__init__.py +++ b/studio/backend/core/__init__.py @@ -10,6 +10,16 @@ like unsloth, transformers, or torch before the version activation code has a chance to run. """ +import sys +from pathlib import Path + +# Ensure the backend directory is on sys.path so that bare "from utils.*" +# imports used throughout the backend work when core is imported as a package +# (e.g. from the CLI: "from studio.backend.core import ModelConfig"). +_backend_dir = str(Path(__file__).resolve().parent.parent) +if _backend_dir not in sys.path: + sys.path.insert(0, _backend_dir) + __all__ = [ # Inference "InferenceBackend", diff --git a/studio/backend/core/export/worker.py b/studio/backend/core/export/worker.py index 4f74f662ee..6af6ff1193 100644 --- a/studio/backend/core/export/worker.py +++ b/studio/backend/core/export/worker.py @@ -40,59 +40,25 @@ def _activate_transformers_version(model_name: str) -> None: if backend_path not in sys.path: sys.path.insert(0, backend_path) - from utils.transformers_version import needs_transformers_5, _resolve_base_model + from utils.transformers_version import ( + needs_transformers_5, + _resolve_base_model, + _ensure_venv_t5_exists, + _VENV_T5_DIR, + ) resolved = _resolve_base_model(model_name) if needs_transformers_5(resolved): - venv_t5 = os.path.join( - os.path.expanduser("~"), ".unsloth", "studio", ".venv_t5" - ) - if os.path.isdir(venv_t5): - sys.path.insert(0, venv_t5) - logger.info("Activated transformers 5.x from %s", venv_t5) - else: - # Fallback: pip install at runtime (slower, ~10-15s) - logger.warning(".venv_t5 not found at %s β€” installing at runtime", venv_t5) - import subprocess as sp - - os.makedirs(venv_t5, exist_ok = True) - r1 = sp.run( - [ - sys.executable, - "-m", - "pip", - "install", - "--target", - venv_t5, - "--no-deps", - "transformers==5.3.0", - ], - stdout = sp.PIPE, - stderr = sp.STDOUT, + if not _ensure_venv_t5_exists(): + raise RuntimeError( + f"Cannot activate transformers 5.x: .venv_t5 missing at {_VENV_T5_DIR}" ) - r2 = sp.run( - [ - sys.executable, - "-m", - "pip", - "install", - "--target", - venv_t5, - "--no-deps", - "huggingface_hub==1.3.0", - ], - stdout = sp.PIPE, - stderr = sp.STDOUT, - ) - if r1.returncode != 0 or r2.returncode != 0: - raise RuntimeError( - f"Failed to install transformers 5.x into {venv_t5}. " - f"pip returncode: transformers={r1.returncode}, huggingface_hub={r2.returncode}" - ) - sys.path.insert(0, venv_t5) + if _VENV_T5_DIR not in sys.path: + sys.path.insert(0, _VENV_T5_DIR) + logger.info("Activated transformers 5.x from %s", _VENV_T5_DIR) # Propagate to child subprocesses (e.g. GGUF converter) _pp = os.environ.get("PYTHONPATH", "") - os.environ["PYTHONPATH"] = venv_t5 + (os.pathsep + _pp if _pp else "") + os.environ["PYTHONPATH"] = _VENV_T5_DIR + (os.pathsep + _pp if _pp else "") else: logger.info("Using default transformers (4.57.x) for %s", model_name) diff --git a/studio/backend/core/inference/llama_cpp.py b/studio/backend/core/inference/llama_cpp.py index 64a7324adf..8054bd2a19 100644 --- a/studio/backend/core/inference/llama_cpp.py +++ b/studio/backend/core/inference/llama_cpp.py @@ -1173,50 +1173,113 @@ class LlamaCppBackend: Handles formats like: {"name":"web_search","arguments":{"query":"..."}} ... - Closing tag is optional (models sometimes omit it). + Closing tags (, , ) are all optional + since models frequently omit them. """ import re tool_calls = [] - # Pattern 1: JSON inside tags (closing tag optional) - for match in re.finditer( - r"\s*(\{.*?\})\s*(?:)?", content, re.DOTALL - ): - try: - obj = json.loads(match.group(1)) - tc = { - "id": f"call_{len(tool_calls)}", - "type": "function", - "function": { - "name": obj.get("name", ""), - "arguments": obj.get("arguments", {}), - }, - } - if isinstance(tc["function"]["arguments"], dict): - tc["function"]["arguments"] = json.dumps( - tc["function"]["arguments"] - ) - tool_calls.append(tc) - except (json.JSONDecodeError, ValueError): - pass + + # Pattern 1: JSON inside tags. + # Use balanced-brace extraction that skips braces inside JSON strings. + for m in re.finditer(r"\s*\{", content): + brace_start = m.end() - 1 # position of the opening { + depth, i = 0, brace_start + in_string = False + while i < len(content): + ch = content[i] + if in_string: + if ch == "\\" and i + 1 < len(content): + i += 2 # skip escaped character + continue + if ch == '"': + in_string = False + elif ch == '"': + in_string = True + elif ch == "{": + depth += 1 + elif ch == "}": + depth -= 1 + if depth == 0: + break + i += 1 + if depth == 0: + json_str = content[brace_start : i + 1] + try: + obj = json.loads(json_str) + tc = { + "id": f"call_{len(tool_calls)}", + "type": "function", + "function": { + "name": obj.get("name", ""), + "arguments": obj.get("arguments", {}), + }, + } + if isinstance(tc["function"]["arguments"], dict): + tc["function"]["arguments"] = json.dumps( + tc["function"]["arguments"] + ) + tool_calls.append(tc) + except (json.JSONDecodeError, ValueError): + pass # Pattern 2: XML-style value - # Closing optional + # All closing tags optional -- models frequently omit , + # , and/or . if not tool_calls: - for match in re.finditer( - r"\s*(.*?)\s*(?:)?", - content, - re.DOTALL, - ): - func_name = match.group(1) - params_text = match.group(2) + # Step 1: Find all positions and extract their bodies. + # Body boundary: use only or next as a boundary because + # code parameter values can contain that literal string. + # After extracting, we trim a trailing if present. + func_starts = list(re.finditer(r"\s*", content)) + for idx, fm in enumerate(func_starts): + func_name = fm.group(1) + body_start = fm.end() + # Hard boundaries: next + next_func = ( + func_starts[idx + 1].start() + if idx + 1 < len(func_starts) + else len(content) + ) + end_tag = re.search(r"", content[body_start:]) + if end_tag: + body_end = body_start + end_tag.start() + else: + body_end = len(content) + body_end = min(body_end, next_func) + body = content[body_start:body_end] + # Trim trailing if present (it's the real closing tag) + body = re.sub(r"\s*\s*$", "", body) + + # Step 2: Extract parameters from body. + # For single-parameter functions (the common case: code, command, + # query), use body end as the only boundary to avoid false matches + # on inside code strings. arguments = {} - for param_match in re.finditer( - r"\s*(.*?)\s*", - params_text, - re.DOTALL, - ): - arguments[param_match.group(1)] = param_match.group(2) + param_starts = list(re.finditer(r"\s*", body)) + if len(param_starts) == 1: + # Single parameter: value is everything from after the tag + # to end of body, trimming any trailing . + pm = param_starts[0] + val = body[pm.end() :] + val = re.sub(r"\s*\s*$", "", val) + arguments[pm.group(1)] = val.strip() + else: + for pidx, pm in enumerate(param_starts): + param_name = pm.group(1) + val_start = pm.end() + # Value ends at next if present + val = re.sub(r"\s*\s*$", "", val) + arguments[param_name] = val.strip() + tc = { "id": f"call_{len(tool_calls)}", "type": "function", @@ -1273,10 +1336,11 @@ class LlamaCppBackend: ) -> Generator[str, None, None]: """Iterate over an httpx streaming response with cancel support. - Uses a short read timeout on the stream so that cancel_event is - checked at least every 0.5s, even if the model is slow to produce - the next token. Without this, iter_text() blocks until the next - chunk arrives and cancellation can take many seconds on large models. + Checks cancel_event between chunks and on ReadTimeout. The + cancel watcher in _stream_with_retry also calls response.close() + on cancel, which unblocks iter_text() once the response exists. + During normal streaming llama-server sends tokens frequently, + so the cancel check between chunks is the primary mechanism. """ text_iter = response.iter_text() while True: @@ -1301,24 +1365,85 @@ class LlamaCppBackend: payload: dict, cancel_event: Optional[threading.Event] = None, ): - """Open an httpx streaming POST, retrying on ReadTimeout. + """Open an httpx streaming POST with cancel support. - The short read timeout (0.5 s) that enables cancel-checking during - streaming can also fire while waiting for the server to produce - its first response bytes (e.g. a reasoning model thinking). - This wrapper retries the connection until headers arrive or - cancel_event is set. + Sends the request once with a long read timeout (120 s) so + prompt processing (prefill) can finish without triggering a + retry storm. The previous 0.5 s timeout caused duplicate POST + requests every half second, forcing llama-server to restart + processing each time. + + A background watcher thread provides cancel by closing the + response when cancel_event is set. Limitation: httpx does not + allow interrupting a blocked read from another thread before + the response object exists, so cancel during the initial + header wait (prefill phase) only takes effect once headers + arrive. After that, response.close() unblocks reads promptly. + In practice llama-server prefill is 1-5 s for typical prompts, + during which cancel is deferred -- still much better than the + old retry storm which made prefill slower. """ - while True: + if cancel_event is not None and cancel_event.is_set(): + raise GeneratorExit + + # Background watcher: close the response if cancel is requested. + # Only effective after response headers arrive (httpx limitation). + _cancel_closed = threading.Event() + _response_ref: list = [None] + + def _cancel_watcher(): + while not _cancel_closed.is_set(): + if cancel_event.wait(timeout = 0.3): + # Cancel requested. Keep polling until the response object + # exists so we can close it, or until the main thread + # finishes on its own (_cancel_closed is set in finally). + while not _cancel_closed.is_set(): + r = _response_ref[0] + if r is not None: + try: + r.close() + return + except Exception as e: + logger.debug( + f"Error closing response in cancel watcher: {e}" + ) + # Response not created yet -- wait briefly and retry + _cancel_closed.wait(timeout = 0.1) + return + + watcher = None + if cancel_event is not None: + watcher = threading.Thread( + target = _cancel_watcher, daemon = True, name = "prefill-cancel" + ) + watcher.start() + + try: + # Long read timeout so prefill (prompt processing) can finish + # without triggering a retry storm. Cancel during both + # prefill and streaming is handled by the watcher thread + # which closes the response, unblocking any httpx read. + prefill_timeout = httpx.Timeout( + connect = 30, + read = 120.0, + write = 10, + pool = 10, + ) + with client.stream( + "POST", url, json = payload, timeout = prefill_timeout + ) as response: + _response_ref[0] = response + if cancel_event is not None and cancel_event.is_set(): + raise GeneratorExit + yield response + return + except (httpx.ReadError, httpx.RemoteProtocolError, httpx.CloseError): + # Response was closed by the cancel watcher if cancel_event is not None and cancel_event.is_set(): raise GeneratorExit - try: - with client.stream("POST", url, json = payload) as response: - yield response - return - except httpx.ReadTimeout: - # Server still thinking -- retry - continue + raise + finally: + _cancel_closed.set() def generate_chat_completion( self, @@ -1371,8 +1496,9 @@ class LlamaCppBackend: in_thinking = False try: - # Use a short read timeout so we can check cancel_event - # frequently instead of blocking indefinitely on slow models. + # _stream_with_retry uses a 120 s read timeout so prefill + # can finish. Cancel during streaming is handled by the + # watcher thread (closes the response on cancel_event). stream_timeout = httpx.Timeout(connect = 10, read = 0.5, write = 10, pool = 10) with httpx.Client(timeout = stream_timeout) as client: with self._stream_with_retry( @@ -1468,7 +1594,10 @@ class LlamaCppBackend: stop: Optional[list[str]] = None, cancel_event: Optional[threading.Event] = None, enable_thinking: Optional[bool] = None, - max_tool_iterations: int = 5, + max_tool_iterations: int = 10, + auto_heal_tool_calls: bool = True, + tool_call_timeout: int = 300, + session_id: Optional[str] = None, ) -> Generator[dict, None, None]: """ Agentic loop: let the model call tools, execute them, and continue. @@ -1533,16 +1662,45 @@ class LlamaCppBackend: tool_calls = message.get("tool_calls") # Fallback: detect tool calls embedded as XML/text in content - # Some models output XML instead of structured tool_calls + # Some models output XML instead of structured tool_calls, + # or bare tags without wrapper. content_text = message.get("content", "") or "" - if not tool_calls and "" in content_text: + if ( + auto_heal_tool_calls + and not tool_calls + and ("" in content_text or " blocks since they + # can contain arbitrary content including code. import re + # Strip ... blocks (greedy inside) content_text = re.sub( - r".*?(?:|$)", + r".*?", + "", + content_text, + flags = re.DOTALL, + ) + # Strip unterminated ... to end + content_text = re.sub( + r".*$", + "", + content_text, + flags = re.DOTALL, + ) + # Strip bare ... blocks + content_text = re.sub( + r".*?", + "", + content_text, + flags = re.DOTALL, + ) + # Strip unterminated bare to end + content_text = re.sub( + r".*$", "", content_text, flags = re.DOTALL, @@ -1569,7 +1727,10 @@ class LlamaCppBackend: try: arguments = json.loads(raw_args) except (json.JSONDecodeError, ValueError): - arguments = {"query": raw_args} + if auto_heal_tool_calls: + arguments = {"query": raw_args} + else: + arguments = {"raw": raw_args} else: arguments = raw_args @@ -1596,10 +1757,33 @@ class LlamaCppBackend: status_text = f"Calling: {tool_name}" yield {"type": "status", "text": status_text} + # Emit tool_start so the frontend can record inputs + yield { + "type": "tool_start", + "tool_name": tool_name, + "tool_call_id": tc.get("id", ""), + "arguments": arguments, + } + # Execute the tool - result = execute_tool( - tool_name, arguments, cancel_event = cancel_event + _effective_timeout = ( + None if tool_call_timeout >= 9999 else tool_call_timeout ) + result = execute_tool( + tool_name, + arguments, + cancel_event = cancel_event, + timeout = _effective_timeout, + session_id = session_id, + ) + + # Emit tool_end so the frontend can record outputs + yield { + "type": "tool_end", + "tool_name": tool_name, + "tool_call_id": tc.get("id", ""), + "result": result, + } # Append tool result to conversation tool_msg = { @@ -1651,7 +1835,30 @@ class LlamaCppBackend: if stop: stream_payload["stop"] = stop + import re as _re_final + + # Closed blocks only -- safe to strip mid-stream without shrinking later. + _TOOL_CLOSED_PATTERNS = [ + _re_final.compile(r".*?", _re_final.DOTALL), + _re_final.compile(r".*?", _re_final.DOTALL), + ] + # Open-ended patterns strip from an opening tag to end-of-string. + # Only applied on the final flush to avoid non-monotonic shrinking. + _TOOL_ALL_PATTERNS = _TOOL_CLOSED_PATTERNS + [ + _re_final.compile(r".*$", _re_final.DOTALL), + _re_final.compile(r".*$", _re_final.DOTALL), + ] + + def _strip_tool_markup(text: str, *, final: bool = False) -> str: + if not auto_heal_tool_calls: + return text + patterns = _TOOL_ALL_PATTERNS if final else _TOOL_CLOSED_PATTERNS + for pat in patterns: + text = pat.sub("", text) + return text.strip() if final else text + cumulative = "" + _last_emitted = "" in_thinking = False has_content_tokens = False reasoning_text = "" @@ -1683,7 +1890,12 @@ class LlamaCppBackend: if in_thinking: if has_content_tokens: cumulative += "" - yield {"type": "content", "text": cumulative} + yield { + "type": "content", + "text": _strip_tool_markup( + cumulative, final = True + ), + } else: cumulative = reasoning_text yield {"type": "content", "text": cumulative} @@ -1713,7 +1925,11 @@ class LlamaCppBackend: cumulative += "" in_thinking = False cumulative += token - yield {"type": "content", "text": cumulative} + cleaned = _strip_tool_markup(cumulative) + # Only emit when cleaned text grows (monotonic). + if len(cleaned) > len(_last_emitted): + _last_emitted = cleaned + yield {"type": "content", "text": cleaned} except json.JSONDecodeError: logger.debug( f"Skipping malformed SSE line: {line[:100]}" diff --git a/studio/backend/core/inference/orchestrator.py b/studio/backend/core/inference/orchestrator.py index 42de014a8d..6ff7fd2cbf 100644 --- a/studio/backend/core/inference/orchestrator.py +++ b/studio/backend/core/inference/orchestrator.py @@ -130,17 +130,17 @@ class InferenceOrchestrator: ) if resp.status_code == 200: models = resp.json() - # Top 8 GGUFs (frontend deduplicates against downloaded, - # so we fetch extra to always fill 4 slots) + # Top 40 GGUFs - frontend pages through them on-demand via + # infinite scroll, so we send a deep pool. gguf_ids = [ m["id"] for m in models if m.get("id", "").upper().endswith("-GGUF") - ][:8] - # Top 8 non-GGUF hub models + ][:40] + # Top 40 non-GGUF hub models hub_ids = [ m["id"] for m in models if not m.get("id", "").upper().endswith("-GGUF") - ][:8] + ][:40] if gguf_ids: self._top_gguf_cache = gguf_ids logger.info("Top GGUF models: %s", gguf_ids) diff --git a/studio/backend/core/inference/tools.py b/studio/backend/core/inference/tools.py index ee89dd2c0b..159facabe9 100644 --- a/studio/backend/core/inference/tools.py +++ b/studio/backend/core/inference/tools.py @@ -16,12 +16,42 @@ import sys import tempfile import threading +from loggers import get_logger from unsloth_zoo.rl_environments import check_signal_escape_patterns +logger = get_logger(__name__) + _EXEC_TIMEOUT = 300 # 5 minutes _MAX_OUTPUT_CHARS = 8000 # truncate long output _BASH_BLOCKED_WORDS = {"rm", "sudo", "dd", "chmod", "mkfs", "shutdown", "reboot"} +# Per-session working directories so each chat thread gets its own sandbox. +# Falls back to a shared ~/studio_sandbox/ for API callers without a session_id. +_workdirs: dict[str, str] = {} + + +def _get_workdir(session_id: str | None = None) -> str: + """Return (and lazily create) a persistent working directory for tool execution.""" + global _workdirs + key = session_id or "_default" + if key not in _workdirs or not os.path.isdir(_workdirs[key]): + home = os.path.expanduser("~") + sandbox_root = os.path.join(home, "studio_sandbox") + if session_id: + # Sanitize: strip path separators and parent-dir references + safe_id = os.path.basename(session_id.replace("..", "")) + if not safe_id: + safe_id = "_invalid" + workdir = os.path.join(sandbox_root, safe_id) + # Verify resolved path stays under sandbox root + if not os.path.realpath(workdir).startswith(os.path.realpath(sandbox_root)): + workdir = os.path.join(sandbox_root, "_invalid") + else: + workdir = sandbox_root + os.makedirs(workdir, exist_ok = True) + _workdirs[key] = workdir + return _workdirs[key] + WEB_SEARCH_TOOL = { "type": "function", @@ -80,25 +110,47 @@ TERMINAL_TOOL = { ALL_TOOLS = [WEB_SEARCH_TOOL, PYTHON_TOOL, TERMINAL_TOOL] -def execute_tool(name: str, arguments: dict, cancel_event = None) -> str: - """Execute a tool by name with the given arguments. Returns result as a string.""" +_TIMEOUT_UNSET = object() + + +def execute_tool( + name: str, + arguments: dict, + cancel_event = None, + timeout: int | None = _TIMEOUT_UNSET, + session_id: str | None = None, +) -> str: + """Execute a tool by name with the given arguments. Returns result as a string. + + ``timeout``: int sets per-call limit in seconds, ``None`` means no limit, + unset (default) uses ``_EXEC_TIMEOUT`` (300 s). + ``session_id``: optional thread/session ID for per-conversation sandbox isolation. + """ + logger.info( + f"execute_tool: name={name}, session_id={session_id}, timeout={timeout}" + ) + effective_timeout = _EXEC_TIMEOUT if timeout is _TIMEOUT_UNSET else timeout if name == "web_search": - return _web_search(arguments.get("query", "")) + return _web_search(arguments.get("query", ""), timeout = effective_timeout) if name == "python": - return _python_exec(arguments.get("code", ""), cancel_event) + return _python_exec( + arguments.get("code", ""), cancel_event, effective_timeout, session_id + ) if name == "terminal": - return _bash_exec(arguments.get("command", ""), cancel_event) + return _bash_exec( + arguments.get("command", ""), cancel_event, effective_timeout, session_id + ) return f"Unknown tool: {name}" -def _web_search(query: str, max_results: int = 5) -> str: +def _web_search(query: str, max_results: int = 5, timeout: int = _EXEC_TIMEOUT) -> str: """Search the web using DuckDuckGo and return formatted results.""" if not query.strip(): return "No query provided." try: from ddgs import DDGS - results = DDGS().text(query, max_results = max_results) + results = DDGS(timeout = timeout).text(query, max_results = max_results) if not results: return "No results found." parts = [] @@ -147,7 +199,12 @@ def _truncate(text: str, limit: int = _MAX_OUTPUT_CHARS) -> str: return text -def _python_exec(code: str, cancel_event = None) -> str: +def _python_exec( + code: str, + cancel_event = None, + timeout: int = _EXEC_TIMEOUT, + session_id: str | None = None, +) -> str: """Execute Python code in a subprocess sandbox.""" if not code or not code.strip(): return "No code provided." @@ -158,8 +215,11 @@ def _python_exec(code: str, cancel_event = None) -> str: return error tmp_path = None + workdir = _get_workdir(session_id) try: - fd, tmp_path = tempfile.mkstemp(suffix = ".py", prefix = "studio_exec_") + fd, tmp_path = tempfile.mkstemp( + suffix = ".py", prefix = "studio_exec_", dir = workdir + ) with os.fdopen(fd, "w") as f: f.write(code) @@ -168,7 +228,7 @@ def _python_exec(code: str, cancel_event = None) -> str: stdout = subprocess.PIPE, stderr = subprocess.STDOUT, text = True, - cwd = tempfile.gettempdir(), + cwd = workdir, ) # Spawn cancel watcher if we have a cancel event @@ -179,11 +239,11 @@ def _python_exec(code: str, cancel_event = None) -> str: watcher.start() try: - output, _ = proc.communicate(timeout = _EXEC_TIMEOUT) + output, _ = proc.communicate(timeout = timeout) except subprocess.TimeoutExpired: proc.kill() proc.communicate() - return _truncate("Execution timed out after 5 minutes.") + return _truncate(f"Execution timed out after {timeout} seconds.") if cancel_event is not None and cancel_event.is_set(): return "Execution cancelled." @@ -203,7 +263,12 @@ def _python_exec(code: str, cancel_event = None) -> str: pass -def _bash_exec(command: str, cancel_event = None) -> str: +def _bash_exec( + command: str, + cancel_event = None, + timeout: int = _EXEC_TIMEOUT, + session_id: str | None = None, +) -> str: """Execute a bash command in a subprocess sandbox.""" if not command or not command.strip(): return "No command provided." @@ -215,35 +280,35 @@ def _bash_exec(command: str, cancel_event = None) -> str: return f"Blocked command(s) for safety: {', '.join(sorted(blocked))}" try: - with tempfile.TemporaryDirectory() as tmpdir: - proc = subprocess.Popen( - ["bash", "-c", command], - stdout = subprocess.PIPE, - stderr = subprocess.STDOUT, - text = True, - cwd = tmpdir, + workdir = _get_workdir(session_id) + proc = subprocess.Popen( + ["bash", "-c", command], + stdout = subprocess.PIPE, + stderr = subprocess.STDOUT, + text = True, + cwd = workdir, + ) + + if cancel_event is not None: + watcher = threading.Thread( + target = _cancel_watcher, args = (proc, cancel_event), daemon = True ) + watcher.start() - if cancel_event is not None: - watcher = threading.Thread( - target = _cancel_watcher, args = (proc, cancel_event), daemon = True - ) - watcher.start() + try: + output, _ = proc.communicate(timeout = timeout) + except subprocess.TimeoutExpired: + proc.kill() + proc.communicate() + return _truncate(f"Execution timed out after {timeout} seconds.") - try: - output, _ = proc.communicate(timeout = _EXEC_TIMEOUT) - except subprocess.TimeoutExpired: - proc.kill() - proc.communicate() - return _truncate("Execution timed out after 5 minutes.") + if cancel_event is not None and cancel_event.is_set(): + return "Execution cancelled." - if cancel_event is not None and cancel_event.is_set(): - return "Execution cancelled." - - result = output or "" - if proc.returncode != 0: - result = f"Exit code {proc.returncode}:\n{result}" - return _truncate(result) if result.strip() else "(no output)" + result = output or "" + if proc.returncode != 0: + result = f"Exit code {proc.returncode}:\n{result}" + return _truncate(result) if result.strip() else "(no output)" except Exception as e: return f"Execution error: {e}" diff --git a/studio/backend/core/inference/worker.py b/studio/backend/core/inference/worker.py index 0693908178..2eb46f3217 100644 --- a/studio/backend/core/inference/worker.py +++ b/studio/backend/core/inference/worker.py @@ -42,59 +42,25 @@ def _activate_transformers_version(model_name: str) -> None: if backend_path not in sys.path: sys.path.insert(0, backend_path) - from utils.transformers_version import needs_transformers_5, _resolve_base_model + from utils.transformers_version import ( + needs_transformers_5, + _resolve_base_model, + _ensure_venv_t5_exists, + _VENV_T5_DIR, + ) resolved = _resolve_base_model(model_name) if needs_transformers_5(resolved): - venv_t5 = os.path.join( - os.path.expanduser("~"), ".unsloth", "studio", ".venv_t5" - ) - if os.path.isdir(venv_t5): - sys.path.insert(0, venv_t5) - logger.info("Activated transformers 5.x from %s", venv_t5) - else: - # Fallback: pip install at runtime (slower, ~10-15s) - logger.warning(".venv_t5 not found at %s β€” installing at runtime", venv_t5) - import subprocess as sp - - os.makedirs(venv_t5, exist_ok = True) - r1 = sp.run( - [ - sys.executable, - "-m", - "pip", - "install", - "--target", - venv_t5, - "--no-deps", - "transformers==5.3.0", - ], - stdout = sp.PIPE, - stderr = sp.STDOUT, + if not _ensure_venv_t5_exists(): + raise RuntimeError( + f"Cannot activate transformers 5.x: .venv_t5 missing at {_VENV_T5_DIR}" ) - r2 = sp.run( - [ - sys.executable, - "-m", - "pip", - "install", - "--target", - venv_t5, - "--no-deps", - "huggingface_hub==1.3.0", - ], - stdout = sp.PIPE, - stderr = sp.STDOUT, - ) - if r1.returncode != 0 or r2.returncode != 0: - raise RuntimeError( - f"Failed to install transformers 5.x into {venv_t5}. " - f"pip returncode: transformers={r1.returncode}, huggingface_hub={r2.returncode}" - ) - sys.path.insert(0, venv_t5) + if _VENV_T5_DIR not in sys.path: + sys.path.insert(0, _VENV_T5_DIR) + logger.info("Activated transformers 5.x from %s", _VENV_T5_DIR) # Propagate to child subprocesses (e.g. GGUF converter) _pp = os.environ.get("PYTHONPATH", "") - os.environ["PYTHONPATH"] = venv_t5 + (os.pathsep + _pp if _pp else "") + os.environ["PYTHONPATH"] = _VENV_T5_DIR + (os.pathsep + _pp if _pp else "") else: logger.info("Using default transformers (4.57.x) for %s", model_name) diff --git a/studio/backend/core/training/trainer.py b/studio/backend/core/training/trainer.py index 0aa6d0922e..bc252d1fa5 100644 --- a/studio/backend/core/training/trainer.py +++ b/studio/backend/core/training/trainer.py @@ -466,6 +466,7 @@ class UnslothTrainer: is_dataset_image: bool = False, is_dataset_audio: bool = False, trust_remote_code: bool = False, + full_finetuning: bool = False, ) -> bool: """Load model for training (supports both text and vision models)""" self.load_in_4bit = load_in_4bit # Store for training_meta.json @@ -612,6 +613,7 @@ class UnslothTrainer: dtype = None, auto_model = CsmForConditionalGeneration, load_in_4bit = False, + full_finetuning = full_finetuning, token = hf_token, trust_remote_code = trust_remote_code, ) @@ -626,6 +628,7 @@ class UnslothTrainer: model_name = model_name, dtype = None, load_in_4bit = False, + full_finetuning = full_finetuning, auto_model = WhisperForConditionalGeneration, whisper_language = "English", whisper_task = "transcribe", @@ -646,6 +649,7 @@ class UnslothTrainer: max_seq_length = max_seq_length, dtype = None, load_in_4bit = load_in_4bit, + full_finetuning = full_finetuning, token = hf_token, trust_remote_code = trust_remote_code, ) @@ -684,6 +688,7 @@ class UnslothTrainer: max_seq_length = max_seq_length, dtype = torch.float32, # Spark-TTS requires float32 load_in_4bit = False, + full_finetuning = full_finetuning, token = hf_token, trust_remote_code = trust_remote_code, ) @@ -697,6 +702,7 @@ class UnslothTrainer: model_name, max_seq_length = max_seq_length, load_in_4bit = False, + full_finetuning = full_finetuning, token = hf_token, trust_remote_code = trust_remote_code, ) @@ -712,6 +718,7 @@ class UnslothTrainer: max_seq_length = max_seq_length, dtype = None, load_in_4bit = load_in_4bit, + full_finetuning = full_finetuning, token = hf_token, trust_remote_code = trust_remote_code, ) @@ -724,6 +731,7 @@ class UnslothTrainer: max_seq_length = max_seq_length, dtype = None, # Auto-detect load_in_4bit = load_in_4bit, + full_finetuning = full_finetuning, token = hf_token, trust_remote_code = trust_remote_code, ) @@ -755,6 +763,7 @@ class UnslothTrainer: max_seq_length = max_seq_length, dtype = None, # Auto-detect load_in_4bit = load_in_4bit, + full_finetuning = full_finetuning, token = hf_token, trust_remote_code = trust_remote_code, ) @@ -779,13 +788,14 @@ class UnslothTrainer: self._source_code_retried = True logger.info(f"\n'could not get source code' β€” retrying once...\n") return self.load_model( - model_name, - max_seq_length, - load_in_4bit, - hf_token, - is_dataset_image, - is_dataset_audio, - trust_remote_code, + model_name = model_name, + max_seq_length = max_seq_length, + load_in_4bit = load_in_4bit, + hf_token = hf_token, + is_dataset_image = is_dataset_image, + is_dataset_audio = is_dataset_audio, + trust_remote_code = trust_remote_code, + full_finetuning = full_finetuning, ) error_msg = str(e) error_lower = error_msg.lower() diff --git a/studio/backend/core/training/worker.py b/studio/backend/core/training/worker.py index 57559ded54..ccd805b7ac 100644 --- a/studio/backend/core/training/worker.py +++ b/studio/backend/core/training/worker.py @@ -36,59 +36,25 @@ def _activate_transformers_version(model_name: str) -> None: if backend_path not in sys.path: sys.path.insert(0, backend_path) - from utils.transformers_version import needs_transformers_5, _resolve_base_model + from utils.transformers_version import ( + needs_transformers_5, + _resolve_base_model, + _ensure_venv_t5_exists, + _VENV_T5_DIR, + ) resolved = _resolve_base_model(model_name) if needs_transformers_5(resolved): - venv_t5 = os.path.join( - os.path.expanduser("~"), ".unsloth", "studio", ".venv_t5" - ) - if os.path.isdir(venv_t5): - sys.path.insert(0, venv_t5) - logger.info("Activated transformers 5.x from %s", venv_t5) - else: - # Fallback: pip install at runtime (slower, ~10-15s) - logger.warning(".venv_t5 not found at %s β€” installing at runtime", venv_t5) - import subprocess as sp - - os.makedirs(venv_t5, exist_ok = True) - r1 = sp.run( - [ - sys.executable, - "-m", - "pip", - "install", - "--target", - venv_t5, - "--no-deps", - "transformers==5.3.0", - ], - stdout = sp.PIPE, - stderr = sp.STDOUT, + if not _ensure_venv_t5_exists(): + raise RuntimeError( + f"Cannot activate transformers 5.x: .venv_t5 missing at {_VENV_T5_DIR}" ) - r2 = sp.run( - [ - sys.executable, - "-m", - "pip", - "install", - "--target", - venv_t5, - "--no-deps", - "huggingface_hub==1.3.0", - ], - stdout = sp.PIPE, - stderr = sp.STDOUT, - ) - if r1.returncode != 0 or r2.returncode != 0: - raise RuntimeError( - f"Failed to install transformers 5.x into {venv_t5}. " - f"pip returncode: transformers={r1.returncode}, huggingface_hub={r2.returncode}" - ) - sys.path.insert(0, venv_t5) + if _VENV_T5_DIR not in sys.path: + sys.path.insert(0, _VENV_T5_DIR) + logger.info("Activated transformers 5.x from %s", _VENV_T5_DIR) # Propagate to child subprocesses (e.g. GGUF converter) _pp = os.environ.get("PYTHONPATH", "") - os.environ["PYTHONPATH"] = venv_t5 + (os.pathsep + _pp if _pp else "") + os.environ["PYTHONPATH"] = _VENV_T5_DIR + (os.pathsep + _pp if _pp else "") else: logger.info("Using default transformers (4.57.x) for %s", model_name) @@ -444,12 +410,16 @@ def run_training_process( _tqdm_thread = _th.Thread(target = _monitor_tqdm, daemon = True) _tqdm_thread.start() + training_type = config.get("training_type", "LoRA/QLoRA") + use_lora = training_type == "LoRA/QLoRA" + # ── 4c. Load training model (uses VRAM β€” dataset already formatted) ── _send_status(event_queue, "Loading model...") success = trainer.load_model( model_name = model_name, max_seq_length = config["max_seq_length"], load_in_4bit = config["load_in_4bit"], + full_finetuning = not use_lora, hf_token = hf_token, is_dataset_image = config.get("is_dataset_image", False), is_dataset_audio = config.get("is_dataset_audio", False), @@ -473,8 +443,6 @@ def run_training_process( return # ── 4d. Prepare model (LoRA or full finetuning) ── - training_type = config.get("training_type", "LoRA/QLoRA") - use_lora = training_type == "LoRA/QLoRA" if use_lora: _send_status(event_queue, "Configuring LoRA adapters...") success = trainer.prepare_model_for_training( diff --git a/studio/backend/models/inference.py b/studio/backend/models/inference.py index 5d17d2d0be..41a942d217 100644 --- a/studio/backend/models/inference.py +++ b/studio/backend/models/inference.py @@ -318,6 +318,24 @@ class ChatCompletionRequest(BaseModel): None, description = "[x-unsloth] List of enabled tool names (e.g. ['web_search', 'python', 'terminal']). If None, all tools are enabled.", ) + auto_heal_tool_calls: Optional[bool] = Field( + True, + description = "[x-unsloth] Auto-detect and fix malformed tool calls from model output.", + ) + max_tool_calls_per_message: Optional[int] = Field( + 10, + ge = 0, + description = "[x-unsloth] Maximum number of tool call iterations per message (0 = disabled, 9999 = unlimited).", + ) + tool_call_timeout: Optional[int] = Field( + 300, + ge = 1, + description = "[x-unsloth] Timeout in seconds for each tool call execution (9999 = no limit).", + ) + session_id: Optional[str] = Field( + None, + description = "[x-unsloth] Session/thread ID for scoping tool execution sandbox.", + ) # ── Streaming response chunks ──────────────────────────────────── diff --git a/studio/backend/requirements/extras-no-deps.txt b/studio/backend/requirements/extras-no-deps.txt index 29bd421a0a..4b5aa86b5f 100644 --- a/studio/backend/requirements/extras-no-deps.txt +++ b/studio/backend/requirements/extras-no-deps.txt @@ -8,7 +8,7 @@ snac # TRL and related packages trl==0.23.1 git+https://github.com/meta-pytorch/OpenEnv.git -executorch>=1.0.1 +# executorch>=1.0.1 # 41.5 MB - no imports in unsloth/zoo/studio torch-c-dlpack-ext sentence_transformers==5.2.0 -transformers==4.57.1 +transformers==4.57.6 diff --git a/studio/backend/requirements/extras.txt b/studio/backend/requirements/extras.txt index 51ef69cf5d..170b1c9ec8 100644 --- a/studio/backend/requirements/extras.txt +++ b/studio/backend/requirements/extras.txt @@ -4,7 +4,7 @@ tomli-w # ExecuTorch dependencies ruamel.yaml -coremltools +# coremltools # 10.2 MB - Apple CoreML, no imports in unsloth/zoo/studio expecttest flatbuffers hydra-core @@ -15,7 +15,7 @@ pytest<9.0 pytest-json-report pytest-rerunfailures==15.1 pytest-xdist -# Also needed by sentence_transformers +# Also needed by sentence_transformers (installed with --no-deps in extras-no-deps.txt) scikit-learn==1.7.1 # Additional extras @@ -26,8 +26,8 @@ omegaconf einx pyloudnorm openai-whisper -uroman -MeCab +# uroman # 4.0 MB - romanization, no imports found +# MeCab # 19.9 MB - Japanese tokenizer, no imports found loguru flatten_dict ffmpy diff --git a/studio/backend/requirements/single-env/constraints.txt b/studio/backend/requirements/single-env/constraints.txt index 1789bbf713..156f78567e 100644 --- a/studio/backend/requirements/single-env/constraints.txt +++ b/studio/backend/requirements/single-env/constraints.txt @@ -1,6 +1,6 @@ # Single-env pins for unsloth + studio + data-designer # Keep compatible with unsloth transformers bounds. -transformers==4.57.1 +transformers==4.57.6 trl==0.23.1 huggingface-hub==0.36.2 diff --git a/studio/backend/requirements/studio.txt b/studio/backend/requirements/studio.txt index da0b4f382b..186ba82fe0 100644 --- a/studio/backend/requirements/studio.txt +++ b/studio/backend/requirements/studio.txt @@ -10,7 +10,7 @@ datasets==4.3.0 pyjwt easydict addict -gradio>=4.0.0 +# gradio>=4.0.0 # 148 MB - Studio uses React + FastAPI, not Gradio huggingface-hub==0.36.2 structlog>=24.1.0 diceware diff --git a/studio/backend/routes/inference.py b/studio/backend/routes/inference.py index d50f53ba8d..4c98dc6d24 100644 --- a/studio/backend/routes/inference.py +++ b/studio/backend/routes/inference.py @@ -1028,7 +1028,7 @@ async def openai_chat_completions( if use_tools: from core.inference.tools import ALL_TOOLS - if payload.enabled_tools: + if payload.enabled_tools is not None: tools_to_use = [ t for t in ALL_TOOLS @@ -1050,6 +1050,16 @@ async def openai_chat_completions( presence_penalty = payload.presence_penalty, cancel_event = cancel_event, enable_thinking = payload.enable_thinking, + auto_heal_tool_calls = payload.auto_heal_tool_calls + if payload.auto_heal_tool_calls is not None + else True, + max_tool_iterations = payload.max_tool_calls_per_message + if payload.max_tool_calls_per_message is not None + else 10, + tool_call_timeout = payload.tool_call_timeout + if payload.tool_call_timeout is not None + else 300, + session_id = payload.session_id, ) _tool_sentinel = object() @@ -1093,6 +1103,10 @@ async def openai_chat_completions( yield f"data: {status_data}\n\n" continue + if event["type"] in ("tool_start", "tool_end"): + yield f"data: {json.dumps(event)}\n\n" + continue + # "content" type -- cumulative text cumulative = event.get("text", "") new_text = cumulative[len(prev_text) :] diff --git a/studio/backend/utils/transformers_version.py b/studio/backend/utils/transformers_version.py index 5666b3be35..60b43500c0 100644 --- a/studio/backend/utils/transformers_version.py +++ b/studio/backend/utils/transformers_version.py @@ -26,6 +26,7 @@ import json import structlog from loggers import get_logger import os +import shutil import subprocess import sys from pathlib import Path @@ -58,7 +59,7 @@ _tokenizer_class_cache: dict[str, bool] = {} # Versions TRANSFORMERS_5_VERSION = "5.3.0" -TRANSFORMERS_DEFAULT_VERSION = "4.57.1" +TRANSFORMERS_DEFAULT_VERSION = "4.57.6" # Pre-installed directory for transformers 5.x β€” created by setup.sh / setup.ps1 _VENV_T5_DIR = str(Path.home() / ".unsloth" / "studio" / ".venv_t5") @@ -216,15 +217,87 @@ def _purge_modules() -> int: return len(to_remove) -def _ensure_venv_t5_exists() -> bool: - """Ensure .venv_t5/ exists. Install at runtime if missing.""" - if os.path.isdir(_VENV_T5_DIR) and os.listdir(_VENV_T5_DIR): - return True +_VENV_T5_PACKAGES = ( + f"transformers=={TRANSFORMERS_5_VERSION}", + "huggingface_hub==1.7.1", + "hf_xet==1.4.2", + "tiktoken", +) - logger.warning(".venv_t5 not found at %s β€” installing at runtime", _VENV_T5_DIR) - os.makedirs(_VENV_T5_DIR, exist_ok = True) - for pkg in (f"transformers=={TRANSFORMERS_5_VERSION}", "huggingface_hub==1.3.0"): - cmd = [ + +def _venv_t5_is_valid() -> bool: + """Return True if .venv_t5/ has all required packages at the correct versions.""" + if not os.path.isdir(_VENV_T5_DIR) or not os.listdir(_VENV_T5_DIR): + return False + # Check that the key package directories exist AND match the required version + for pkg_spec in _VENV_T5_PACKAGES: + parts = pkg_spec.split("==") + pkg_name = parts[0] + pkg_version = parts[1] if len(parts) > 1 else None + pkg_name_norm = pkg_name.replace("-", "_") + # Check directory exists + if not any( + (Path(_VENV_T5_DIR) / d).is_dir() + for d in (pkg_name_norm, pkg_name_norm.replace("_", "-")) + ): + return False + # For unpinned packages, existence is enough + if pkg_version is None: + continue + # Check version via .dist-info metadata + dist_info_found = False + for di in Path(_VENV_T5_DIR).glob(f"{pkg_name_norm}-*.dist-info"): + metadata = di / "METADATA" + if not metadata.is_file(): + continue + for line in metadata.read_text(errors = "replace").splitlines(): + if line.startswith("Version:"): + installed_ver = line.split(":", 1)[1].strip() + if installed_ver != pkg_version: + logger.info( + ".venv_t5 has %s==%s but need %s", + pkg_name, + installed_ver, + pkg_version, + ) + return False + dist_info_found = True + break + if dist_info_found: + break + if not dist_info_found: + return False + return True + + +def _install_to_venv_t5(pkg: str) -> bool: + """Install a single package into .venv_t5/, preferring uv then pip.""" + # Try uv first (faster) if already on PATH -- do NOT install uv at runtime + if shutil.which("uv"): + result = subprocess.run( + [ + "uv", + "pip", + "install", + "--python", + sys.executable, + "--target", + _VENV_T5_DIR, + "--no-deps", + "--upgrade", + pkg, + ], + stdout = subprocess.PIPE, + stderr = subprocess.STDOUT, + text = True, + ) + if result.returncode == 0: + return True + logger.warning("uv install of %s failed, falling back to pip", pkg) + + # Fallback to pip + result = subprocess.run( + [ sys.executable, "-m", "pip", @@ -232,13 +305,31 @@ def _ensure_venv_t5_exists() -> bool: "--target", _VENV_T5_DIR, "--no-deps", + "--upgrade", pkg, - ] - result = subprocess.run( - cmd, stdout = subprocess.PIPE, stderr = subprocess.STDOUT, text = True - ) - if result.returncode != 0: - logger.error("pip install failed:\n%s", result.stdout) + ], + stdout = subprocess.PIPE, + stderr = subprocess.STDOUT, + text = True, + ) + if result.returncode != 0: + logger.error("install failed:\n%s", result.stdout) + return False + return True + + +def _ensure_venv_t5_exists() -> bool: + """Ensure .venv_t5/ exists with all required packages. Install if missing.""" + if _venv_t5_is_valid(): + return True + + logger.warning( + ".venv_t5 not found or incomplete at %s -- installing at runtime", _VENV_T5_DIR + ) + shutil.rmtree(_VENV_T5_DIR, ignore_errors = True) + os.makedirs(_VENV_T5_DIR, exist_ok = True) + for pkg in _VENV_T5_PACKAGES: + if not _install_to_venv_t5(pkg): return False logger.info("Installed transformers 5.x to %s", _VENV_T5_DIR) return True diff --git a/studio/frontend/bun.lock b/studio/frontend/bun.lock index 6ac0c76470..7e3b0ac51e 100644 --- a/studio/frontend/bun.lock +++ b/studio/frontend/bun.lock @@ -5,7 +5,7 @@ "": { "name": "unsloth-theme", "dependencies": { - "@assistant-ui/react": "^0.12.17", + "@assistant-ui/react": "^0.12.19", "@assistant-ui/react-markdown": "^0.12.3", "@assistant-ui/react-streamdown": "^0.1.2", "@base-ui/react": "^1.2.0", @@ -43,7 +43,7 @@ "framer-motion": "^11.18.2", "js-yaml": "^4.1.1", "katex": "^0.16.28", - "lucide-react": "^0.575.0", + "lucide-react": "^0.577.0", "mammoth": "^1.11.0", "motion": "^12.34.0", "next": "^16.1.6", @@ -88,17 +88,17 @@ "@antfu/ni": ["@antfu/ni@25.0.0", "", { "dependencies": { "ansis": "^4.0.0", "fzf": "^0.5.2", "package-manager-detector": "^1.3.0", "tinyexec": "^1.0.1" }, "bin": { "na": "bin/na.mjs", "ni": "bin/ni.mjs", "nr": "bin/nr.mjs", "nci": "bin/nci.mjs", "nlx": "bin/nlx.mjs", "nun": "bin/nun.mjs", "nup": "bin/nup.mjs" } }, "sha512-9q/yCljni37pkMr4sPrI3G4jqdIk074+iukc5aFJl7kmDCCsiJrbZ6zKxnES1Gwg+i9RcDZwvktl23puGslmvA=="], - "@assistant-ui/core": ["@assistant-ui/core@0.1.5", "", { "dependencies": { "assistant-stream": "^0.3.5", "nanoid": "^5.1.6" }, "peerDependencies": { "@assistant-ui/store": "^0.2.2", "@assistant-ui/tap": "^0.5.2", "@types/react": "*", "assistant-cloud": "^0.1.21", "react": "^18 || ^19", "zustand": "^5.0.11" }, "optionalPeers": ["@types/react", "assistant-cloud", "react", "zustand"] }, "sha512-kLqFbRULZvE+hIwxGz705BW3QYhfwiVaVWoolfTGYkg+4xwah1PGuH0zqjXP5AMADtz+L69Lp+LX0xU9MQZ0DA=="], + "@assistant-ui/core": ["@assistant-ui/core@0.1.7", "", { "dependencies": { "assistant-stream": "^0.3.6", "nanoid": "^5.1.6" }, "peerDependencies": { "@assistant-ui/store": "^0.2.3", "@assistant-ui/tap": "^0.5.3", "@types/react": "*", "assistant-cloud": "^0.1.22", "react": "^18 || ^19", "zustand": "^5.0.11" }, "optionalPeers": ["@types/react", "assistant-cloud", "react", "zustand"] }, "sha512-219T42ihVOicbJXZLWgD2CW5Bylg9Nk7geC331X4RfJxTDYlm2zIjViGlGaqfj6URXBp6kMulO2BTUrHGmAvdw=="], - "@assistant-ui/react": ["@assistant-ui/react@0.12.17", "", { "dependencies": { "@assistant-ui/core": "^0.1.5", "@assistant-ui/store": "^0.2.2", "@assistant-ui/tap": "^0.5.2", "@radix-ui/primitive": "^1.1.3", "@radix-ui/react-compose-refs": "^1.1.2", "@radix-ui/react-context": "^1.1.3", "@radix-ui/react-primitive": "^2.1.4", "@radix-ui/react-use-callback-ref": "^1.1.1", "@radix-ui/react-use-escape-keydown": "^1.1.1", "assistant-cloud": "^0.1.21", "assistant-stream": "^0.3.4", "nanoid": "^5.1.6", "radix-ui": "^1.4.3", "react-textarea-autosize": "^8.5.9", "zod": "^4.3.6", "zustand": "^5.0.11" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^18 || ^19", "react-dom": "^18 || ^19" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-t4Z8LatD3LQrtURLaYPG47r4iG7UQgkdoi5YEv+EhzvYiG8I7kAyV4SbnFH6sXPrnleV4IpBHAd8Wc7ynkQtsw=="], + "@assistant-ui/react": ["@assistant-ui/react@0.12.19", "", { "dependencies": { "@assistant-ui/core": "^0.1.7", "@assistant-ui/store": "^0.2.3", "@assistant-ui/tap": "^0.5.3", "@radix-ui/primitive": "^1.1.3", "@radix-ui/react-compose-refs": "^1.1.2", "@radix-ui/react-context": "^1.1.3", "@radix-ui/react-primitive": "^2.1.4", "@radix-ui/react-use-callback-ref": "^1.1.1", "@radix-ui/react-use-escape-keydown": "^1.1.1", "assistant-cloud": "^0.1.22", "assistant-stream": "^0.3.6", "nanoid": "^5.1.6", "radix-ui": "^1.4.3", "react-textarea-autosize": "^8.5.9", "zod": "^4.3.6", "zustand": "^5.0.11" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^18 || ^19", "react-dom": "^18 || ^19" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-scAf0o8cwjuHT9Y44EFGXcE2y6BSmpeMvt0NxOn8+Y/HBlNttQMLNvrM0p2AjacXCUufagiafAnWybzBV3nKEQ=="], "@assistant-ui/react-markdown": ["@assistant-ui/react-markdown@0.12.4", "", { "dependencies": { "@radix-ui/react-primitive": "^2.1.4", "@radix-ui/react-use-callback-ref": "^1.1.1", "classnames": "^2.5.1", "react-markdown": "^10.1.0" }, "peerDependencies": { "@assistant-ui/react": "^0.12.11", "@types/react": "*", "react": "^18 || ^19" }, "optionalPeers": ["@types/react"] }, "sha512-6TD9guiuLJxJoOwSjNHUYAVma2ctDCG9uypUqKHE0OUhDwTDD3NsMvTnQ0n0Lh8nnCEwVglOwKKlSEYpV7SnWA=="], "@assistant-ui/react-streamdown": ["@assistant-ui/react-streamdown@0.1.3", "", { "dependencies": { "rehype-harden": "^1.1.7", "rehype-raw": "^7.0.0", "rehype-sanitize": "^6.0.0", "streamdown": "^2.1.0" }, "peerDependencies": { "@assistant-ui/react": "^0.12.11", "@streamdown/cjk": "^1.0.0", "@streamdown/code": "^1.0.0", "@streamdown/math": "^1.0.0", "@streamdown/mermaid": "^1.0.0", "@types/react": "*", "react": "^18 || ^19" }, "optionalPeers": ["@streamdown/cjk", "@streamdown/code", "@streamdown/math", "@streamdown/mermaid", "@types/react"] }, "sha512-n1UCjXQ3svmDtJBMJj/vXqz/BqAQBuy7myrXeymz2tD9l+ENQgqu2JY5ir3J19juJTe5lsi/P3+tOJ2C1jc/nw=="], - "@assistant-ui/store": ["@assistant-ui/store@0.2.2", "", { "dependencies": { "use-effect-event": "^2.0.3" }, "peerDependencies": { "@assistant-ui/tap": "^0.5.2", "@types/react": "*", "react": "^18 || ^19" }, "optionalPeers": ["@types/react"] }, "sha512-JzQseWFp3UmbByBSWQmiGi/bz5jbfru04hIgb2DJBpnnTyns8Zl+8wDPnwiYGF/6SA+IzTg5M0V1wf77rwU0dA=="], + "@assistant-ui/store": ["@assistant-ui/store@0.2.3", "", { "dependencies": { "use-effect-event": "^2.0.3" }, "peerDependencies": { "@assistant-ui/tap": "^0.5.3", "@types/react": "*", "react": "^18 || ^19" }, "optionalPeers": ["@types/react"] }, "sha512-daStbgSQiX7+csqK6Cvo7A8p8UZkTCSMxBHxbhJvwrlVbp7BRJWTxq3U3rpTkSGIar23SXIyVRRfXU8VW7pswA=="], - "@assistant-ui/tap": ["@assistant-ui/tap@0.5.2", "", { "peerDependencies": { "@types/react": "*", "react": "^18 || ^19" }, "optionalPeers": ["@types/react", "react"] }, "sha512-w6gXhr+mF6cPG6ZCnkqV4kkOHzR+Fb+52S4T34PnrH0cs8l2Gqlwo/kB9BcB9fGmjwL7izdwubQ7t2VBhWpz/Q=="], + "@assistant-ui/tap": ["@assistant-ui/tap@0.5.3", "", { "peerDependencies": { "@types/react": "*", "react": "^18 || ^19" }, "optionalPeers": ["@types/react", "react"] }, "sha512-wy06ksqF2LfFxe4JXy31Ns89N/be1Dy3c+mG363cFHFp3CbLkRu8CrCN2SQSgCkXt628E+D8QyzqdBcl9kD4NQ=="], "@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], @@ -846,7 +846,7 @@ "aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="], - "assistant-cloud": ["assistant-cloud@0.1.21", "", { "dependencies": { "assistant-stream": "^0.3.4" } }, "sha512-KZ9ZsF1i1zMhozvD4m8TsmTdtufqULMaqgOoSLRyVtnhwvxkDufL87tSjv7epddZ4kbebe31biWSg7KIlgzvQA=="], + "assistant-cloud": ["assistant-cloud@0.1.22", "", { "dependencies": { "assistant-stream": "^0.3.6" } }, "sha512-AEE9shV+oFrGDv/MRTRERctNKpIYS0n34UpAQXXICiOkSWD6QZnS1ljLqruFko7fJoT5CIWq8dNeJWdzQLTBLg=="], "assistant-stream": ["assistant-stream@0.3.3", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "nanoid": "^5.1.6", "secure-json-parse": "^4.1.0" } }, "sha512-Ne/uTseMIiZx740dTbr/SWxONM8nYj4Z5BRmUfqQN+TNgtOCgWOlC/oTUQ+A7LIUHtmGbcoyZwDf8yd2RASnDA=="], @@ -1450,7 +1450,7 @@ "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], - "lucide-react": ["lucide-react@0.575.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-VuXgKZrk0uiDlWjGGXmKV6MSk9Yy4l10qgVvzGn2AWBx1Ylt0iBexKOAoA6I7JO3m+M9oeovJd3yYENfkUbOeg=="], + "lucide-react": ["lucide-react@0.577.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-4LjoFv2eEPwYDPg/CUdBJQSDfPyzXCRrVW1X7jrx/trgxnxkHFjnVZINbzvzxjN70dxychOfg+FTYwBiS3pQ5A=="], "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], @@ -2082,9 +2082,9 @@ "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], - "@assistant-ui/core/assistant-stream": ["assistant-stream@0.3.5", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "nanoid": "^5.1.6", "secure-json-parse": "^4.1.0" } }, "sha512-OGxVClfpEOoSsJDraPoe+GYTwh9TJX1wxK3hT5Qs7gIOyD/MZbqwyWwabRO2KTnNU4w7usvmC/vneUzxSk4bBg=="], + "@assistant-ui/core/assistant-stream": ["assistant-stream@0.3.6", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "nanoid": "^5.1.6", "secure-json-parse": "^4.1.0" } }, "sha512-NdtSRrQfWCDA/aqQ1xhobf/xnhuMZkhFAw9xzAt5iAoL3ouxVXOowSRN87OL4MYBQEvqtcjw9/CE6YcsXoBtuw=="], - "@assistant-ui/react/assistant-stream": ["assistant-stream@0.3.5", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "nanoid": "^5.1.6", "secure-json-parse": "^4.1.0" } }, "sha512-OGxVClfpEOoSsJDraPoe+GYTwh9TJX1wxK3hT5Qs7gIOyD/MZbqwyWwabRO2KTnNU4w7usvmC/vneUzxSk4bBg=="], + "@assistant-ui/react/assistant-stream": ["assistant-stream@0.3.6", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "nanoid": "^5.1.6", "secure-json-parse": "^4.1.0" } }, "sha512-NdtSRrQfWCDA/aqQ1xhobf/xnhuMZkhFAw9xzAt5iAoL3ouxVXOowSRN87OL4MYBQEvqtcjw9/CE6YcsXoBtuw=="], "@assistant-ui/react/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], @@ -2282,7 +2282,7 @@ "ajv-formats/ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="], - "assistant-cloud/assistant-stream": ["assistant-stream@0.3.5", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "nanoid": "^5.1.6", "secure-json-parse": "^4.1.0" } }, "sha512-OGxVClfpEOoSsJDraPoe+GYTwh9TJX1wxK3hT5Qs7gIOyD/MZbqwyWwabRO2KTnNU4w7usvmC/vneUzxSk4bBg=="], + "assistant-cloud/assistant-stream": ["assistant-stream@0.3.6", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "nanoid": "^5.1.6", "secure-json-parse": "^4.1.0" } }, "sha512-NdtSRrQfWCDA/aqQ1xhobf/xnhuMZkhFAw9xzAt5iAoL3ouxVXOowSRN87OL4MYBQEvqtcjw9/CE6YcsXoBtuw=="], "chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], diff --git a/studio/frontend/package.json b/studio/frontend/package.json index b3acb6468f..4b40759d62 100644 --- a/studio/frontend/package.json +++ b/studio/frontend/package.json @@ -13,7 +13,7 @@ "biome:fix": "biome check . --write" }, "dependencies": { - "@assistant-ui/react": "^0.12.17", + "@assistant-ui/react": "^0.12.19", "@assistant-ui/react-markdown": "^0.12.3", "@assistant-ui/react-streamdown": "^0.1.2", "@base-ui/react": "^1.2.0", @@ -51,7 +51,7 @@ "framer-motion": "^11.18.2", "js-yaml": "^4.1.1", "katex": "^0.16.28", - "lucide-react": "^0.575.0", + "lucide-react": "^0.577.0", "mammoth": "^1.11.0", "motion": "^12.34.0", "next": "^16.1.6", diff --git a/studio/frontend/src/components/assistant-ui/badge.tsx b/studio/frontend/src/components/assistant-ui/badge.tsx new file mode 100644 index 0000000000..7b189b349c --- /dev/null +++ b/studio/frontend/src/components/assistant-ui/badge.tsx @@ -0,0 +1,67 @@ +"use client"; + +import type { ComponentProps } from "react"; +import { Slot } from "radix-ui"; +import { cva, type VariantProps } from "class-variance-authority"; +import { cn } from "@/lib/utils"; + +const badgeVariants = cva( + "inline-flex items-center justify-center gap-1 rounded-md font-medium text-xs transition-colors [&_svg]:size-3 [&_svg]:shrink-0", + { + variants: { + variant: { + outline: + "border border-input bg-transparent text-muted-foreground hover:bg-accent hover:text-accent-foreground", + secondary: + "bg-secondary text-secondary-foreground hover:bg-secondary/80", + muted: + "bg-muted text-muted-foreground hover:bg-muted/80 hover:text-foreground", + ghost: + "bg-transparent text-muted-foreground hover:bg-accent hover:text-accent-foreground", + info: "bg-blue-100 text-blue-700 hover:bg-blue-100/80 dark:bg-blue-900/50 dark:text-blue-300", + warning: + "bg-amber-100 text-amber-700 hover:bg-amber-100/80 dark:bg-amber-900/50 dark:text-amber-300", + success: + "bg-emerald-100 text-emerald-700 hover:bg-emerald-100/80 dark:bg-emerald-900/50 dark:text-emerald-300", + destructive: + "bg-red-100 text-red-700 hover:bg-red-100/80 dark:bg-red-900/50 dark:text-red-300", + }, + size: { + sm: "px-1.5 py-0.5", + default: "px-2 py-1", + lg: "px-2.5 py-1.5 text-sm", + }, + }, + defaultVariants: { + variant: "outline", + size: "default", + }, + }, +); + +export type BadgeProps = ComponentProps<"span"> & + VariantProps & { + asChild?: boolean; + }; + +function Badge({ + className, + variant, + size, + asChild = false, + ...props +}: BadgeProps) { + const Comp = asChild ? Slot.Root : "span"; + + return ( + + ); +} + +export { Badge, badgeVariants }; diff --git a/studio/frontend/src/components/assistant-ui/markdown-text.tsx b/studio/frontend/src/components/assistant-ui/markdown-text.tsx index e41ec10dfe..0bbbe94fdc 100644 --- a/studio/frontend/src/components/assistant-ui/markdown-text.tsx +++ b/studio/frontend/src/components/assistant-ui/markdown-text.tsx @@ -10,7 +10,7 @@ import { HugeiconsIcon } from "@hugeicons/react"; import { code } from "@streamdown/code"; import { math } from "@streamdown/math"; import { mermaid } from "@streamdown/mermaid"; -import { DownloadIcon } from "lucide-react"; +import { DownloadIcon, Maximize2Icon, Minimize2Icon } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { Block, type BlockProps, Streamdown } from "streamdown"; import "katex/dist/katex.min.css"; @@ -84,6 +84,11 @@ function isSvgFence(codeFence: CodeFence): boolean { return false; } +function isHtmlFence(codeFence: CodeFence): boolean { + const lang = codeFence.language?.toLowerCase() ?? ""; + return lang === "html" && !codeFence.source.trimStart().startsWith("]|on\w+\s*=|javascript:|]|]|]|]/i; function sanitizeSvg(source: string): string | null { @@ -104,6 +109,96 @@ function SvgPreview({ source }: { source: string }) { ); } +const HTML_PREVIEW_DEFAULT_HEIGHT = 400; +const HTML_PREVIEW_MAX_HEIGHT = 800; + +function HtmlPreview({ source }: { source: string }) { + const iframeRef = useRef(null); + const [height, setHeight] = useState(HTML_PREVIEW_DEFAULT_HEIGHT); + const [enlarged, setEnlarged] = useState(false); + + useEffect(() => { + const handler = (e: MessageEvent) => { + if (e.source !== iframeRef.current?.contentWindow) return; + if (typeof e.data?.htmlPreviewHeight === "number") { + setHeight(Math.min(Math.max(e.data.htmlPreviewHeight, 100), HTML_PREVIEW_MAX_HEIGHT)); + } + }; + window.addEventListener("message", handler); + return () => window.removeEventListener("message", handler); + }, []); + + useEffect(() => { + if (!enlarged) return; + const handler = (e: KeyboardEvent) => { + if (e.key === "Escape") setEnlarged(false); + }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, [enlarged]); + + const resizeScript = ``; + + const srcDoc = source + resizeScript; + + if (enlarged) { + return ( + <> +
+ {/* Placeholder keeps layout stable while overlay is shown */} +
+
{ if (e.target === e.currentTarget) setEnlarged(false); }} + > +
+ +
+
+