diff --git a/studio/frontend/src/features/chat/api-provider-logo.tsx b/studio/frontend/src/features/chat/api-provider-logo.tsx index f9d574b8fa..7de9bea9a8 100644 --- a/studio/frontend/src/features/chat/api-provider-logo.tsx +++ b/studio/frontend/src/features/chat/api-provider-logo.tsx @@ -40,10 +40,10 @@ interface ApiProviderLogoProps { title?: string; } -/** - * Renders a registry provider's logo when its asset exists under - * `public/provider-logos/`. OpenAI's is inverted in dark mode for contrast. - */ +// Monochrome logos vanish on a dark background. +const DARK_INVERT_LOGOS = new Set(["openai", "ollama", "openrouter"]); + +/** Provider logo from `public/provider-logos/`; monochrome ones invert in dark mode. */ export function ApiProviderLogo({ providerType, className, title }: ApiProviderLogoProps) { const src = apiProviderLogoSrc(providerType); if (!src && isCustomProviderType(providerType)) { @@ -63,7 +63,7 @@ export function ApiProviderLogo({ providerType, className, title }: ApiProviderL aria-hidden className={cn( "shrink-0 object-contain", - providerType === "openai" && "dark:invert", + providerType && DARK_INVERT_LOGOS.has(providerType) && "dark:invert", className, )} /> diff --git a/studio/frontend/src/features/settings/settings-dialog.tsx b/studio/frontend/src/features/settings/settings-dialog.tsx index 0ba59f7095..f4ba98b1ce 100644 --- a/studio/frontend/src/features/settings/settings-dialog.tsx +++ b/studio/frontend/src/features/settings/settings-dialog.tsx @@ -12,6 +12,7 @@ import { type TranslationKey, useT } from "@/i18n"; import { cn } from "@/lib/utils"; import { MicIcon } from "@/lib/mic-icon"; import { + BotIcon, Cancel01Icon, CloudIcon, CpuIcon, @@ -40,6 +41,7 @@ import { useSettingsDialogStore, } from "./stores/settings-dialog-store"; import { AboutTab } from "./tabs/about-tab"; +import { AgentsTab } from "./tabs/agents-tab"; import { ApiKeysTab } from "./tabs/api-keys-tab"; import { AppearanceTab } from "./tabs/appearance-tab"; import { ChatTab } from "./tabs/chat-tab"; @@ -71,13 +73,11 @@ const TABS: TabDef[] = [ id: "resources", labelKey: "settings.tabs.resources", icon: CpuIcon, - badgeKey: "common.new", }, { id: "chat", labelKey: "settings.tabs.chat", icon: Message01Icon, - badgeKey: "common.new", }, { id: "api-keys", @@ -89,6 +89,12 @@ const TABS: TabDef[] = [ labelKey: "settings.tabs.connections", icon: CloudIcon, }, + { + id: "agents", + labelKey: "settings.tabs.agents", + icon: BotIcon, + badgeKey: "common.new", + }, { id: "voice", labelKey: "settings.tabs.voice", @@ -124,6 +130,8 @@ function renderTab(tab: SettingsTab) { return ; case "api-keys": return ; + case "agents": + return ; case "about": return ; } @@ -222,6 +230,7 @@ export function SettingsDialog() { connections: null, data: null, "api-keys": null, + agents: null, about: null, }); diff --git a/studio/frontend/src/features/settings/settings-search.ts b/studio/frontend/src/features/settings/settings-search.ts index 63b492878f..f7366dba17 100644 --- a/studio/frontend/src/features/settings/settings-search.ts +++ b/studio/frontend/src/features/settings/settings-search.ts @@ -103,6 +103,19 @@ export const SETTINGS_SEARCH_INDEX: Record = { "settings.apiKeys.description", "settings.apiKeys.accessTokens", ], + agents: [ + // Heading and intro carry the searched terms ("unsloth start", agent names); titles do not. + "settings.agents.title", + "settings.agents.description", + "settings.agents.intro", + "settings.agents.quickstart.title", + "settings.agents.supportedAgents.title", + "settings.agents.models.title", + "settings.agents.options.title", + "settings.agents.remote.title", + "settings.agents.passthrough.title", + "settings.agents.dryRun.title", + ], connections: [], voice: [ "settings.voice.dictation.sectionTitle", diff --git a/studio/frontend/src/features/settings/stores/settings-dialog-store.ts b/studio/frontend/src/features/settings/stores/settings-dialog-store.ts index 51908a5ad0..e7ca3455e2 100644 --- a/studio/frontend/src/features/settings/stores/settings-dialog-store.ts +++ b/studio/frontend/src/features/settings/stores/settings-dialog-store.ts @@ -13,6 +13,7 @@ export type SettingsTab = | "connections" | "data" | "api-keys" + | "agents" | "about"; export type SettingsScrollTarget = "about-updates"; @@ -69,6 +70,7 @@ function loadInitialTab(): SettingsTab { "connections", "data", "api-keys", + "agents", "about", ]; return valid.includes(stored as SettingsTab) diff --git a/studio/frontend/src/features/settings/tabs/agents-tab.tsx b/studio/frontend/src/features/settings/tabs/agents-tab.tsx new file mode 100644 index 0000000000..55cfc8b31e --- /dev/null +++ b/studio/frontend/src/features/settings/tabs/agents-tab.tsx @@ -0,0 +1,466 @@ +// 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 { getClientPlatform } from "@/components/tauri/window-titlebar"; +import { fetchDeviceType, usePlatformStore } from "@/config/env"; +import { useT } from "@/i18n"; +import type { TranslationKey } from "@/i18n"; +import { getApiBase, isTauri } from "@/lib/api-base"; +import { copyToClipboard } from "@/lib/copy-to-clipboard"; +import { Tick02Icon } from "@/lib/tick-icon"; +import { cn } from "@/lib/utils"; +import { + ArrowUpRight01Icon, + Book03Icon, + Copy01Icon, +} from "@hugeicons/core-free-icons"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { useEffect, useRef, useState } from "react"; +import { useChatRuntimeStore } from "@/features/chat"; +import { ApiProviderLogo } from "../../chat/api-provider-logo"; +import { type CodingAgentsInfo, loadCodingAgents } from "../api/coding-agents"; +import { + buildAgentCommand, + isLoopbackHost, + normalizeHost, +} from "../components/agent-command"; +import { SettingsSection } from "../components/settings-section"; + +const DOCS_URL = "https://unsloth.ai/docs/integrations/unsloth-start"; + +function isLoopbackBase(base: string): boolean { + try { + return isLoopbackHost(normalizeHost(new URL(base).hostname)); + } catch { + return false; + } +} + +// Desktop-only: a browser loopback URL may be an SSH/port forward to another host. +function canUseLocalAgentDetection(base: string): boolean { + return isTauri && isLoopbackBase(base); +} + +// One timeout, reset on re-click and cleared on unmount, so the tick never leaks. +function useCopyButton(text: string) { + const [copied, setCopied] = useState(false); + const timeoutRef = useRef(null); + + useEffect( + () => () => { + if (timeoutRef.current !== null) window.clearTimeout(timeoutRef.current); + }, + [], + ); + + const copy = async () => { + if (!(await copyToClipboard(text))) return; + setCopied(true); + if (timeoutRef.current !== null) window.clearTimeout(timeoutRef.current); + timeoutRef.current = window.setTimeout(() => { + setCopied(false); + timeoutRef.current = null; + }, 1600); + }; + + return { copied, copy }; +} + +// Ids match the backend detection list; agents without an official `logo` asset get a monogram. +// Names are untranslated, so `settings.agents.intro` lists them all to keep them searchable. +const SUPPORTED_AGENTS: { + id: string; + name: string; + logo?: string; + color?: string; + mark?: string; +}[] = [ + { id: "claude", name: "Claude Code", logo: "anthropic" }, + { id: "codex", name: "OpenAI Codex", logo: "openai" }, + { id: "hermes", name: "Hermes", color: "#8B5CF6", mark: "He" }, + { id: "openclaw", name: "OpenClaw", color: "#F59E0B", mark: "Ol" }, + { id: "opencode", name: "OpenCode", color: "#3B82F6", mark: "Oc" }, + { id: "pi", name: "Pi", color: "#EC4899", mark: "Pi" }, +]; + +/** Official brand logo when available, else a brand-colored monogram tile. */ +function AgentIcon({ + logo, + color, + mark, +}: { + logo?: string; + color?: string; + mark?: string; +}) { + if (logo) { + return ( + + + + ); + } + return ( + + {mark} + + ); +} + +function InlineCommand({ command }: { command: string }) { + const t = useT(); + const { copied, copy } = useCopyButton(command); + + return ( + <> + + + {copied ? t("settings.agents.copied") : ""} + + + ); +} + +// Flag tokens are literal; only the descriptions are localized. +const OPTION_ROWS: { flag: string; descKey: TranslationKey }[] = [ + { flag: "--model, -m", descKey: "settings.agents.options.model" }, + { + flag: "--context-length", + descKey: "settings.agents.options.contextLength", + }, + { flag: "--gguf-variant", descKey: "settings.agents.options.ggufVariant" }, + { + flag: "--load-in-4bit / --no-load-in-4bit", + descKey: "settings.agents.options.loadIn4bit", + }, + { + flag: "--tensor-parallel / --no-tensor-parallel", + descKey: "settings.agents.options.tensorParallel", + }, + { flag: "--serve / --no-serve", descKey: "settings.agents.options.serve" }, + { + flag: "--launch / --no-launch", + descKey: "settings.agents.options.launch", + }, + { + flag: "--persist / --no-persist", + descKey: "settings.agents.options.persist", + }, + { flag: "--api-key", descKey: "settings.agents.options.apiKey" }, + { flag: "--yolo", descKey: "settings.agents.options.yolo" }, +]; + +const QUICKSTART_AGENT = "claude"; + +// Flags only: agentCommand supplies the prefix so every example targets the Studio +// this tab shows. Kept single line so the copy pastes as-is. +const MODEL_SUFFIX_FLAGS = + "--model unsloth/gemma-4-E2B-it-GGUF:UD-Q4_K_XL --context-length 32768"; + +const MODEL_VARIANT_FLAGS = + "--model unsloth/gemma-4-E2B-it-GGUF --gguf-variant UD-Q4_K_XL --context-length 32768"; + +const REMOTE_CMD_UNIX = `export UNSLOTH_STUDIO_URL=https://studio.example.com +export UNSLOTH_API_KEY=sk-unsloth-... +unsloth start claude`; + +// PowerShell uses $env: assignments; export is POSIX-only. +const REMOTE_CMD_WINDOWS = `$env:UNSLOTH_STUDIO_URL = "https://studio.example.com" +$env:UNSLOTH_API_KEY = "sk-unsloth-..." +unsloth start claude`; + +// Independent alternatives, each with its own copy button (not one script). +const PASSTHROUGH_EXAMPLES = [ + { agent: "claude", flags: "--continue" }, + { agent: "codex", flags: "--persist resume --last" }, +]; + +const DRY_RUN_FLAGS = "--no-launch"; + +function CommandBlock({ command }: { command: string }) { + const t = useT(); + const { copied, copy } = useCopyButton(command); + + return ( +
+
+        {command}
+      
+ + + {copied ? t("settings.agents.copied") : ""} + +
+ ); +} + +export function AgentsTab() { + const t = useT(); + const serverUrl = usePlatformStore((s) => s.serverUrl); + const deviceType = usePlatformStore((s) => s.deviceType); + const [info, setInfo] = useState(null); + + const origin = typeof window !== "undefined" ? window.location.origin : ""; + const localDetection = canUseLocalAgentDetection(serverUrl ?? origin); + + // The remote snippet runs on the client, so use the client platform, not deviceType. + // Anchor the match: a bare includes("win") would also match "darwin". + const [isWindowsClient] = useState(() => { + const p = getClientPlatform(); + return p.startsWith("win") || p.includes("windows"); + }); + + useEffect(() => { + void fetchDeviceType({ force: true }); + }, []); + + // A remote backend's PATH says nothing about the machine running the copied command. + useEffect(() => { + if (!localDetection) return; + let cancelled = false; + loadCodingAgents() + .then((next) => { + if (!cancelled) setInfo(next); + }) + .catch(() => { + // Best-effort; the tab still works without PATH detection. + }); + return () => { + cancelled = true; + }; + }, [localDetection]); + + // Derive visibility from localDetection instead of clearing info in the effect. + const visibleInfo = localDetection ? info : null; + const detected = new Set(visibleInfo?.detected ?? []); + const remoteCommand = isWindowsClient ? REMOTE_CMD_WINDOWS : REMOTE_CMD_UNIX; + + // `codex` needs a GGUF model (unsloth_cli's _require_gguf_for_codex exits otherwise), so flag + // its row instead of offering a failing command. Same three signals the API usage panel uses. + const activeGgufVariant = useChatRuntimeStore((s) => s.activeGgufVariant); + const activeNativePathToken = useChatRuntimeStore( + (s) => s.activeNativePathToken, + ); + const ggufContextLength = useChatRuntimeStore((s) => s.ggufContextLength); + const isGguf = + activeGgufVariant != null || + activeNativePathToken != null || + ggufContextLength != null; + + // Build from the reachable base: a bare `unsloth start` only probes 127.0.0.1:8888, but the + // desktop falls back across 8888-8908 and Studio may be remote. The browser must use its own + // origin, since /api/health reports the backend's localhost (the user's, behind a tunnel); + // the desktop has no window origin and falls back to getApiBase() while serverUrl loads. + // No --api-key: the CLI caches an explicit key per base, so a placeholder would overwrite a + // working saved one. Omitting it replays the saved key; the remote section covers first setup. + const commandBase = isTauri ? (serverUrl ?? getApiBase()) : origin; + // The command runs wherever the CLI is. For a loopback base that is this Studio's + // own host, so use deviceType, which reports wsl where the browser would claim + // Windows and emit $env: syntax bash rejects. A remote base is reached from the + // viewer's machine instead, so only the client platform describes that shell. + const commandOs = + (isLoopbackBase(commandBase) ? deviceType === "windows" : isWindowsClient) + ? "windows" + : "unix"; + const agentCommand = (agentId: string) => + buildAgentCommand(commandBase, null, commandOs, agentId); + const example = (agentId: string, flags: string) => + `${agentCommand(agentId)} ${flags}`; + + return ( +
+ {/* data-settings-label lets indexed settings search scroll to these. */} +
+

+ {t("settings.agents.title")} +

+

+ {t("settings.agents.description")} +

+
+ +

+ + unsloth start + {" "} + {t("settings.agents.intro")} +

+ + + + {t("settings.agents.readDocs")} + + + + +
+ +
+
+ + +
+ {SUPPORTED_AGENTS.map((agent) => ( +
+
+ + + {agent.name} + + {detected.has(agent.id) ? ( + + {t("settings.agents.quickstart.installed")} + + ) : null} + {agent.id === "codex" && !isGguf ? ( + + {t("settings.agents.supportedAgents.requiresGguf")} + + ) : null} +
+ +
+ ))} +
+ {visibleInfo !== null && detected.size === 0 ? ( +

+ {t("settings.agents.quickstart.noneDetected")} +

+ ) : null} +
+ + +
+
+ + {t("settings.agents.models.suffixLabel")} + + +
+
+ + {t("settings.agents.models.variantLabel")} + + +
+
+
+ + +
+ {OPTION_ROWS.map((row) => ( +
+ + {row.flag} + + + {t(row.descKey)} + +
+ ))} +
+
+ + +
+ +
+
+ + +
+ {PASSTHROUGH_EXAMPLES.map(({ agent, flags }) => ( + + ))} +
+
+ + +
+ +
+
+
+ ); +} diff --git a/studio/frontend/src/i18n/locales/en.ts b/studio/frontend/src/i18n/locales/en.ts index 2e6571c300..491fc8b18f 100644 --- a/studio/frontend/src/i18n/locales/en.ts +++ b/studio/frontend/src/i18n/locales/en.ts @@ -101,6 +101,7 @@ export const en = { connections: "Connections", data: "Data", apiKeys: "API", + agents: "Agents", about: "About", }, voice: { @@ -580,6 +581,67 @@ export const en = { unknown: "Unknown", }, }, + agents: { + title: "Agents (unsloth start)", + description: + "Connect coding agents like Claude Code and Codex to a model running locally in Unsloth.", + intro: + "connects Claude Code, Codex, Hermes, OpenClaw, OpenCode, Pi and other agents to a model served locally by Unsloth, fully offline on your own hardware. It runs a OpenAI-compatible server for the agent and never touches your agent's config files.", + readDocs: "Read the docs", + copy: "Copy", + copied: "Copied", + quickstart: { + title: "Quickstart", + description: + "Launch an agent against the model currently loaded in Studio. Load a model first, then swap claude for any supported agent below.", + noneDetected: "No supported agent CLIs were found on your PATH.", + installed: "Installed", + }, + supportedAgents: { + title: "Supported agents", + description: "Each agent launches with its own command:", + requiresGguf: "Needs a GGUF model", + }, + models: { + title: "Choosing a model", + description: + "Pass --model to pick a model and quantization, and --context-length to set the window. Use a quantization suffix, or an explicit --gguf-variant flag.", + suffixLabel: "With a quantization suffix", + variantLabel: "With an explicit variant flag", + }, + options: { + title: "Common options", + description: + "Unsloth flags are parsed first; anything it doesn't recognize is passed straight through to the agent.", + model: + "Select a model. Without --model, unsloth start uses the model currently loaded in Studio and errors if none is loaded.", + contextLength: + "Set the requested context length (alias: --max-seq-length).", + ggufVariant: "Choose the GGUF quantization variant.", + loadIn4bit: "Toggle 4-bit loading for Hugging Face models.", + tensorParallel: "Toggle tensor-parallel across multiple GPUs.", + serve: "Enable or disable the automatic local server.", + launch: "Launch the agent, or just print the command and environment.", + persist: "Keep Unsloth-managed agent storage between runs.", + apiKey: "Provide your Unsloth API key (or set UNSLOTH_API_KEY).", + yolo: "Skip approval prompts. Use only in trusted environments.", + }, + remote: { + title: "Connect to a remote Studio", + description: + "Point unsloth start at a Studio running elsewhere by setting these before launching (or pass --api-key directly):", + }, + passthrough: { + title: "Passing agent arguments", + description: + "Arguments after the Unsloth flags are forwarded to the agent itself, so native commands like resume still work:", + }, + dryRun: { + title: "Preview without launching", + description: + "Add --no-launch to print the environment and command instead of launching the agent. If --model is set, the model may still be resolved and loaded.", + }, + }, chat: { title: "Chat", description: "Customize how chat behaves on this device.",