From cbb6f98b9d39d0478c5da564c7012959d3738865 Mon Sep 17 00:00:00 2001 From: null-crafter <163591881+null-crafter@users.noreply.github.com> Date: Tue, 7 Jul 2026 21:41:15 +0000 Subject: [PATCH 01/23] feat: add in-chat computer use toggle --- .gitignore | 6 ++ .../_components/nav-bar/user-profile.tsx | 17 +----- components/chat-input-form/inside-buttons.tsx | 59 ++++++++++++++++++- messages/en.json | 3 + messages/es.json | 3 + messages/fr.json | 3 + messages/zh.json | 3 + package.json | 2 +- pnpm-lock.yaml | 30 +++++----- 9 files changed, 93 insertions(+), 33 deletions(-) diff --git a/.gitignore b/.gitignore index 3c4dc78b..01bda2c8 100644 --- a/.gitignore +++ b/.gitignore @@ -85,3 +85,9 @@ e2e/playwright-report e2e/test-results.json playwright/.auth e2e/playwright/.auth + + +# harnesses +.omo +.omp +.pi diff --git a/app/platform/[tenantKey]/[mentorId]/_components/nav-bar/user-profile.tsx b/app/platform/[tenantKey]/[mentorId]/_components/nav-bar/user-profile.tsx index 231e5c20..a27aaeb1 100644 --- a/app/platform/[tenantKey]/[mentorId]/_components/nav-bar/user-profile.tsx +++ b/app/platform/[tenantKey]/[mentorId]/_components/nav-bar/user-profile.tsx @@ -1,12 +1,6 @@ 'use client'; -import { - useEffect, - useState, - useCallback, - useRef, - type ComponentProps, -} from 'react'; +import { useEffect, useState, useCallback, useRef } from 'react'; import { useParams, useRouter, @@ -361,15 +355,6 @@ export function UserProfile() { onResetState: resetState, onSelectFoundryModel, }} - // System Control (Computer Assistant): configure the size gate to 13 GB. - // The dropdown self-wires the rest of systemControlProps via useGhostOs; - // the SDK types the object as fully required, but each field falls back at - // runtime, so we intentionally pass only the gate. - systemControlProps={ - { requiredSizeGb: 13 } as unknown as NonNullable< - ComponentProps['systemControlProps'] - > - } // Controlled modal state for URL sync isModalOpen={isProfileModalOpen} onModalOpenChange={handleModalOpenChange} diff --git a/components/chat-input-form/inside-buttons.tsx b/components/chat-input-form/inside-buttons.tsx index 23c0c5f8..908e3ebe 100644 --- a/components/chat-input-form/inside-buttons.tsx +++ b/components/chat-input-form/inside-buttons.tsx @@ -15,12 +15,35 @@ import { PopoverAnchor, PopoverContent, } from '@/components/ui/popover'; -import { X, BookOpen, Archive, Check, Terminal } from 'lucide-react'; +import { X, BookOpen, Archive, Check, Terminal, Monitor } from 'lucide-react'; +import { toast } from 'sonner'; import { DeepSearchIcon, CanvasIcon } from '@/components/icons/svg-icons'; import { TOOLS } from '@iblai/iblai-js/web-utils'; +import { + useGhostOs, + isSystemControlEnabled, + setSystemControlEnabled, + isLocalLLMEnabled, + getLocalLLMModel, + modelSupportsSystemControl, +} from '@iblai/iblai-js/web-containers'; import { MemoryButton } from './memory-button'; import { MemoryMenu } from './memory-menu'; +// Computer Use is macOS-only in prod; the env flag bypasses the OS check so the +// toggle can be exercised on Linux/Windows desktop builds during testing. +const isMacOS = () => { + if (typeof navigator === 'undefined') return false; + return /mac/i.test(navigator.userAgent || ''); +}; +const allowNonMacOSComputerUse = () => + process.env.NEXT_PUBLIC_ALLOW_NON_MACOS_COMPUTER_USE_TOGGLE === 'true'; + +// 12GB floor, matching the SDK default (DEFAULT_SYSTEM_CONTROL_REQUIRED_SIZE_GB) +// and the Local Models tab's "supported" indicator. modelSupportsSystemControl +// gates size <= gb (strictly greater), so a model of exactly 12GB is also off. +const COMPUTER_USE_MIN_MODEL_GB = 12; + interface InsideButtonsProps { activeOptions: string[]; onOptionClick: (optionName: string) => Promise; @@ -53,6 +76,32 @@ export const InsideButtons = ({ username, }: InsideButtonsProps) => { const t = useTranslations('chatInputFormInsideButtons'); + + // Computer Use = the Tauri GhostOS assistant. Same calls as the old profile + // "Computer Assistant" toggle (useGhostOs install/stop + localStorage pref), + // no backend round-trip. Reads the pref on mount; cross-tab sync not polled. + const ghostOs = useGhostOs(); + const [computerUseEnabled, setComputerUseEnabled] = useState(isSystemControlEnabled); + const toggleComputerUse = () => { + const next = !computerUseEnabled; + // Guards only when turning on (mirrors the old profile toggle). The chatbox + // has no inline notice space, so remind via toast instead of failing silently. + if (next) { + if (!isLocalLLMEnabled()) { + toast.warning(t('computerUseNeedsLocalModel')); + return; + } + if (!modelSupportsSystemControl(getLocalLLMModel(), COMPUTER_USE_MIN_MODEL_GB)) { + toast.warning(t('computerUseModelTooSmall')); + return; + } + } + setComputerUseEnabled(next); + setSystemControlEnabled(next); + if (next) ghostOs.install(); + else ghostOs.stop(); + }; + const allInsideButtons = [ { name: 'Canvas', @@ -96,6 +145,14 @@ export const InsideButtons = ({ action: /* istanbul ignore next */ () => onOptionClick(TOOLS.MEMORY), isEnabled: memoryEnabled && !embedMode && !!username, }, + { + name: 'Computer Use', + label: t('computerUse'), + icon: , + isActive: computerUseEnabled, + action: toggleComputerUse, + isEnabled: ghostOs.isAvailable && (isMacOS() || allowNonMacOSComputerUse()), + }, ].filter((item) => item.isEnabled); // Get visible inside buttons based on screen size. diff --git a/messages/en.json b/messages/en.json index 3421f88f..1425d7d2 100644 --- a/messages/en.json +++ b/messages/en.json @@ -457,6 +457,9 @@ "studyMode": "Study Mode", "deepResearch": "Deep Research", "memory": "Memory", + "computerUse": "Computer Use", + "computerUseNeedsLocalModel": "Turn on “Local Models” first — Computer Use needs a local AI model.", + "computerUseModelTooSmall": "Your local model is too small for Computer Use. Pick a model of at least 12GB in Local Models.", "moreOptions": "More options" }, "chatInputFormMemoryMenu": { diff --git a/messages/es.json b/messages/es.json index f6174870..605d7f6a 100644 --- a/messages/es.json +++ b/messages/es.json @@ -457,6 +457,9 @@ "studyMode": "Modo de estudio", "deepResearch": "Investigación profunda", "memory": "Memoria", + "computerUse": "Uso del Ordenador", + "computerUseNeedsLocalModel": "Activa primero «Modelos Locales»: el Uso del Ordenador necesita un modelo de IA local.", + "computerUseModelTooSmall": "Tu modelo local es demasiado pequeño para el Uso del Ordenador. Elige un modelo de al menos 12 GB en Modelos Locales.", "moreOptions": "Más opciones" }, "chatInputFormMemoryMenu": { diff --git a/messages/fr.json b/messages/fr.json index 7f027966..ff0e18b4 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -457,6 +457,9 @@ "studyMode": "Mode étude", "deepResearch": "Recherche approfondie", "memory": "Mémoire", + "computerUse": "Utilisation de l'ordinateur", + "computerUseNeedsLocalModel": "Activez d’abord « Modèles Locaux » — l’Utilisation de l’ordinateur nécessite un modèle d’IA local.", + "computerUseModelTooSmall": "Votre modèle local est trop petit pour l’Utilisation de l’ordinateur. Choisissez un modèle d’au moins 12 Go dans Modèles Locaux.", "moreOptions": "Plus d'options" }, "chatInputFormMemoryMenu": { diff --git a/messages/zh.json b/messages/zh.json index bec005b3..0e21fc95 100644 --- a/messages/zh.json +++ b/messages/zh.json @@ -457,6 +457,9 @@ "studyMode": "学习模式", "deepResearch": "深度研究", "memory": "记忆", + "computerUse": "电脑操作", + "computerUseNeedsLocalModel": "请先开启“本地模型”——电脑操作需要本地 AI 模型。", + "computerUseModelTooSmall": "您的本地模型太小,无法用于电脑操作。请在“本地模型”中选择至少 12GB 的模型。", "moreOptions": "更多选项" }, "chatInputFormMemoryMenu": { diff --git a/package.json b/package.json index 53720ad6..9ec9efb6 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,7 @@ "dependencies": { "@iblai/agent-ai": "2.5.2", "@iblai/iblai-api": "4.166.0-ai", - "@iblai/iblai-js": "1.22.4", + "@iblai/iblai-js": "1.22.5-computer-use-1.1", "@iblai/iblai-web-mentor": "1.3.4", "@livekit/components-react": "2.8.1", "@livekit/components-styles": "1.1.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dcbfba18..2508d740 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -44,8 +44,8 @@ importers: specifier: 4.166.0-ai version: 4.166.0-ai '@iblai/iblai-js': - specifier: 1.22.4 - version: 1.22.4(91917b6c2cef637bc1f74c6f4e24fa62) + specifier: 1.22.5-computer-use-1.1 + version: 1.22.5-computer-use-1.1(91917b6c2cef637bc1f74c6f4e24fa62) '@iblai/iblai-web-mentor': specifier: 1.3.4 version: 1.3.4(@babel/core@7.29.7)(@types/babel__core@7.20.5)(rollup@4.62.0)(typescript@5.9.3) @@ -1016,8 +1016,8 @@ packages: '@iblai/iblai-api@4.166.0-ai': resolution: {integrity: sha512-dY9Jv+CkXEyTxRsOQFay5YvYe8K2LSQluJJvtssGQV2RbrxPPzONaKfnhcyarDs7Ft35Xqk1fuErjhUwNG3OIg==} - '@iblai/iblai-js@1.22.4': - resolution: {integrity: sha512-yH+Gtp82Xo6/6NMyUiSqDgA8IXij2b7K2qTgy7+VtEEiZkREFK6t1ihH7HMk8P186aYBbP/E9sUUhJCZhj+mqg==} + '@iblai/iblai-js@1.22.5-computer-use-1.1': + resolution: {integrity: sha512-awllZRg1puzdCpc3S/13OjxBEK7M42zft8LGFSoQQ9Dpy9DQ6wq2z2NEjnaDpX3CIiV6fkYYGfAHCEUsB6+m4Q==} engines: {node: '>=20.0.0'} hasBin: true peerDependencies: @@ -1046,13 +1046,13 @@ packages: engines: {node: '>=20.0.0'} hasBin: true - '@iblai/web-containers@1.12.2': - resolution: {integrity: sha512-BNi/WmvXcaFGy1P1ttAdoSE6tm7qIGeDwMaribpu11hQYhy1QzIuyMS7SwFELINfUgxQk7IBiGf3jZlu+4a2mg==} + '@iblai/web-containers@1.12.2-computer-use-1.1': + resolution: {integrity: sha512-+nSDcvzOhaRW6WWOBDzLjZDyEwD1fiaTkXgCSV/0uvk3XGC9Sad8I3Rt7KQquFMVceCiPJDbQn5EGLC3X9BpXg==} engines: {node: 25.3.0} peerDependencies: '@iblai/data-layer': 1.9.2 '@iblai/iblai-api': 4.166.0-ai - '@iblai/web-utils': 1.11.11 + '@iblai/web-utils': 1.11.12 '@livekit/components-react': 2.8.1 '@radix-ui/react-dialog': ^1.1.7 '@reduxjs/toolkit': 2.7.0 @@ -1072,8 +1072,8 @@ packages: sonner: optional: true - '@iblai/web-utils@1.11.11': - resolution: {integrity: sha512-TvZqYd63OpbsN/emXVQue5bMXg6Va1v6TlMPZyZ0eT1w61Gi0Ka+SfwFXS1MAuT7vmGILXxzBE1hoMBtEEnSOg==} + '@iblai/web-utils@1.11.12': + resolution: {integrity: sha512-7f+getyolEwBNB/DKTX3XLvm1zxD2okToLowytp3G0xBPlIfMcIUdDuIW4je5LcGvy64tKB568kir+aXaH7v1g==} engines: {node: 25.3.0} peerDependencies: '@iblai/data-layer': ^1.1.2 @@ -9993,13 +9993,13 @@ snapshots: dependencies: tslib: 2.8.1 - '@iblai/iblai-js@1.22.4(91917b6c2cef637bc1f74c6f4e24fa62)': + '@iblai/iblai-js@1.22.5-computer-use-1.1(91917b6c2cef637bc1f74c6f4e24fa62)': dependencies: '@iblai/data-layer': 1.9.2(@reduxjs/toolkit@2.7.0(react-redux@9.2.0(@types/react@19.1.17)(react@19.1.0)(redux@5.0.1))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react-redux@9.2.0(@types/react@19.1.17)(react@19.1.0)(redux@5.0.1))(react@19.1.0) '@iblai/iblai-api': 4.166.0-ai '@iblai/mcp': 1.7.8 - '@iblai/web-containers': 1.12.2(48baafe1ac52e7091bf5fce1d50fb0ab) - '@iblai/web-utils': 1.11.11(@iblai/data-layer@1.9.2(@reduxjs/toolkit@2.7.0(react-redux@9.2.0(@types/react@19.1.17)(react@19.1.0)(redux@5.0.1))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react-redux@9.2.0(@types/react@19.1.17)(react@19.1.0)(redux@5.0.1))(react@19.1.0))(@iblai/iblai-api@4.166.0-ai)(@tauri-apps/api@2.11.0)(@tauri-apps/plugin-os@2.3.2)(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(redux@5.0.1)(sonner@2.0.7(react-dom@19.1.0(react@19.1.0))(react@19.1.0)) + '@iblai/web-containers': 1.12.2-computer-use-1.1(1dd545cac6026f5e21a258f8e0ce4830) + '@iblai/web-utils': 1.11.12(@iblai/data-layer@1.9.2(@reduxjs/toolkit@2.7.0(react-redux@9.2.0(@types/react@19.1.17)(react@19.1.0)(redux@5.0.1))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react-redux@9.2.0(@types/react@19.1.17)(react@19.1.0)(redux@5.0.1))(react@19.1.0))(@iblai/iblai-api@4.166.0-ai)(@tauri-apps/api@2.11.0)(@tauri-apps/plugin-os@2.3.2)(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(redux@5.0.1)(sonner@2.0.7(react-dom@19.1.0(react@19.1.0))(react@19.1.0)) '@radix-ui/react-dialog': 1.1.7(@types/react-dom@19.2.1(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@reduxjs/toolkit': 2.7.0(react-redux@9.2.0(@types/react@19.1.17)(react@19.1.0)(redux@5.0.1))(react@19.1.0) axios: 1.18.1 @@ -10048,11 +10048,11 @@ snapshots: - '@cfworker/json-schema' - supports-color - '@iblai/web-containers@1.12.2(48baafe1ac52e7091bf5fce1d50fb0ab)': + '@iblai/web-containers@1.12.2-computer-use-1.1(1dd545cac6026f5e21a258f8e0ce4830)': dependencies: '@iblai/data-layer': 1.9.2(@reduxjs/toolkit@2.7.0(react-redux@9.2.0(@types/react@19.1.17)(react@19.1.0)(redux@5.0.1))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react-redux@9.2.0(@types/react@19.1.17)(react@19.1.0)(redux@5.0.1))(react@19.1.0) '@iblai/iblai-api': 4.166.0-ai - '@iblai/web-utils': 1.11.11(@iblai/data-layer@1.9.2(@reduxjs/toolkit@2.7.0(react-redux@9.2.0(@types/react@19.1.17)(react@19.1.0)(redux@5.0.1))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react-redux@9.2.0(@types/react@19.1.17)(react@19.1.0)(redux@5.0.1))(react@19.1.0))(@iblai/iblai-api@4.166.0-ai)(@tauri-apps/api@2.11.0)(@tauri-apps/plugin-os@2.3.2)(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(redux@5.0.1)(sonner@2.0.7(react-dom@19.1.0(react@19.1.0))(react@19.1.0)) + '@iblai/web-utils': 1.11.12(@iblai/data-layer@1.9.2(@reduxjs/toolkit@2.7.0(react-redux@9.2.0(@types/react@19.1.17)(react@19.1.0)(redux@5.0.1))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react-redux@9.2.0(@types/react@19.1.17)(react@19.1.0)(redux@5.0.1))(react@19.1.0))(@iblai/iblai-api@4.166.0-ai)(@tauri-apps/api@2.11.0)(@tauri-apps/plugin-os@2.3.2)(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(redux@5.0.1)(sonner@2.0.7(react-dom@19.1.0(react@19.1.0))(react@19.1.0)) '@radix-ui/react-accordion': 1.2.8(@types/react-dom@19.2.1(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-avatar': 1.1.7(@types/react-dom@19.2.1(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-checkbox': 1.2.3(@types/react-dom@19.2.1(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) @@ -10134,7 +10134,7 @@ snapshots: - supports-color - vinxi - '@iblai/web-utils@1.11.11(@iblai/data-layer@1.9.2(@reduxjs/toolkit@2.7.0(react-redux@9.2.0(@types/react@19.1.17)(react@19.1.0)(redux@5.0.1))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react-redux@9.2.0(@types/react@19.1.17)(react@19.1.0)(redux@5.0.1))(react@19.1.0))(@iblai/iblai-api@4.166.0-ai)(@tauri-apps/api@2.11.0)(@tauri-apps/plugin-os@2.3.2)(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(redux@5.0.1)(sonner@2.0.7(react-dom@19.1.0(react@19.1.0))(react@19.1.0))': + '@iblai/web-utils@1.11.12(@iblai/data-layer@1.9.2(@reduxjs/toolkit@2.7.0(react-redux@9.2.0(@types/react@19.1.17)(react@19.1.0)(redux@5.0.1))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react-redux@9.2.0(@types/react@19.1.17)(react@19.1.0)(redux@5.0.1))(react@19.1.0))(@iblai/iblai-api@4.166.0-ai)(@tauri-apps/api@2.11.0)(@tauri-apps/plugin-os@2.3.2)(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(redux@5.0.1)(sonner@2.0.7(react-dom@19.1.0(react@19.1.0))(react@19.1.0))': dependencies: '@iblai/data-layer': 1.9.2(@reduxjs/toolkit@2.7.0(react-redux@9.2.0(@types/react@19.1.17)(react@19.1.0)(redux@5.0.1))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react-redux@9.2.0(@types/react@19.1.17)(react@19.1.0)(redux@5.0.1))(react@19.1.0) '@iblai/iblai-api': 4.166.0-ai From 796088dac57913ce45e59c857308dded904c43dc Mon Sep 17 00:00:00 2001 From: null-crafter <163591881+null-crafter@users.noreply.github.com> Date: Wed, 8 Jul 2026 20:13:04 +0000 Subject: [PATCH 02/23] feat: enable remote AI chat + MCP tools for computer use --- package.json | 2 +- pnpm-lock.yaml | 157 +++++++++++++++++++++++----- src-tauri/capabilities/default.json | 6 ++ 3 files changed, 139 insertions(+), 26 deletions(-) diff --git a/package.json b/package.json index 9ec9efb6..411c50c7 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,7 @@ "dependencies": { "@iblai/agent-ai": "2.5.2", "@iblai/iblai-api": "4.166.0-ai", - "@iblai/iblai-js": "1.22.5-computer-use-1.1", + "@iblai/iblai-js": "1.22.5-computer-use-1.11", "@iblai/iblai-web-mentor": "1.3.4", "@livekit/components-react": "2.8.1", "@livekit/components-styles": "1.1.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2508d740..e5861849 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -44,8 +44,8 @@ importers: specifier: 4.166.0-ai version: 4.166.0-ai '@iblai/iblai-js': - specifier: 1.22.5-computer-use-1.1 - version: 1.22.5-computer-use-1.1(91917b6c2cef637bc1f74c6f4e24fa62) + specifier: 1.22.5-computer-use-1.11 + version: 1.22.5-computer-use-1.11(91917b6c2cef637bc1f74c6f4e24fa62) '@iblai/iblai-web-mentor': specifier: 1.3.4 version: 1.3.4(@babel/core@7.29.7)(@types/babel__core@7.20.5)(rollup@4.62.0)(typescript@5.9.3) @@ -503,6 +503,28 @@ packages: '@adobe/css-tools@4.5.0': resolution: {integrity: sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==} + '@ai-sdk/gateway@4.0.13': + resolution: {integrity: sha512-EIiMZZMEAc3yZ3nywrJfKigrkaNwqrQgGsj0QbQ6x3Y73MlQe68EElR2cm/8I5dvBci3PFyahMRe3NwR8OOb9A==} + engines: {node: '>=22'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + '@ai-sdk/openai-compatible@3.0.5': + resolution: {integrity: sha512-4UtDxT6Ga7U225o5fBEgtCFZHba4/iTDXRqMrU62yEfTrFks4o+F4jt0D3OWmUasR9LPFzLy0KnEITxFDtc89g==} + engines: {node: '>=22'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + '@ai-sdk/provider-utils@5.0.5': + resolution: {integrity: sha512-oI0t3dvCoqWNV1I8o1Rybi2DXDvHES5r/TrwtJW90tuFLVepgJlftPxrcjh8vaSvjqC2diTuA2vXyjKAyHJm4A==} + engines: {node: '>=22'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + '@ai-sdk/provider@4.0.2': + resolution: {integrity: sha512-pfPoy9J1B1xV7cqJ8MYHOsDYrMv5tR3+EMNfI249OhkD2uRakvav3Fo7XpD2luuN/YNCBY7KfEQc7vEV7KEtyw==} + engines: {node: '>=22'} + '@alloc/quick-lru@5.2.0': resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} @@ -1016,8 +1038,8 @@ packages: '@iblai/iblai-api@4.166.0-ai': resolution: {integrity: sha512-dY9Jv+CkXEyTxRsOQFay5YvYe8K2LSQluJJvtssGQV2RbrxPPzONaKfnhcyarDs7Ft35Xqk1fuErjhUwNG3OIg==} - '@iblai/iblai-js@1.22.5-computer-use-1.1': - resolution: {integrity: sha512-awllZRg1puzdCpc3S/13OjxBEK7M42zft8LGFSoQQ9Dpy9DQ6wq2z2NEjnaDpX3CIiV6fkYYGfAHCEUsB6+m4Q==} + '@iblai/iblai-js@1.22.5-computer-use-1.11': + resolution: {integrity: sha512-jAzBwhslWLBSslwcE1622O0tpTNov8LhuoE4dX0SxCSWkZ6LFCd5Pnq3ikQF79HEYuomuPHlxIeLFGHu2Ci+AQ==} engines: {node: '>=20.0.0'} hasBin: true peerDependencies: @@ -1046,13 +1068,13 @@ packages: engines: {node: '>=20.0.0'} hasBin: true - '@iblai/web-containers@1.12.2-computer-use-1.1': - resolution: {integrity: sha512-+nSDcvzOhaRW6WWOBDzLjZDyEwD1fiaTkXgCSV/0uvk3XGC9Sad8I3Rt7KQquFMVceCiPJDbQn5EGLC3X9BpXg==} + '@iblai/web-containers@1.12.2-computer-use-1.11': + resolution: {integrity: sha512-9MA+jqWznobFZFA2SdmVaRa1MgZ/9jFMriKxL2vLQFXgO21Q7WD3TZmz2nwC/eMACc9Nuh9UHh+/w9dtbMli0g==} engines: {node: 25.3.0} peerDependencies: '@iblai/data-layer': 1.9.2 '@iblai/iblai-api': 4.166.0-ai - '@iblai/web-utils': 1.11.12 + '@iblai/web-utils': 1.11.12-computer-use-1.11 '@livekit/components-react': 2.8.1 '@radix-ui/react-dialog': ^1.1.7 '@reduxjs/toolkit': 2.7.0 @@ -1072,8 +1094,8 @@ packages: sonner: optional: true - '@iblai/web-utils@1.11.12': - resolution: {integrity: sha512-7f+getyolEwBNB/DKTX3XLvm1zxD2okToLowytp3G0xBPlIfMcIUdDuIW4je5LcGvy64tKB568kir+aXaH7v1g==} + '@iblai/web-utils@1.11.12-computer-use-1.11': + resolution: {integrity: sha512-KvLbcZzRnbo4nheNU0Ob0WOjOkD85npyc/u1oq6QLE7LGMicipAjxxLnATXSVw70YWnhrnicabDSf/TJ5Tes2g==} engines: {node: 25.3.0} peerDependencies: '@iblai/data-layer': ^1.1.2 @@ -4560,6 +4582,10 @@ packages: cpu: [x64] os: [win32] + '@vercel/oidc@3.2.0': + resolution: {integrity: sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==} + engines: {node: '>= 20'} + '@vitejs/plugin-react@5.1.2': resolution: {integrity: sha512-EcA07pHJouywpzsoTUqNh5NwGayl2PPVEJKUSinGGSxFGYn+shYbqMGBg6FXDqgXum9Ou/ecb+411ssw8HImJQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -4650,6 +4676,9 @@ packages: '@webassemblyjs/wast-printer@1.14.1': resolution: {integrity: sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==} + '@workflow/serde@4.1.0': + resolution: {integrity: sha512-pav4F2BoirECWR7Nf1TKt+2eETcBj7jj4cBefQ8VXQCA6NPkaKeLfj/zMgi+3zYV5ZIBT4GuUiphsj0/b9hPQQ==} + '@xtuc/ieee754@1.2.0': resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==} @@ -4693,6 +4722,12 @@ packages: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} + ai@7.0.17: + resolution: {integrity: sha512-kpIjNtN0tyUaxzkAs7b8YF48WZzc71dKu0HqFPSAWg/avu1Ogv50UPE9hpVMr+kb33zsEa6LrAU9DZHMNscvzw==} + engines: {node: '>=22'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + ajv-formats@2.1.1: resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==} peerDependencies: @@ -6799,6 +6834,9 @@ packages: json-schema-typed@8.0.2: resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} + json-schema@0.4.0: + resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==} + json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} @@ -9458,12 +9496,12 @@ packages: zod@3.24.2: resolution: {integrity: sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ==} + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + zod@4.3.6: resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} - zod@4.4.3: - resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} - zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} @@ -9471,6 +9509,31 @@ snapshots: '@adobe/css-tools@4.5.0': {} + '@ai-sdk/gateway@4.0.13(zod@3.25.76)': + dependencies: + '@ai-sdk/provider': 4.0.2 + '@ai-sdk/provider-utils': 5.0.5(zod@3.25.76) + '@vercel/oidc': 3.2.0 + zod: 3.25.76 + + '@ai-sdk/openai-compatible@3.0.5(zod@3.25.76)': + dependencies: + '@ai-sdk/provider': 4.0.2 + '@ai-sdk/provider-utils': 5.0.5(zod@3.25.76) + zod: 3.25.76 + + '@ai-sdk/provider-utils@5.0.5(zod@3.25.76)': + dependencies: + '@ai-sdk/provider': 4.0.2 + '@standard-schema/spec': 1.1.0 + '@workflow/serde': 4.1.0 + eventsource-parser: 3.1.0 + zod: 3.25.76 + + '@ai-sdk/provider@4.0.2': + dependencies: + json-schema: 0.4.0 + '@alloc/quick-lru@5.2.0': {} '@apm-js-collab/code-transformer-bundler-plugins@0.5.0': @@ -9993,13 +10056,13 @@ snapshots: dependencies: tslib: 2.8.1 - '@iblai/iblai-js@1.22.5-computer-use-1.1(91917b6c2cef637bc1f74c6f4e24fa62)': + '@iblai/iblai-js@1.22.5-computer-use-1.11(91917b6c2cef637bc1f74c6f4e24fa62)': dependencies: '@iblai/data-layer': 1.9.2(@reduxjs/toolkit@2.7.0(react-redux@9.2.0(@types/react@19.1.17)(react@19.1.0)(redux@5.0.1))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react-redux@9.2.0(@types/react@19.1.17)(react@19.1.0)(redux@5.0.1))(react@19.1.0) '@iblai/iblai-api': 4.166.0-ai '@iblai/mcp': 1.7.8 - '@iblai/web-containers': 1.12.2-computer-use-1.1(1dd545cac6026f5e21a258f8e0ce4830) - '@iblai/web-utils': 1.11.12(@iblai/data-layer@1.9.2(@reduxjs/toolkit@2.7.0(react-redux@9.2.0(@types/react@19.1.17)(react@19.1.0)(redux@5.0.1))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react-redux@9.2.0(@types/react@19.1.17)(react@19.1.0)(redux@5.0.1))(react@19.1.0))(@iblai/iblai-api@4.166.0-ai)(@tauri-apps/api@2.11.0)(@tauri-apps/plugin-os@2.3.2)(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(redux@5.0.1)(sonner@2.0.7(react-dom@19.1.0(react@19.1.0))(react@19.1.0)) + '@iblai/web-containers': 1.12.2-computer-use-1.11(91dd1c75d223b12bcec8a56eae208ce6) + '@iblai/web-utils': 1.11.12-computer-use-1.11(@iblai/data-layer@1.9.2(@reduxjs/toolkit@2.7.0(react-redux@9.2.0(@types/react@19.1.17)(react@19.1.0)(redux@5.0.1))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react-redux@9.2.0(@types/react@19.1.17)(react@19.1.0)(redux@5.0.1))(react@19.1.0))(@iblai/iblai-api@4.166.0-ai)(@tauri-apps/api@2.11.0)(@tauri-apps/plugin-os@2.3.2)(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(redux@5.0.1)(sonner@2.0.7(react-dom@19.1.0(react@19.1.0))(react@19.1.0)) '@radix-ui/react-dialog': 1.1.7(@types/react-dom@19.2.1(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@reduxjs/toolkit': 2.7.0(react-redux@9.2.0(@types/react@19.1.17)(react@19.1.0)(redux@5.0.1))(react@19.1.0) axios: 1.18.1 @@ -10048,11 +10111,11 @@ snapshots: - '@cfworker/json-schema' - supports-color - '@iblai/web-containers@1.12.2-computer-use-1.1(1dd545cac6026f5e21a258f8e0ce4830)': + '@iblai/web-containers@1.12.2-computer-use-1.11(91dd1c75d223b12bcec8a56eae208ce6)': dependencies: '@iblai/data-layer': 1.9.2(@reduxjs/toolkit@2.7.0(react-redux@9.2.0(@types/react@19.1.17)(react@19.1.0)(redux@5.0.1))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react-redux@9.2.0(@types/react@19.1.17)(react@19.1.0)(redux@5.0.1))(react@19.1.0) '@iblai/iblai-api': 4.166.0-ai - '@iblai/web-utils': 1.11.12(@iblai/data-layer@1.9.2(@reduxjs/toolkit@2.7.0(react-redux@9.2.0(@types/react@19.1.17)(react@19.1.0)(redux@5.0.1))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react-redux@9.2.0(@types/react@19.1.17)(react@19.1.0)(redux@5.0.1))(react@19.1.0))(@iblai/iblai-api@4.166.0-ai)(@tauri-apps/api@2.11.0)(@tauri-apps/plugin-os@2.3.2)(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(redux@5.0.1)(sonner@2.0.7(react-dom@19.1.0(react@19.1.0))(react@19.1.0)) + '@iblai/web-utils': 1.11.12-computer-use-1.11(@iblai/data-layer@1.9.2(@reduxjs/toolkit@2.7.0(react-redux@9.2.0(@types/react@19.1.17)(react@19.1.0)(redux@5.0.1))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react-redux@9.2.0(@types/react@19.1.17)(react@19.1.0)(redux@5.0.1))(react@19.1.0))(@iblai/iblai-api@4.166.0-ai)(@tauri-apps/api@2.11.0)(@tauri-apps/plugin-os@2.3.2)(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(redux@5.0.1)(sonner@2.0.7(react-dom@19.1.0(react@19.1.0))(react@19.1.0)) '@radix-ui/react-accordion': 1.2.8(@types/react-dom@19.2.1(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-avatar': 1.1.7(@types/react-dom@19.2.1(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-checkbox': 1.2.3(@types/react-dom@19.2.1(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) @@ -10134,11 +10197,15 @@ snapshots: - supports-color - vinxi - '@iblai/web-utils@1.11.12(@iblai/data-layer@1.9.2(@reduxjs/toolkit@2.7.0(react-redux@9.2.0(@types/react@19.1.17)(react@19.1.0)(redux@5.0.1))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react-redux@9.2.0(@types/react@19.1.17)(react@19.1.0)(redux@5.0.1))(react@19.1.0))(@iblai/iblai-api@4.166.0-ai)(@tauri-apps/api@2.11.0)(@tauri-apps/plugin-os@2.3.2)(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(redux@5.0.1)(sonner@2.0.7(react-dom@19.1.0(react@19.1.0))(react@19.1.0))': + '@iblai/web-utils@1.11.12-computer-use-1.11(@iblai/data-layer@1.9.2(@reduxjs/toolkit@2.7.0(react-redux@9.2.0(@types/react@19.1.17)(react@19.1.0)(redux@5.0.1))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react-redux@9.2.0(@types/react@19.1.17)(react@19.1.0)(redux@5.0.1))(react@19.1.0))(@iblai/iblai-api@4.166.0-ai)(@tauri-apps/api@2.11.0)(@tauri-apps/plugin-os@2.3.2)(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(redux@5.0.1)(sonner@2.0.7(react-dom@19.1.0(react@19.1.0))(react@19.1.0))': dependencies: + '@ai-sdk/openai-compatible': 3.0.5(zod@3.25.76) '@iblai/data-layer': 1.9.2(@reduxjs/toolkit@2.7.0(react-redux@9.2.0(@types/react@19.1.17)(react@19.1.0)(redux@5.0.1))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react-redux@9.2.0(@types/react@19.1.17)(react@19.1.0)(redux@5.0.1))(react@19.1.0) '@iblai/iblai-api': 4.166.0-ai + '@modelcontextprotocol/sdk': 1.27.1(zod@3.25.76) '@reduxjs/toolkit': 2.11.2(react-redux@9.2.0(@types/react@19.1.17)(react@19.1.0)(redux@5.0.1))(react@19.1.0) + '@tauri-apps/plugin-shell': 2.3.4 + ai: 7.0.17(zod@3.25.76) axios: 1.18.1 dayjs: 1.11.20 jwt-decode: 4.0.0 @@ -10148,12 +10215,13 @@ snapshots: react-redux: 9.2.0(@types/react@19.1.17)(react@19.1.0)(redux@5.0.1) rollup: 4.59.0 rollup-plugin-copy: 3.5.0 - zod: 3.24.2 + zod: 3.25.76 optionalDependencies: '@tauri-apps/api': 2.11.0 '@tauri-apps/plugin-os': 2.3.2 sonner: 2.0.7(react-dom@19.1.0(react@19.1.0))(react@19.1.0) transitivePeerDependencies: + - '@cfworker/json-schema' - '@types/react' - debug - redux @@ -10442,6 +10510,28 @@ snapshots: dependencies: '@bufbuild/protobuf': 1.10.1 + '@modelcontextprotocol/sdk@1.27.1(zod@3.25.76)': + dependencies: + '@hono/node-server': 1.19.14(hono@4.12.25) + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + content-type: 1.0.5 + cors: 2.8.6 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.1.0 + express: 5.2.1 + express-rate-limit: 8.5.2(express@5.2.1) + hono: 4.12.25 + jose: 6.2.3 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.1 + raw-body: 3.0.2 + zod: 3.25.76 + zod-to-json-schema: 3.25.2(zod@3.25.76) + transitivePeerDependencies: + - supports-color + '@modelcontextprotocol/sdk@1.27.1(zod@4.3.6)': dependencies: '@hono/node-server': 1.19.14(hono@4.12.25) @@ -13451,6 +13541,8 @@ snapshots: '@unrs/resolver-binding-win32-x64-msvc@1.12.2': optional: true + '@vercel/oidc@3.2.0': {} + '@vitejs/plugin-react@5.1.2(vite@7.3.6(@types/node@20.17.48)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0))': dependencies: '@babel/core': 7.29.7 @@ -13608,6 +13700,8 @@ snapshots: '@webassemblyjs/ast': 1.14.1 '@xtuc/long': 4.2.2 + '@workflow/serde@4.1.0': {} + '@xtuc/ieee754@1.2.0': {} '@xtuc/long@4.2.2': {} @@ -13643,6 +13737,13 @@ snapshots: agent-base@7.1.4: {} + ai@7.0.17(zod@3.25.76): + dependencies: + '@ai-sdk/gateway': 4.0.13(zod@3.25.76) + '@ai-sdk/provider': 4.0.2 + '@ai-sdk/provider-utils': 5.0.5(zod@3.25.76) + zod: 3.25.76 + ajv-formats@2.1.1(ajv@8.20.0): optionalDependencies: ajv: 8.20.0 @@ -14829,8 +14930,8 @@ snapshots: '@babel/parser': 7.29.7 eslint: 9.39.4(jiti@2.7.0) hermes-parser: 0.25.1 - zod: 4.4.3 - zod-validation-error: 4.0.2(zod@4.4.3) + zod: 4.3.6 + zod-validation-error: 4.0.2(zod@4.3.6) transitivePeerDependencies: - supports-color @@ -16064,6 +16165,8 @@ snapshots: json-schema-typed@8.0.2: {} + json-schema@0.4.0: {} + json-stable-stringify-without-jsonify@1.0.1: {} json-with-bigint@3.5.8: {} @@ -19274,18 +19377,22 @@ snapshots: normalize-path: 3.0.0 readable-stream: 4.7.0 + zod-to-json-schema@3.25.2(zod@3.25.76): + dependencies: + zod: 3.25.76 + zod-to-json-schema@3.25.2(zod@4.3.6): dependencies: zod: 4.3.6 - zod-validation-error@4.0.2(zod@4.4.3): + zod-validation-error@4.0.2(zod@4.3.6): dependencies: - zod: 4.4.3 + zod: 4.3.6 zod@3.24.2: {} - zod@4.3.6: {} + zod@3.25.76: {} - zod@4.4.3: {} + zod@4.3.6: {} zwitch@2.0.4: {} diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index a722f9d9..820dba6b 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -52,6 +52,12 @@ "core:webview:allow-create-webview-window", "opener:default", "shell:allow-open", + { + "identifier": "shell:allow-spawn", + "allow": [{ "name": "ghost", "cmd": "ghost", "args": ["mcp"] }] + }, + "shell:allow-stdin-write", + "shell:allow-kill", "allow-open-external-url", "allow-navigate-to", "allow-get-os-type", From bfe6dd37828c741696b6a895aa56caea99a299b8 Mon Sep 17 00:00:00 2001 From: null-crafter <163591881+null-crafter@users.noreply.github.com> Date: Wed, 8 Jul 2026 20:53:33 +0000 Subject: [PATCH 03/23] feat: Computer Use no longer requires a local model --- components/chat-input-form/inside-buttons.tsx | 39 +++++++++++-------- 1 file changed, 22 insertions(+), 17 deletions(-) diff --git a/components/chat-input-form/inside-buttons.tsx b/components/chat-input-form/inside-buttons.tsx index 908e3ebe..a9fa0a0b 100644 --- a/components/chat-input-form/inside-buttons.tsx +++ b/components/chat-input-form/inside-buttons.tsx @@ -18,7 +18,7 @@ import { import { X, BookOpen, Archive, Check, Terminal, Monitor } from 'lucide-react'; import { toast } from 'sonner'; import { DeepSearchIcon, CanvasIcon } from '@/components/icons/svg-icons'; -import { TOOLS } from '@iblai/iblai-js/web-utils'; +import { TOOLS, hasRemoteAiConfig } from '@iblai/iblai-js/web-utils'; import { useGhostOs, isSystemControlEnabled, @@ -84,15 +84,20 @@ export const InsideButtons = ({ const [computerUseEnabled, setComputerUseEnabled] = useState(isSystemControlEnabled); const toggleComputerUse = () => { const next = !computerUseEnabled; - // Guards only when turning on (mirrors the old profile toggle). The chatbox - // has no inline notice space, so remind via toast instead of failing silently. + // Guard only when turning on. Computer Use runs on EITHER a large local model + // or the remote AI (DM OpenAI-compatible endpoint) — allow enabling when + // either backend is ready, so a local model is not required. The chatbox has + // no inline notice space, so remind via toast instead of failing silently. if (next) { - if (!isLocalLLMEnabled()) { - toast.warning(t('computerUseNeedsLocalModel')); - return; - } - if (!modelSupportsSystemControl(getLocalLLMModel(), COMPUTER_USE_MIN_MODEL_GB)) { - toast.warning(t('computerUseModelTooSmall')); + const localReady = + isLocalLLMEnabled() && + modelSupportsSystemControl(getLocalLLMModel(), COMPUTER_USE_MIN_MODEL_GB); + if (!localReady && !hasRemoteAiConfig()) { + toast.warning( + isLocalLLMEnabled() + ? t('computerUseModelTooSmall') + : t('computerUseNeedsLocalModel'), + ); return; } } @@ -103,6 +108,14 @@ export const InsideButtons = ({ }; const allInsideButtons = [ + { + name: 'Computer Use', + label: t('computerUse'), + icon: , + isActive: computerUseEnabled, + action: toggleComputerUse, + isEnabled: ghostOs.isAvailable && (isMacOS() || allowNonMacOSComputerUse()), + }, { name: 'Canvas', label: t('canvas'), @@ -145,14 +158,6 @@ export const InsideButtons = ({ action: /* istanbul ignore next */ () => onOptionClick(TOOLS.MEMORY), isEnabled: memoryEnabled && !embedMode && !!username, }, - { - name: 'Computer Use', - label: t('computerUse'), - icon: , - isActive: computerUseEnabled, - action: toggleComputerUse, - isEnabled: ghostOs.isAvailable && (isMacOS() || allowNonMacOSComputerUse()), - }, ].filter((item) => item.isEnabled); // Get visible inside buttons based on screen size. From d9d3e8996fd954ac4701f7367de0b9f8719cc4ee Mon Sep 17 00:00:00 2001 From: null-crafter <163591881+null-crafter@users.noreply.github.com> Date: Thu, 9 Jul 2026 14:08:51 +0000 Subject: [PATCH 04/23] chore: bump @iblai/iblai-js to 1.22.5-computer-use-1.12 --- package.json | 2 +- pnpm-lock.yaml | 24 ++++++++++++------------ 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/package.json b/package.json index 411c50c7..bfeb1f9c 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,7 @@ "dependencies": { "@iblai/agent-ai": "2.5.2", "@iblai/iblai-api": "4.166.0-ai", - "@iblai/iblai-js": "1.22.5-computer-use-1.11", + "@iblai/iblai-js": "1.22.5-computer-use-1.12", "@iblai/iblai-web-mentor": "1.3.4", "@livekit/components-react": "2.8.1", "@livekit/components-styles": "1.1.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e5861849..34142ff2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -44,8 +44,8 @@ importers: specifier: 4.166.0-ai version: 4.166.0-ai '@iblai/iblai-js': - specifier: 1.22.5-computer-use-1.11 - version: 1.22.5-computer-use-1.11(91917b6c2cef637bc1f74c6f4e24fa62) + specifier: 1.22.5-computer-use-1.12 + version: 1.22.5-computer-use-1.12(91917b6c2cef637bc1f74c6f4e24fa62) '@iblai/iblai-web-mentor': specifier: 1.3.4 version: 1.3.4(@babel/core@7.29.7)(@types/babel__core@7.20.5)(rollup@4.62.0)(typescript@5.9.3) @@ -1038,8 +1038,8 @@ packages: '@iblai/iblai-api@4.166.0-ai': resolution: {integrity: sha512-dY9Jv+CkXEyTxRsOQFay5YvYe8K2LSQluJJvtssGQV2RbrxPPzONaKfnhcyarDs7Ft35Xqk1fuErjhUwNG3OIg==} - '@iblai/iblai-js@1.22.5-computer-use-1.11': - resolution: {integrity: sha512-jAzBwhslWLBSslwcE1622O0tpTNov8LhuoE4dX0SxCSWkZ6LFCd5Pnq3ikQF79HEYuomuPHlxIeLFGHu2Ci+AQ==} + '@iblai/iblai-js@1.22.5-computer-use-1.12': + resolution: {integrity: sha512-fzQHQduSfnbMNJtXaJYgqzWz+xxfQt6LLg3P4Nk/1R1jNYl8LnQV6PY3pvESWwEHuTVDbRoaJA35xzyx8bn/qA==} engines: {node: '>=20.0.0'} hasBin: true peerDependencies: @@ -1094,8 +1094,8 @@ packages: sonner: optional: true - '@iblai/web-utils@1.11.12-computer-use-1.11': - resolution: {integrity: sha512-KvLbcZzRnbo4nheNU0Ob0WOjOkD85npyc/u1oq6QLE7LGMicipAjxxLnATXSVw70YWnhrnicabDSf/TJ5Tes2g==} + '@iblai/web-utils@1.11.12-computer-use-1.12': + resolution: {integrity: sha512-UKoNTUjRR27kNJwRySPUBBtJurSTqI+rN9w8MBNZGB53Az3nSsfLTl+qB4fjn+DGburlx5dsQGjTNuxC/3KO9A==} engines: {node: 25.3.0} peerDependencies: '@iblai/data-layer': ^1.1.2 @@ -10056,13 +10056,13 @@ snapshots: dependencies: tslib: 2.8.1 - '@iblai/iblai-js@1.22.5-computer-use-1.11(91917b6c2cef637bc1f74c6f4e24fa62)': + '@iblai/iblai-js@1.22.5-computer-use-1.12(91917b6c2cef637bc1f74c6f4e24fa62)': dependencies: '@iblai/data-layer': 1.9.2(@reduxjs/toolkit@2.7.0(react-redux@9.2.0(@types/react@19.1.17)(react@19.1.0)(redux@5.0.1))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react-redux@9.2.0(@types/react@19.1.17)(react@19.1.0)(redux@5.0.1))(react@19.1.0) '@iblai/iblai-api': 4.166.0-ai '@iblai/mcp': 1.7.8 - '@iblai/web-containers': 1.12.2-computer-use-1.11(91dd1c75d223b12bcec8a56eae208ce6) - '@iblai/web-utils': 1.11.12-computer-use-1.11(@iblai/data-layer@1.9.2(@reduxjs/toolkit@2.7.0(react-redux@9.2.0(@types/react@19.1.17)(react@19.1.0)(redux@5.0.1))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react-redux@9.2.0(@types/react@19.1.17)(react@19.1.0)(redux@5.0.1))(react@19.1.0))(@iblai/iblai-api@4.166.0-ai)(@tauri-apps/api@2.11.0)(@tauri-apps/plugin-os@2.3.2)(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(redux@5.0.1)(sonner@2.0.7(react-dom@19.1.0(react@19.1.0))(react@19.1.0)) + '@iblai/web-containers': 1.12.2-computer-use-1.11(901fc98b6ef548cbeddcda2d2ff8065e) + '@iblai/web-utils': 1.11.12-computer-use-1.12(@iblai/data-layer@1.9.2(@reduxjs/toolkit@2.7.0(react-redux@9.2.0(@types/react@19.1.17)(react@19.1.0)(redux@5.0.1))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react-redux@9.2.0(@types/react@19.1.17)(react@19.1.0)(redux@5.0.1))(react@19.1.0))(@iblai/iblai-api@4.166.0-ai)(@tauri-apps/api@2.11.0)(@tauri-apps/plugin-os@2.3.2)(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(redux@5.0.1)(sonner@2.0.7(react-dom@19.1.0(react@19.1.0))(react@19.1.0)) '@radix-ui/react-dialog': 1.1.7(@types/react-dom@19.2.1(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@reduxjs/toolkit': 2.7.0(react-redux@9.2.0(@types/react@19.1.17)(react@19.1.0)(redux@5.0.1))(react@19.1.0) axios: 1.18.1 @@ -10111,11 +10111,11 @@ snapshots: - '@cfworker/json-schema' - supports-color - '@iblai/web-containers@1.12.2-computer-use-1.11(91dd1c75d223b12bcec8a56eae208ce6)': + '@iblai/web-containers@1.12.2-computer-use-1.11(901fc98b6ef548cbeddcda2d2ff8065e)': dependencies: '@iblai/data-layer': 1.9.2(@reduxjs/toolkit@2.7.0(react-redux@9.2.0(@types/react@19.1.17)(react@19.1.0)(redux@5.0.1))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react-redux@9.2.0(@types/react@19.1.17)(react@19.1.0)(redux@5.0.1))(react@19.1.0) '@iblai/iblai-api': 4.166.0-ai - '@iblai/web-utils': 1.11.12-computer-use-1.11(@iblai/data-layer@1.9.2(@reduxjs/toolkit@2.7.0(react-redux@9.2.0(@types/react@19.1.17)(react@19.1.0)(redux@5.0.1))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react-redux@9.2.0(@types/react@19.1.17)(react@19.1.0)(redux@5.0.1))(react@19.1.0))(@iblai/iblai-api@4.166.0-ai)(@tauri-apps/api@2.11.0)(@tauri-apps/plugin-os@2.3.2)(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(redux@5.0.1)(sonner@2.0.7(react-dom@19.1.0(react@19.1.0))(react@19.1.0)) + '@iblai/web-utils': 1.11.12-computer-use-1.12(@iblai/data-layer@1.9.2(@reduxjs/toolkit@2.7.0(react-redux@9.2.0(@types/react@19.1.17)(react@19.1.0)(redux@5.0.1))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react-redux@9.2.0(@types/react@19.1.17)(react@19.1.0)(redux@5.0.1))(react@19.1.0))(@iblai/iblai-api@4.166.0-ai)(@tauri-apps/api@2.11.0)(@tauri-apps/plugin-os@2.3.2)(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(redux@5.0.1)(sonner@2.0.7(react-dom@19.1.0(react@19.1.0))(react@19.1.0)) '@radix-ui/react-accordion': 1.2.8(@types/react-dom@19.2.1(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-avatar': 1.1.7(@types/react-dom@19.2.1(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-checkbox': 1.2.3(@types/react-dom@19.2.1(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) @@ -10197,7 +10197,7 @@ snapshots: - supports-color - vinxi - '@iblai/web-utils@1.11.12-computer-use-1.11(@iblai/data-layer@1.9.2(@reduxjs/toolkit@2.7.0(react-redux@9.2.0(@types/react@19.1.17)(react@19.1.0)(redux@5.0.1))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react-redux@9.2.0(@types/react@19.1.17)(react@19.1.0)(redux@5.0.1))(react@19.1.0))(@iblai/iblai-api@4.166.0-ai)(@tauri-apps/api@2.11.0)(@tauri-apps/plugin-os@2.3.2)(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(redux@5.0.1)(sonner@2.0.7(react-dom@19.1.0(react@19.1.0))(react@19.1.0))': + '@iblai/web-utils@1.11.12-computer-use-1.12(@iblai/data-layer@1.9.2(@reduxjs/toolkit@2.7.0(react-redux@9.2.0(@types/react@19.1.17)(react@19.1.0)(redux@5.0.1))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react-redux@9.2.0(@types/react@19.1.17)(react@19.1.0)(redux@5.0.1))(react@19.1.0))(@iblai/iblai-api@4.166.0-ai)(@tauri-apps/api@2.11.0)(@tauri-apps/plugin-os@2.3.2)(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(redux@5.0.1)(sonner@2.0.7(react-dom@19.1.0(react@19.1.0))(react@19.1.0))': dependencies: '@ai-sdk/openai-compatible': 3.0.5(zod@3.25.76) '@iblai/data-layer': 1.9.2(@reduxjs/toolkit@2.7.0(react-redux@9.2.0(@types/react@19.1.17)(react@19.1.0)(redux@5.0.1))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react-redux@9.2.0(@types/react@19.1.17)(react@19.1.0)(redux@5.0.1))(react@19.1.0) From 70712e87c98ac7781199a8fbe9945f10844d2b16 Mon Sep 17 00:00:00 2001 From: null-crafter <163591881+null-crafter@users.noreply.github.com> Date: Mon, 20 Jul 2026 13:25:25 +0000 Subject: [PATCH 05/23] feat: move local llms to llm tab --- .../modals/edit-mentor-modal/tabs/llm-tab.tsx | 56 ++++ components/modals/llm-provider-modal.tsx | 146 ++++++++++- .../llm-provider-modal/local-model-row.tsx | 244 ++++++++++++++++++ 3 files changed, 444 insertions(+), 2 deletions(-) create mode 100644 components/modals/llm-provider-modal/local-model-row.tsx diff --git a/components/modals/edit-mentor-modal/tabs/llm-tab.tsx b/components/modals/edit-mentor-modal/tabs/llm-tab.tsx index 2fdd8345..48c40da3 100644 --- a/components/modals/edit-mentor-modal/tabs/llm-tab.tsx +++ b/components/modals/edit-mentor-modal/tabs/llm-tab.tsx @@ -11,12 +11,14 @@ import { useEditMentorMutation, useGetMentorSettingsQuery, } from '@iblai/iblai-js/data-layer'; +import { LOCAL_MODELS, isTauriApp } from '@iblai/iblai-js/web-containers'; import { Input } from '@/components/ui/input'; import { useUsername } from '@/hooks/use-user'; import { LLMProvider, LLMProviderModal, + providerKey, } from '@/components/modals/llm-provider-modal'; import { TenantKeyMentorIdParams } from '@/lib/types'; import { toast } from 'sonner'; @@ -72,6 +74,25 @@ export function LLMTab({ showConfigurationHeader = true }: LLMTabProps) { const isLoading = isMentorSettingsLoading || isLoadingLLMProviders; + // Providers that only offer on-device (local) models — surfaced as grid cards + // (Tauri desktop only) so their downloadable models are reachable even when the + // backend LLM list has no matching cloud provider. + const localOnlyProviders = React.useMemo(() => { + if (!isTauriApp()) return [] as { key: string; provider: string }[]; + const cloudKeys = new Set( + (llmProviders ?? []).map((p) => providerKey(p.name)), + ); + const seen = new Set(); + const result: { key: string; provider: string }[] = []; + for (const model of LOCAL_MODELS) { + const key = providerKey(model.provider); + if (cloudKeys.has(key) || seen.has(key)) continue; + seen.add(key); + result.push({ key, provider: model.provider }); + } + return result; + }, [llmProviders]); + async function updateMentorLLM(llmProvider: string, llmName: string) { try { await editMentor({ @@ -195,6 +216,41 @@ export function LLMTab({ showConfigurationHeader = true }: LLMTabProps) { ); })} + {localOnlyProviders.map((lp) => { + const details = getLLMProviderDetails(lp.provider); + return ( +
{ + if (isDisabled || disabled) return; + setSelectedLLMProvider({ + id: -1, + name: lp.provider, + logo: details.logo, + description: null, + chat_models: [], + }); + }} + > +
+ {t('providerLogoAlt', +
+ + {details.name} + +
+ ); + })} )} diff --git a/components/modals/llm-provider-modal.tsx b/components/modals/llm-provider-modal.tsx index 6e731db4..67571eef 100644 --- a/components/modals/llm-provider-modal.tsx +++ b/components/modals/llm-provider-modal.tsx @@ -21,6 +21,19 @@ import { getLLMProviderDetails, Provider, } from '@/lib/utils'; +import { useModelDownload } from '@/hooks/use-model-download'; +import { + LOCAL_MODELS, + type LocalModel, + isLocalLLMEnabled, + setLocalLLMEnabled, + getLocalLLMModel, + setLocalLLMModel, +} from '@iblai/iblai-js/web-containers'; +import { + LocalModelRow, + type LocalRowStatus, +} from './llm-provider-modal/local-model-row'; interface LLM { llm_name: string; @@ -55,6 +68,26 @@ interface Props { llms: Provider[]; } +/** + * Whether a catalog model id (e.g. "llama3.2") is among the installed Ollama + * tags (e.g. "llama3.2:latest"). Inlined from web-containers' `isModelInstalled` + * (not re-exported from the package entry). Matches an exact tag or same base. + */ +function isModelInstalled(modelId: string, tags?: string[]): boolean { + return !!tags?.some((t) => t === modelId || t.startsWith(`${modelId}:`)); +} + +// Match a local model's `provider` (e.g. "Mistral AI") to a cloud provider name +// (e.g. "mistral") tolerantly: strip non-alphanumerics + a couple of aliases. +const PROVIDER_ALIASES: Record = { + mistralai: 'mistral', + metallama: 'meta', +}; +export function providerKey(name: string): string { + const normalized = name.toLowerCase().replace(/[^a-z0-9]/g, ''); + return PROVIDER_ALIASES[normalized] ?? normalized; +} + export function LLMProviderModal({ isOpen, onClose, @@ -67,15 +100,99 @@ export function LLMProviderModal({ const t = useTranslations('modalsLlmProviderModal'); const [searchQuery, setSearchQuery] = React.useState(''); + // On-device model download state (Tauri desktop only; hidden on web). + const { + isAvailable, + state: downloadState, + ollamaStatus, + startDownload, + cancelDownload, + } = useModelDownload(); + + // Mirror the device-global local selection so picks re-render immediately. + const [localSel, setLocalSel] = React.useState(() => ({ + enabled: isLocalLLMEnabled(), + model: getLocalLLMModel(), + })); + const filteredLLMs = React.useMemo(() => { return llmProvider?.chat_models.filter((llm) => llm.llm_name.toLowerCase().includes(searchQuery.toLowerCase()), ); }, [searchQuery, llmProvider]); + // Local models belonging to THIS provider (merged into the same list). + const localModels = React.useMemo(() => { + if (!isAvailable) return [] as LocalModel[]; + const key = providerKey(llmProvider.name); + return LOCAL_MODELS.filter( + (m) => + providerKey(m.provider) === key && + m.name.toLowerCase().includes(searchQuery.toLowerCase()), + ); + }, [isAvailable, llmProvider.name, searchQuery]); + const switchLLMAllowed = canSwitchLLm(llmProvider); const switchProviderAllowed = canSwitchProvider(llms, llmProvider.name); + const busyDownloading = + downloadState.status === 'downloading' || downloadState.status === 'checking'; + + const statusFor = (m: LocalModel): LocalRowStatus => { + if (localSel.enabled && localSel.model === m.id) return 'selected'; + const active = downloadState.activeModel === m.id; + if (active && downloadState.status === 'error') return 'error'; + if (active && busyDownloading) + return downloadState.progress > 0 ? 'downloading' : 'starting'; + if (isModelInstalled(m.id, ollamaStatus?.installed_models)) return 'installed'; + return 'not-installed'; + }; + + const activateLocal = (m: LocalModel, status: LocalRowStatus) => { + switch (status) { + case 'not-installed': + case 'error': + startDownload(m.id); + break; + case 'starting': + case 'downloading': + cancelDownload(); + break; + case 'installed': + // Use this on-device model — device-global, mutually exclusive with cloud. + setLocalLLMModel(m.id); + setLocalLLMEnabled(true); + setLocalSel({ enabled: true, model: m.id }); + break; + case 'selected': + break; + } + }; + + const selectCloud = (llmName: string) => { + // Picking a cloud model turns local mode off so routing uses the cloud LLM. + if (localSel.enabled) { + setLocalLLMEnabled(false); + setLocalSel((prev) => ({ ...prev, enabled: false })); + } + onSelect(llmProvider.name, llmName); + }; + + // Announce download lifecycle changes once (not every %) for screen readers. + const activeLocal = LOCAL_MODELS.find((m) => m.id === downloadState.activeModel); + const liveMessage = !activeLocal + ? '' + : downloadState.status === 'completed' + ? `${activeLocal.name} downloaded` + : downloadState.status === 'cancelled' + ? 'Download cancelled' + : downloadState.status === 'error' + ? 'Download failed' + : downloadState.status === 'checking' || + (downloadState.status === 'downloading' && downloadState.progress === 0) + ? `Started downloading ${activeLocal.name}` + : ''; + return ( @@ -100,11 +217,16 @@ export function LLMProviderModal({ /> +
+ {liveMessage} +
+
{filteredLLMs.map((llm) => { const isActive = mentorSettings?.llm_name === llm.llm_name && - mentorSettings?.llm_provider === llmProvider.name; + mentorSettings?.llm_provider === llmProvider.name && + !localSel.enabled; const providerDetails = getLLMProviderDetails( llmProvider.name, @@ -122,7 +244,7 @@ export function LLMProviderModal({ key={llm.llm_name} disabled={isDisabled} onClick={() => { - onSelect(llmProvider.name, llm.llm_name); + selectCloud(llm.llm_name); }} className={cn( 'flex cursor-pointer items-center gap-3 rounded-lg border border-gray-200 p-4 transition-colors', @@ -153,6 +275,26 @@ export function LLMProviderModal({ ); })} + + {/* On-device models for this provider, merged into the same list. */} + {localModels.map((m) => { + const status = statusFor(m); + return ( + activateLocal(m, status)} + /> + ); + })}
diff --git a/components/modals/llm-provider-modal/local-model-row.tsx b/components/modals/llm-provider-modal/local-model-row.tsx new file mode 100644 index 00000000..4399c2ae --- /dev/null +++ b/components/modals/llm-provider-modal/local-model-row.tsx @@ -0,0 +1,244 @@ +'use client'; + +import React from 'react'; +import Image from 'next/image'; +import { Download, X, Check, RotateCcw } from 'lucide-react'; +import { cn } from '@/lib/utils'; + +/** + * Interaction states for an on-device (local) model row. The row is a single + * click-toggle whose action depends on state: download → cancel → select. + */ +export type LocalRowStatus = + | 'not-installed' + | 'starting' + | 'downloading' + | 'installed' + | 'selected' + | 'error'; + +interface LocalModelRowProps { + name: string; + size: string; + logo: string; + status: LocalRowStatus; + /** 0–100; meaningful while downloading. */ + progress: number; + /** Another model is downloading, so this row can't start one. */ + disabled?: boolean; + disabledReason?: string; + errorMessage?: string; + onActivate: () => void; +} + +const RING_SIZE = 32; +const RING_STROKE = 3; + +/** + * Determinate progress ring drawn around the model logo. The fill transition is + * gated behind `motion-safe` so it stays static under prefers-reduced-motion. + */ +function ProgressRing({ + value, + children, +}: { + value: number; + children: React.ReactNode; +}) { + const r = (RING_SIZE - RING_STROKE) / 2; + const circumference = 2 * Math.PI * r; + const clamped = Math.max(0, Math.min(100, value)); + const offset = circumference - (clamped / 100) * circumference; + return ( + + + {/* Slightly inset logo so the ring reads clearly around it. */} + + {children} + + + ); +} + +/** + * A local (on-device) model presented in the same list as cloud models. It uses + * the same provider logo as the cloud rows; the model name gets its own line and + * the on-device badge, size, and download/status affordance sit on a subtitle + * line beneath it. One click toggles download ⇄ cancel; once installed a click + * selects it. + */ +export function LocalModelRow({ + name, + size, + logo, + status, + progress, + disabled = false, + disabledReason, + errorMessage, + onActivate, +}: LocalModelRowProps) { + const selected = status === 'selected'; + const downloading = status === 'downloading' || status === 'starting'; + // A resting row (nothing downloading) can still be disabled when another + // model is pulling; a row that is itself downloading stays clickable (cancel). + const isBusyDisabled = disabled && !downloading && !selected; + const inert = isBusyDisabled || selected; + const pct = Math.round(Math.max(0, Math.min(100, progress))); + + const ariaLabel = (() => { + switch (status) { + case 'not-installed': + return `Download ${name}, ${size}, on-device model`; + case 'starting': + return `Starting download of ${name}`; + case 'downloading': + return `Cancel download of ${name}, ${pct} percent`; + case 'installed': + return `Use ${name}, on-device model`; + case 'selected': + return `Selected ${name}, on-device model, in use`; + case 'error': + return `Retry download of ${name}${errorMessage ? `, ${errorMessage}` : ''}`; + } + })(); + + return ( + + ); +} From 9304e57f0bbf9bf975d5f12de56436ba9471c108 Mon Sep 17 00:00:00 2001 From: null-crafter <163591881+null-crafter@users.noreply.github.com> Date: Wed, 22 Jul 2026 20:35:53 +0000 Subject: [PATCH 06/23] feat: improve local model handling --- .../nav-bar/__tests__/index.test.tsx | 68 +++++++++ .../[mentorId]/_components/nav-bar/index.tsx | 94 +++++++++++- .../_components/nav-bar/user-profile.tsx | 5 + .../__tests__/llm-provider-modal.test.tsx | 17 +++ .../tabs/__tests__/llm-tab.test.tsx | 20 +++ .../modals/edit-mentor-modal/tabs/llm-tab.tsx | 22 ++- components/modals/llm-provider-modal.tsx | 21 ++- hooks/use-model-download.ts | 38 +++-- hooks/use-selected-local-model.ts | 70 +++++++++ package.json | 2 +- pnpm-lock.yaml | 137 ++++++++++++++---- 11 files changed, 446 insertions(+), 48 deletions(-) create mode 100644 hooks/use-selected-local-model.ts diff --git a/app/platform/[tenantKey]/[mentorId]/_components/nav-bar/__tests__/index.test.tsx b/app/platform/[tenantKey]/[mentorId]/_components/nav-bar/__tests__/index.test.tsx index 1f92bdf4..b613ecc6 100644 --- a/app/platform/[tenantKey]/[mentorId]/_components/nav-bar/__tests__/index.test.tsx +++ b/app/platform/[tenantKey]/[mentorId]/_components/nav-bar/__tests__/index.test.tsx @@ -397,11 +397,32 @@ vi.mock('@/components/modals/auth-modal', () => ({ })); let lastCreditBalanceProps: any = null; +// On-device selection, driven per-test; default OFF so existing tests see the +// pre-badge nav-bar. One catalog model lets the badge resolve a display name. +let mockLocalEnabled = false; +let mockLocalModelId: string | null = null; vi.mock('@iblai/iblai-js/web-containers', () => ({ // The toggle now ships from the SDK; tests don't exercise its internals, // so a no-render stub is enough — it also blocks the SDK's transitive // axios chain from being pulled into the test's module graph. ChatPrivacyToggle: () => null, + // On-device model helpers read by `useSelectedLocalModel` (the top-left + // badge). Default to local mode OFF so the nav-bar renders exactly as it did + // before the badge existed (cloud selector shown, no badge). + isLocalLLMEnabled: () => mockLocalEnabled, + getLocalLLMModel: () => mockLocalModelId, + // The hook reconciles the tool-support cache against the catalog on read. + getLocalLLMToolSupport: () => true, + setLocalLLMToolSupport: () => {}, + LOCAL_MODELS: [ + { + name: 'Llama 3.2', + provider: 'Meta', + id: 'llama3.2', + size: '2.0 GB', + tool_support: true, + }, + ], NotificationDropdown: () => (
Notifications
), @@ -540,6 +561,8 @@ describe('NavBar', () => { }; mockAllTenants = [{ key: 'test-tenant' }]; lastCreditBalanceProps = null; + mockLocalEnabled = false; + mockLocalModelId = null; // Suppress console.log during tests vi.spyOn(console, 'log').mockImplementation(() => {}); }); @@ -619,6 +642,51 @@ describe('NavBar', () => { expect(screen.getByLabelText('LLM Model Selector')).toBeInTheDocument(); }); + it('shows the on-device model badge and hides the cloud selector when local mode is on', () => { + mockIsAdmin = true; + mockUserIsStudent = false; + mockPathname = '/platform/tenant123/mentor456'; + mockLocalEnabled = true; + mockLocalModelId = 'llama3.2'; + const store = createTestStore(); + + render( + + + , + ); + + const badge = screen.getByTestId('local-model-indicator'); + expect(badge).toHaveTextContent('Llama 3.2'); + expect(badge).toHaveTextContent('On-device'); + // The on-device badge replaces the cloud model selector while local is on. + expect( + screen.queryByLabelText('LLM Model Selector'), + ).not.toBeInTheDocument(); + }); + + it('opens the model picker when the on-device badge is clicked, so an admin can still choose an LLM while local is on', () => { + mockIsAdmin = true; + mockUserIsStudent = false; + mockPathname = '/platform/tenant123/mentor456'; + mockLocalEnabled = true; + mockLocalModelId = 'llama3.2'; + const store = createTestStore(); + + render( + + + , + ); + + // Picker is closed until the badge is clicked. + expect( + screen.queryByTestId('llm-provider-modal'), + ).not.toBeInTheDocument(); + fireEvent.click(screen.getByTestId('local-model-indicator')); + expect(screen.getByTestId('llm-provider-modal')).toBeInTheDocument(); + }); + it('hides the LLM model selector on the tenant-scoped projects index page', () => { mockIsAdmin = true; mockUserIsStudent = false; diff --git a/app/platform/[tenantKey]/[mentorId]/_components/nav-bar/index.tsx b/app/platform/[tenantKey]/[mentorId]/_components/nav-bar/index.tsx index 39fb1f02..2291431e 100644 --- a/app/platform/[tenantKey]/[mentorId]/_components/nav-bar/index.tsx +++ b/app/platform/[tenantKey]/[mentorId]/_components/nav-bar/index.tsx @@ -12,6 +12,7 @@ import { Menu, User, Bot, + HardDrive, GitFork, } from 'lucide-react'; @@ -84,6 +85,10 @@ import { config } from '@/lib/config'; import { MentorVisibilityEnum } from '@iblai/iblai-api'; import { toast } from 'sonner'; import { useModelDownload } from '@/hooks/use-model-download'; +import { + useSelectedLocalModel, + LOCAL_LLM_CHANGED_EVENT, +} from '@/hooks/use-selected-local-model'; import { useMentorSegments, MENTOR_SEGMENT_NAV_CATEGORIES, @@ -240,6 +245,47 @@ export function NavBar() { onSelectFoundryModel, } = useModelDownload(); + // Active on-device (local) model, if any. Shown top-left in place of the cloud + // model while local mode is on (chat routes to the local model then, so the + // cloud model would be misleading). Reactive to picks + the master toggle. + const selectedLocal = useSelectedLocalModel(); + const localModelLogo = selectedLocal.model + ? getLLMProviderDetails(selectedLocal.model.provider, selectedLocal.model.name) + .logo + : ''; + const localModelName = + selectedLocal.model?.name ?? selectedLocal.modelId ?? ''; + // Admins (non-students) can switch the mentor's LLM; for them the on-device + // badge doubles as the entry point to the model picker (mirrors the cloud + // selector's gate). Others get a plain, non-interactive indicator. + const canChooseLlm = isAdmin && !userIsStudent; + const localModelBadgeInner = ( + <> +
+ {localModelLogo ? ( + {`${localModelName} + ) : ( + + )} +
+ + {localModelName} + + + + {canChooseLlm && } + + ); + console.log('[NavBar] After useModelDownload:', { isLocalLLMAvailable, foundryStatus, @@ -487,7 +533,46 @@ export function NavBar() { )}
- {isOnChatPage && isAdmin && !userIsStudent && ( + {/* On-device (local) model indicator. Shown while local mode is on; + it replaces the cloud model selector below (hidden via the same + `selectedLocal.isLocal` condition). For users who can switch LLMs + it is ALSO the entry point to the model picker — click to open it + and choose a different model (cloud or local) without first + disabling local mode. */} + {isOnChatPage && selectedLocal.isLocal && ( + + + {canChooseLlm ? ( + + ) : ( +
+ {localModelBadgeInner} +
+ )} +
+ + {canChooseLlm + ? `On-device: ${localModelName} — click to change model` + : `On-device model in use — ${localModelName}`} + +
+ )} + + {isOnChatPage && isAdmin && !userIsStudent && !selectedLocal.isLocal && (
) : null; }, + // Faithful to the real normalizer (lowercase + strip non-alphanumerics); the + // tab now calls this unconditionally to compute the highlighted provider. + providerKey: (name: string) => + (name ?? '').toLowerCase().replace(/[^a-z0-9]/g, ''), })); // ============================================================================ @@ -127,6 +131,9 @@ const mentorSettings = { describe('LLMTab', () => { beforeEach(() => { vi.clearAllMocks(); + // The on-device selection lives in localStorage; clear it so a test that + // enables local mode can't leak into the (default cloud) tests. + localStorage.clear(); mockIsEditing = false; mockUnwrap.mockResolvedValue({}); mockUseParams.mockReturnValue({ @@ -189,6 +196,19 @@ describe('LLMTab', () => { expect(container.querySelector('.border-blue-500')).toBeInTheDocument(); }); + it('does not highlight the stale cloud provider when an on-device model is active', async () => { + // Bug: the tab highlighted the mentor's cloud provider (openai) even though + // chat was actually using a local model (Llama 3.2 → Meta). With local mode + // on, the openai card — the only provider here matching the cloud setting — + // must lose its active border (no cloud card matches the local "meta" key). + localStorage.setItem('ibl_local_llm_enabled', 'true'); + localStorage.setItem('ibl_local_llm_model', 'llama3.2'); + const { container } = render(); + await waitFor(() => { + expect(container.querySelector('.border-blue-500')).not.toBeInTheDocument(); + }); + }); + it('opens the provider modal when a provider card is clicked', async () => { const user = userEvent.setup(); render(); diff --git a/components/modals/edit-mentor-modal/tabs/llm-tab.tsx b/components/modals/edit-mentor-modal/tabs/llm-tab.tsx index 2dd066e3..811a4676 100644 --- a/components/modals/edit-mentor-modal/tabs/llm-tab.tsx +++ b/components/modals/edit-mentor-modal/tabs/llm-tab.tsx @@ -27,6 +27,7 @@ import { useNavigate } from '@/hooks/user-navigate'; import { Spinner } from '@/components/spinner'; import WithFormPermissions from '@/hoc/withPermissions'; import { extractErrorMessage } from '@/lib/error'; +import { useSelectedLocalModel } from '@/hooks/use-selected-local-model'; type LLMTabProps = { showConfigurationHeader?: boolean; @@ -93,6 +94,19 @@ export function LLMTab({ showConfigurationHeader = true }: LLMTabProps) { return result; }, [llmProviders]); + // The highlighted (selected) provider card must reflect the model chat + // actually uses. When on-device mode is on, that's the selected local model's + // provider (e.g. Llama 3.2 → Meta) — NOT the mentor's stale cloud + // `llm_provider` (which stays e.g. OpenAI). Falls back to the cloud provider + // when local mode is off. Reactive so it updates the instant a model is picked. + const selectedLocal = useSelectedLocalModel(); + const activeProviderKey = + selectedLocal.isLocal && selectedLocal.model + ? providerKey(selectedLocal.model.provider) + : mentorSettings?.llm_provider + ? providerKey(mentorSettings.llm_provider) + : ''; + async function updateMentorLLM(llmProvider: string, llmName: string) { try { await editMentor({ @@ -184,7 +198,8 @@ export function LLMTab({ showConfigurationHeader = true }: LLMTabProps) { 'flex cursor-pointer items-center gap-3 rounded-lg border border-gray-200 bg-white p-4 shadow-sm transition-shadow hover:shadow-md', { 'border-blue-500': - mentorSettings?.llm_provider === model.name, + !!activeProviderKey && + providerKey(model.name) === activeProviderKey, }, )} onClick={() => { @@ -227,7 +242,10 @@ export function LLMTab({ showConfigurationHeader = true }: LLMTabProps) { return (
{ if (isDisabled || disabled) return; setSelectedLLMProvider({ diff --git a/components/modals/llm-provider-modal.tsx b/components/modals/llm-provider-modal.tsx index 67571eef..e453f4d9 100644 --- a/components/modals/llm-provider-modal.tsx +++ b/components/modals/llm-provider-modal.tsx @@ -29,11 +29,13 @@ import { setLocalLLMEnabled, getLocalLLMModel, setLocalLLMModel, + setLocalLLMToolSupport, } from '@iblai/iblai-js/web-containers'; import { LocalModelRow, type LocalRowStatus, } from './llm-provider-modal/local-model-row'; +import { LOCAL_LLM_CHANGED_EVENT } from '@/hooks/use-selected-local-model'; interface LLM { llm_name: string; @@ -116,8 +118,14 @@ export function LLMProviderModal({ })); const filteredLLMs = React.useMemo(() => { - return llmProvider?.chat_models.filter((llm) => - llm.llm_name.toLowerCase().includes(searchQuery.toLowerCase()), + // Guard `chat_models` (not just `llmProvider`): a provider can come back from + // the API without a models array, and an undefined `.filter` here throws in + // render and unmounts the whole dialog. Coalesce so the `.map` consumer below + // always gets an array. (Mirrors the `canSwitchProvider` hardening in utils.) + return ( + llmProvider?.chat_models?.filter((llm) => + llm.llm_name.toLowerCase().includes(searchQuery.toLowerCase()), + ) ?? [] ); }, [searchQuery, llmProvider]); @@ -162,7 +170,14 @@ export function LLMProviderModal({ // Use this on-device model — device-global, mutually exclusive with cloud. setLocalLLMModel(m.id); setLocalLLMEnabled(true); + // Persist tool-calling support so local chat routes to the MCP/tool + // bridge (:8000). Without this the flag stays at its default (false) and + // streaming chat rejects the model with "tool_support=false" even though + // the catalog marks it tool-capable. + setLocalLLMToolSupport(m.tool_support); setLocalSel({ enabled: true, model: m.id }); + // Notify same-tab listeners (e.g. the nav-bar on-device badge). + window.dispatchEvent(new Event(LOCAL_LLM_CHANGED_EVENT)); break; case 'selected': break; @@ -174,6 +189,8 @@ export function LLMProviderModal({ if (localSel.enabled) { setLocalLLMEnabled(false); setLocalSel((prev) => ({ ...prev, enabled: false })); + // Notify same-tab listeners (e.g. the nav-bar on-device badge). + window.dispatchEvent(new Event(LOCAL_LLM_CHANGED_EVENT)); } onSelect(llmProvider.name, llmName); }; diff --git a/hooks/use-model-download.ts b/hooks/use-model-download.ts index 453474b6..5874ae8a 100644 --- a/hooks/use-model-download.ts +++ b/hooks/use-model-download.ts @@ -289,7 +289,13 @@ export function useModelDownload() { try { console.log('[useModelDownload] Setting state to checking...'); - setState((prev) => ({ ...prev, status: 'checking' })); + // Never downgrade an in-flight download. checkStatus runs on mount, on a + // 2.5s interval, and after each pull — and state is shared across every + // useModelDownload instance via localStorage — so an unguarded write here + // races a running pull and flips the row back to "Download" (the flash). + setState((prev) => + prev.status === 'downloading' ? prev : { ...prev, status: 'checking' }, + ); // First check if Foundry Local has models available (PREFERRED option) // Foundry is prioritized over Ollama due to better performance and efficiency @@ -464,24 +470,26 @@ export function useModelDownload() { setOllamaStatus(status); if (status.model_installed) { - setState((prev) => ({ - ...prev, - status: 'completed', - progress: 100, - message: 'Model installed', - })); + setState((prev) => + prev.status === 'downloading' + ? prev + : { + ...prev, + status: 'completed', + progress: 100, + message: 'Model installed', + }, + ); } else { - setState((prev) => ({ - ...prev, - status: 'idle', - })); + setState((prev) => + prev.status === 'downloading' ? prev : { ...prev, status: 'idle' }, + ); } } catch (error) { console.error('Failed to check status:', error); - setState((prev) => ({ - ...prev, - status: 'idle', - })); + setState((prev) => + prev.status === 'downloading' ? prev : { ...prev, status: 'idle' }, + ); } }, [isAvailable, invoke, setState]); diff --git a/hooks/use-selected-local-model.ts b/hooks/use-selected-local-model.ts new file mode 100644 index 00000000..92b9d650 --- /dev/null +++ b/hooks/use-selected-local-model.ts @@ -0,0 +1,70 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { + isLocalLLMEnabled, + getLocalLLMModel, + getLocalLLMToolSupport, + setLocalLLMToolSupport, + LOCAL_MODELS, + type LocalModel, +} from '@iblai/iblai-js/web-containers'; + +/** + * Window event other OS components dispatch after changing the device-global + * on-device selection — a model pick in the LLM tab or the Local Models master + * toggle in Profile → Advanced. `storage` events only fire cross-tab, so this + * covers the same-tab case so the nav-bar badge updates immediately. + */ +export const LOCAL_LLM_CHANGED_EVENT = 'ibl:local-llm-changed'; + +export interface SelectedLocalModel { + /** On-device mode is enabled and a model id is selected. */ + isLocal: boolean; + /** The selected catalog model, or null if the id isn't a known catalog entry. */ + model: LocalModel | null; + /** The raw selected model id (may be set even when not in the catalog). */ + modelId: string | null; +} + +/** + * Reactive view of the on-device model selection (`ibl_local_llm_enabled` + + * `ibl_local_llm_model` in localStorage). Reads on mount — so it is + * SSR/hydration-safe (nothing resolves until the client mounts) — and re-reads + * on cross-tab `storage` events and same-tab {@link LOCAL_LLM_CHANGED_EVENT} + * events. Used by the nav-bar to show the active on-device model top-left. + */ +export function useSelectedLocalModel(): SelectedLocalModel { + const [sel, setSel] = useState({ + isLocal: false, + model: null, + modelId: null, + }); + + useEffect(() => { + const read = () => { + const enabled = isLocalLLMEnabled(); + const id = getLocalLLMModel(); + const model = id ? (LOCAL_MODELS.find((m) => m.id === id) ?? null) : null; + // Self-heal the derived tool-support cache. Local chat routes on + // `ibl_local_llm_tool_support` (SDK ollama-client), but an older selection + // may have left it stale/false — which makes streaming reject a genuinely + // tool-capable model. The catalog is the source of truth; reconcile here + // (the nav-bar mounts this hook on every load). Write only on a real + // mismatch so it can't loop. + if (enabled && model && getLocalLLMToolSupport() !== model.tool_support) { + setLocalLLMToolSupport(model.tool_support); + } + setSel({ isLocal: enabled && !!id, model, modelId: id }); + }; + read(); + window.addEventListener('storage', read); + window.addEventListener(LOCAL_LLM_CHANGED_EVENT, read); + return () => { + window.removeEventListener('storage', read); + window.removeEventListener(LOCAL_LLM_CHANGED_EVENT, read); + }; + }, []); + + return sel; +} diff --git a/package.json b/package.json index 37eac422..8a3f7109 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,7 @@ "dependencies": { "@iblai/agent-ai": "2.5.2", "@iblai/iblai-api": "4.166.0-ai", - "@iblai/iblai-js": "1.22.5-computer-use-1.12", + "@iblai/iblai-js": "1.26.6-computer-use-1.13", "@iblai/iblai-web-mentor": "1.3.4", "@livekit/components-react": "2.8.1", "@livekit/components-styles": "1.1.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9fd43d39..4d52b8df 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -44,8 +44,8 @@ importers: specifier: 4.166.0-ai version: 4.166.0-ai '@iblai/iblai-js': - specifier: 1.22.5-computer-use-1.12 - version: 1.22.5-computer-use-1.12(09ad73cc8de68f3b29da137077ebeb71) + specifier: 1.26.6-computer-use-1.13 + version: 1.26.6-computer-use-1.13(09ad73cc8de68f3b29da137077ebeb71) '@iblai/iblai-web-mentor': specifier: 1.3.4 version: 1.3.4(@babel/core@7.29.7)(@types/babel__core@7.20.5)(rollup@4.62.2)(typescript@5.9.3) @@ -879,8 +879,8 @@ packages: react: 19.1.0 react-dom: 19.1.0 - '@iblai/data-layer@1.9.2': - resolution: {integrity: sha512-z7XNYNb+XoahIhzld9yBVqAnf/WfpUddCAi/5QmJeP6FkYecm/GRKsPx4RwIB9vw2Uk64iJxn1DCcfEc1p3A+g==} + '@iblai/data-layer@1.9.5': + resolution: {integrity: sha512-iZw73nIfje8ChdEQYGa/phwzMl8Ci2Ly7HoayVTumxvhoiggkKZgitQcCG3GKlVhHdnTNAqRkcqF1SXq9Kfjsw==} engines: {node: 25.3.0} peerDependencies: '@reduxjs/toolkit': 2.7.0 @@ -891,8 +891,8 @@ packages: '@iblai/iblai-api@4.166.0-ai': resolution: {integrity: sha512-dY9Jv+CkXEyTxRsOQFay5YvYe8K2LSQluJJvtssGQV2RbrxPPzONaKfnhcyarDs7Ft35Xqk1fuErjhUwNG3OIg==} - '@iblai/iblai-js@1.22.5-computer-use-1.12': - resolution: {integrity: sha512-fzQHQduSfnbMNJtXaJYgqzWz+xxfQt6LLg3P4Nk/1R1jNYl8LnQV6PY3pvESWwEHuTVDbRoaJA35xzyx8bn/qA==} + '@iblai/iblai-js@1.26.6-computer-use-1.13': + resolution: {integrity: sha512-zDxmu5WhbD0ssmsUACbJE5tIy1CppHzQn7IdaJcdm6vDfUtjPNGWhFu0fywiODipin+z9yU6ocmOkm8zJrnwCQ==} engines: {node: '>=20.0.0'} hasBin: true peerDependencies: @@ -916,18 +916,18 @@ packages: '@iblai/iblai-web-mentor@1.3.4': resolution: {integrity: sha512-w0K22cQl/LquXDDnVi7DgiOeuS+iha85poZxuLCtJqvwyCxAMLch1DWN3LbNCxw+YGhrV7BOEENp8s7bZ+7ZGw==} - '@iblai/mcp@1.7.8': - resolution: {integrity: sha512-Xwz3e8+/u/fxuwjRxzOtWTHwFMJioSQAxRTPqIu8+AH609Ln+fe6gF6irKtqaJLHEdOE0Jbe+NWjhL1iiPoT3g==} + '@iblai/mcp@1.8.2': + resolution: {integrity: sha512-zUppU29ZO/nzs1X1seToym3MmgcSnqpVhKVCd4vEE+Y5chet/iL6Ym0XOFNeVIh8veEe4Ks4lA8h+BYK4kQ3rQ==} engines: {node: '>=20.0.0'} hasBin: true - '@iblai/web-containers@1.12.2-computer-use-1.11': - resolution: {integrity: sha512-9MA+jqWznobFZFA2SdmVaRa1MgZ/9jFMriKxL2vLQFXgO21Q7WD3TZmz2nwC/eMACc9Nuh9UHh+/w9dtbMli0g==} + '@iblai/web-containers@1.15.4-computer-use-1.12': + resolution: {integrity: sha512-01Ljzb2a3nO0yBsLH/zN374hPz9DGUFzcVMGhAcBuhgVYFlB0+3UiS5Nxq65LnyGlviMqHdeJstIFc1H5fI88A==} engines: {node: 25.3.0} peerDependencies: - '@iblai/data-layer': 1.9.2 + '@iblai/data-layer': 1.9.5 '@iblai/iblai-api': 4.166.0-ai - '@iblai/web-utils': 1.11.12-computer-use-1.11 + '@iblai/web-utils': 1.13.4-computer-use-1.12 '@livekit/components-react': 2.8.1 '@radix-ui/react-dialog': ^1.1.7 '@reduxjs/toolkit': 2.7.0 @@ -947,8 +947,8 @@ packages: sonner: optional: true - '@iblai/web-utils@1.11.12-computer-use-1.12': - resolution: {integrity: sha512-UKoNTUjRR27kNJwRySPUBBtJurSTqI+rN9w8MBNZGB53Az3nSsfLTl+qB4fjn+DGburlx5dsQGjTNuxC/3KO9A==} + '@iblai/web-utils@1.13.4-computer-use-1.12': + resolution: {integrity: sha512-EG+hVjpScuIVVz4iN3xMeatspeVCB7gZ+8g67ciA2nOw/AjPhcbYBSdBV82WltTzc2c1VxdLhbKBuvE23Y1gNQ==} engines: {node: 25.3.0} peerDependencies: '@iblai/data-layer': ^1.1.2 @@ -1674,6 +1674,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-arrow@1.1.7': + resolution: {integrity: sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-aspect-ratio@1.1.1': resolution: {integrity: sha512-kNU4FIpcFMBLkOUcgeIteH06/8JLBcYY6Le1iKenDGCYNYFX3TQqCZjzkOsz37h7r94/99GTb7YhEr98ZBJibw==} peerDependencies: @@ -2067,6 +2080,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-hover-card@1.1.15': + resolution: {integrity: sha512-qgTkjNT1CfKMoP0rcasmlH2r1DAiYicWsDsufxl940sT2wHNEWWv6FMWIQXWhVdmC1d/HYfbhQx60KYyAtKxjg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-hover-card@1.1.4': resolution: {integrity: sha512-QSUUnRA3PQ2UhvoCv3eYvMnCAgGQW+sTu86QPuNb+ZMi+ZENd6UWpiXbcWDQ4AEaKF9KKpCHBeaJz9Rw6lRlaQ==} peerDependencies: @@ -2237,6 +2263,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-popper@1.2.8': + resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-portal@1.1.3': resolution: {integrity: sha512-NciRqhXnGojhT93RPyDaMPfLH3ZSl4jjIFbZQ1b/vxvZEdHsBZ49wP9w8L3HzUQwep01LcWtkUvm0OVB5JAHTw==} peerDependencies: @@ -9940,7 +9979,7 @@ snapshots: react: 19.1.0 react-dom: 19.1.0(react@19.1.0) - '@iblai/data-layer@1.9.2(@reduxjs/toolkit@2.7.0(react-redux@9.2.0(@types/react@19.1.17)(react@19.1.0)(redux@5.0.1))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react-redux@9.2.0(@types/react@19.1.17)(react@19.1.0)(redux@5.0.1))(react@19.1.0)': + '@iblai/data-layer@1.9.5(@reduxjs/toolkit@2.7.0(react-redux@9.2.0(@types/react@19.1.17)(react@19.1.0)(redux@5.0.1))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react-redux@9.2.0(@types/react@19.1.17)(react@19.1.0)(redux@5.0.1))(react@19.1.0)': dependencies: '@iblai/iblai-api': 4.166.0-ai '@reduxjs/toolkit': 2.7.0(react-redux@9.2.0(@types/react@19.1.17)(react@19.1.0)(redux@5.0.1))(react@19.1.0) @@ -9954,13 +9993,13 @@ snapshots: dependencies: tslib: 2.8.1 - '@iblai/iblai-js@1.22.5-computer-use-1.12(09ad73cc8de68f3b29da137077ebeb71)': + '@iblai/iblai-js@1.26.6-computer-use-1.13(09ad73cc8de68f3b29da137077ebeb71)': dependencies: - '@iblai/data-layer': 1.9.2(@reduxjs/toolkit@2.7.0(react-redux@9.2.0(@types/react@19.1.17)(react@19.1.0)(redux@5.0.1))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react-redux@9.2.0(@types/react@19.1.17)(react@19.1.0)(redux@5.0.1))(react@19.1.0) + '@iblai/data-layer': 1.9.5(@reduxjs/toolkit@2.7.0(react-redux@9.2.0(@types/react@19.1.17)(react@19.1.0)(redux@5.0.1))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react-redux@9.2.0(@types/react@19.1.17)(react@19.1.0)(redux@5.0.1))(react@19.1.0) '@iblai/iblai-api': 4.166.0-ai - '@iblai/mcp': 1.7.8 - '@iblai/web-containers': 1.12.2-computer-use-1.11(12ec236c47d23656aecb696e96de9b87) - '@iblai/web-utils': 1.11.12-computer-use-1.12(@iblai/data-layer@1.9.2(@reduxjs/toolkit@2.7.0(react-redux@9.2.0(@types/react@19.1.17)(react@19.1.0)(redux@5.0.1))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react-redux@9.2.0(@types/react@19.1.17)(react@19.1.0)(redux@5.0.1))(react@19.1.0))(@iblai/iblai-api@4.166.0-ai)(@tauri-apps/api@2.11.0)(@tauri-apps/plugin-os@2.3.2)(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(redux@5.0.1)(sonner@2.0.7(react-dom@19.1.0(react@19.1.0))(react@19.1.0)) + '@iblai/mcp': 1.8.2 + '@iblai/web-containers': 1.15.4-computer-use-1.12(ee6385f528e0f5f01760b2e47e6bc333) + '@iblai/web-utils': 1.13.4-computer-use-1.12(@iblai/data-layer@1.9.5(@reduxjs/toolkit@2.7.0(react-redux@9.2.0(@types/react@19.1.17)(react@19.1.0)(redux@5.0.1))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react-redux@9.2.0(@types/react@19.1.17)(react@19.1.0)(redux@5.0.1))(react@19.1.0))(@iblai/iblai-api@4.166.0-ai)(@tauri-apps/api@2.11.0)(@tauri-apps/plugin-os@2.3.2)(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(redux@5.0.1)(sonner@2.0.7(react-dom@19.1.0(react@19.1.0))(react@19.1.0)) '@radix-ui/react-dialog': 1.1.7(@types/react-dom@19.2.1(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@reduxjs/toolkit': 2.7.0(react-redux@9.2.0(@types/react@19.1.17)(react@19.1.0)(redux@5.0.1))(react@19.1.0) axios: 1.18.1 @@ -10001,7 +10040,7 @@ snapshots: - supports-color - typescript - '@iblai/mcp@1.7.8': + '@iblai/mcp@1.8.2': dependencies: '@modelcontextprotocol/sdk': 1.27.1(zod@4.3.6) zod: 4.3.6 @@ -10009,16 +10048,18 @@ snapshots: - '@cfworker/json-schema' - supports-color - '@iblai/web-containers@1.12.2-computer-use-1.11(12ec236c47d23656aecb696e96de9b87)': + '@iblai/web-containers@1.15.4-computer-use-1.12(ee6385f528e0f5f01760b2e47e6bc333)': dependencies: - '@iblai/data-layer': 1.9.2(@reduxjs/toolkit@2.7.0(react-redux@9.2.0(@types/react@19.1.17)(react@19.1.0)(redux@5.0.1))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react-redux@9.2.0(@types/react@19.1.17)(react@19.1.0)(redux@5.0.1))(react@19.1.0) + '@iblai/data-layer': 1.9.5(@reduxjs/toolkit@2.7.0(react-redux@9.2.0(@types/react@19.1.17)(react@19.1.0)(redux@5.0.1))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react-redux@9.2.0(@types/react@19.1.17)(react@19.1.0)(redux@5.0.1))(react@19.1.0) '@iblai/iblai-api': 4.166.0-ai - '@iblai/web-utils': 1.11.12-computer-use-1.12(@iblai/data-layer@1.9.2(@reduxjs/toolkit@2.7.0(react-redux@9.2.0(@types/react@19.1.17)(react@19.1.0)(redux@5.0.1))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react-redux@9.2.0(@types/react@19.1.17)(react@19.1.0)(redux@5.0.1))(react@19.1.0))(@iblai/iblai-api@4.166.0-ai)(@tauri-apps/api@2.11.0)(@tauri-apps/plugin-os@2.3.2)(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(redux@5.0.1)(sonner@2.0.7(react-dom@19.1.0(react@19.1.0))(react@19.1.0)) + '@iblai/web-utils': 1.13.4-computer-use-1.12(@iblai/data-layer@1.9.5(@reduxjs/toolkit@2.7.0(react-redux@9.2.0(@types/react@19.1.17)(react@19.1.0)(redux@5.0.1))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react-redux@9.2.0(@types/react@19.1.17)(react@19.1.0)(redux@5.0.1))(react@19.1.0))(@iblai/iblai-api@4.166.0-ai)(@tauri-apps/api@2.11.0)(@tauri-apps/plugin-os@2.3.2)(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(redux@5.0.1)(sonner@2.0.7(react-dom@19.1.0(react@19.1.0))(react@19.1.0)) '@radix-ui/react-accordion': 1.2.8(@types/react-dom@19.2.1(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-avatar': 1.1.7(@types/react-dom@19.2.1(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-checkbox': 1.2.3(@types/react-dom@19.2.1(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-collapsible': 1.1.12(@types/react-dom@19.2.1(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-dialog': 1.1.7(@types/react-dom@19.2.1(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-dropdown-menu': 2.1.7(@types/react-dom@19.2.1(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-hover-card': 1.1.15(@types/react-dom@19.2.1(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-label': 2.1.8(@types/react-dom@19.2.1(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-popover': 1.1.7(@types/react-dom@19.2.1(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-progress': 1.1.1(@types/react-dom@19.2.1(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) @@ -10095,10 +10136,10 @@ snapshots: - supports-color - vinxi - '@iblai/web-utils@1.11.12-computer-use-1.12(@iblai/data-layer@1.9.2(@reduxjs/toolkit@2.7.0(react-redux@9.2.0(@types/react@19.1.17)(react@19.1.0)(redux@5.0.1))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react-redux@9.2.0(@types/react@19.1.17)(react@19.1.0)(redux@5.0.1))(react@19.1.0))(@iblai/iblai-api@4.166.0-ai)(@tauri-apps/api@2.11.0)(@tauri-apps/plugin-os@2.3.2)(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(redux@5.0.1)(sonner@2.0.7(react-dom@19.1.0(react@19.1.0))(react@19.1.0))': + '@iblai/web-utils@1.13.4-computer-use-1.12(@iblai/data-layer@1.9.5(@reduxjs/toolkit@2.7.0(react-redux@9.2.0(@types/react@19.1.17)(react@19.1.0)(redux@5.0.1))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react-redux@9.2.0(@types/react@19.1.17)(react@19.1.0)(redux@5.0.1))(react@19.1.0))(@iblai/iblai-api@4.166.0-ai)(@tauri-apps/api@2.11.0)(@tauri-apps/plugin-os@2.3.2)(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(redux@5.0.1)(sonner@2.0.7(react-dom@19.1.0(react@19.1.0))(react@19.1.0))': dependencies: '@ai-sdk/openai-compatible': 3.0.5(zod@3.25.76) - '@iblai/data-layer': 1.9.2(@reduxjs/toolkit@2.7.0(react-redux@9.2.0(@types/react@19.1.17)(react@19.1.0)(redux@5.0.1))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react-redux@9.2.0(@types/react@19.1.17)(react@19.1.0)(redux@5.0.1))(react@19.1.0) + '@iblai/data-layer': 1.9.5(@reduxjs/toolkit@2.7.0(react-redux@9.2.0(@types/react@19.1.17)(react@19.1.0)(redux@5.0.1))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react-redux@9.2.0(@types/react@19.1.17)(react@19.1.0)(redux@5.0.1))(react@19.1.0) '@iblai/iblai-api': 4.166.0-ai '@modelcontextprotocol/sdk': 1.27.1(zod@3.25.76) '@reduxjs/toolkit': 2.11.2(react-redux@9.2.0(@types/react@19.1.17)(react@19.1.0)(redux@5.0.1))(react@19.1.0) @@ -10780,6 +10821,15 @@ snapshots: '@types/react': 19.1.17 '@types/react-dom': 19.2.1(@types/react@19.1.17) + '@radix-ui/react-arrow@1.1.7(@types/react-dom@19.2.1(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.1(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + optionalDependencies: + '@types/react': 19.1.17 + '@types/react-dom': 19.2.1(@types/react@19.1.17) + '@radix-ui/react-aspect-ratio@1.1.1(@types/react-dom@19.2.1(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@radix-ui/react-primitive': 2.0.1(@types/react-dom@19.2.1(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) @@ -11167,6 +11217,23 @@ snapshots: '@types/react': 19.1.17 '@types/react-dom': 19.2.1(@types/react@19.1.17) + '@radix-ui/react-hover-card@1.1.15(@types/react-dom@19.2.1(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.17)(react@19.1.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.1.17)(react@19.1.0) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.1(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.1(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.1(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.1(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.1(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.17)(react@19.1.0) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + optionalDependencies: + '@types/react': 19.1.17 + '@types/react-dom': 19.2.1(@types/react@19.1.17) + '@radix-ui/react-hover-card@1.1.4(@types/react-dom@19.2.1(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@radix-ui/primitive': 1.1.1 @@ -11392,6 +11459,24 @@ snapshots: '@types/react': 19.1.17 '@types/react-dom': 19.2.1(@types/react@19.1.17) + '@radix-ui/react-popper@1.2.8(@types/react-dom@19.2.1(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@floating-ui/react-dom': 2.1.8(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-arrow': 1.1.7(@types/react-dom@19.2.1(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.17)(react@19.1.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.1.17)(react@19.1.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.1(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.17)(react@19.1.0) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.17)(react@19.1.0) + '@radix-ui/react-use-rect': 1.1.1(@types/react@19.1.17)(react@19.1.0) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.1.17)(react@19.1.0) + '@radix-ui/rect': 1.1.1 + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + optionalDependencies: + '@types/react': 19.1.17 + '@types/react-dom': 19.2.1(@types/react@19.1.17) + '@radix-ui/react-portal@1.1.3(@types/react-dom@19.2.1(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@radix-ui/react-primitive': 2.0.1(@types/react-dom@19.2.1(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) From a77df3c366ee920841c393a1448853c5f951ca52 Mon Sep 17 00:00:00 2001 From: null-crafter <163591881+null-crafter@users.noreply.github.com> Date: Thu, 23 Jul 2026 16:21:43 +0000 Subject: [PATCH 07/23] =?UTF-8?q?feat(mentor):=20on-device=20models=20in?= =?UTF-8?q?=20the=20LLM=20picker=20=E2=80=94=20canonical=20provider=20name?= =?UTF-8?q?s/icons,=20available-first=20ordering,=20and=20accurate=20insta?= =?UTF-8?q?ll=20status?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../__tests__/llm-provider-modal.test.tsx | 89 ++++++++++ .../tabs/__tests__/llm-tab.test.tsx | 4 - .../modals/edit-mentor-modal/tabs/llm-tab.tsx | 18 +- components/modals/llm-provider-modal.tsx | 155 ++++++++++-------- .../llm-provider-modal/local-model-row.tsx | 9 +- .../use-model-download.coverage.test.ts | 21 ++- hooks/use-model-download.ts | 55 ++++--- lib/__tests__/utils.test.ts | 53 ++++++ lib/utils.ts | 115 ++++++++----- public/llm-alibaba-provider.png | Bin 0 -> 29855 bytes public/llm-ibm-provider.png | Bin 0 -> 1045 bytes 11 files changed, 375 insertions(+), 144 deletions(-) create mode 100644 public/llm-alibaba-provider.png create mode 100644 public/llm-ibm-provider.png diff --git a/components/modals/__tests__/llm-provider-modal.test.tsx b/components/modals/__tests__/llm-provider-modal.test.tsx index 93c4a6e9..2f75bfe7 100644 --- a/components/modals/__tests__/llm-provider-modal.test.tsx +++ b/components/modals/__tests__/llm-provider-modal.test.tsx @@ -21,6 +21,51 @@ vi.mock('next/image', () => ({ // classname joiner (cheap, no side effects) so emitted classes are meaningful. let mockSwitchLLm = true; let mockSwitchProvider = true; +// On-device model state — OFF by default so the cloud-only tests are unaffected +// (localModels is gated on `isAvailable`); one test flips these on to exercise +// the available-first ordering. +let mockLocalAvailable = false; +let mockInstalledModels: string[] = []; +vi.mock('@/hooks/use-model-download', () => ({ + useModelDownload: () => ({ + isAvailable: mockLocalAvailable, + state: { + status: 'idle', + progress: 0, + activeModel: undefined, + message: '', + logs: [], + lastUpdated: '', + }, + ollamaStatus: { installed_models: mockInstalledModels }, + startDownload: vi.fn(), + cancelDownload: vi.fn(), + }), +})); +vi.mock('@iblai/iblai-js/web-containers', () => ({ + LOCAL_MODELS: [ + { + name: 'Zeta Download', + provider: 'OpenAI', + id: 'dl-model', + size: '3 GB', + tool_support: true, + }, + { + name: 'Alpha Ready', + provider: 'OpenAI', + id: 'ready-model', + size: '2 GB', + tool_support: true, + }, + ], + isLocalLLMEnabled: () => false, + getLocalLLMModel: () => null, + getLocalLLMToolSupport: () => true, + setLocalLLMModel: vi.fn(), + setLocalLLMEnabled: vi.fn(), + setLocalLLMToolSupport: vi.fn(), +})); vi.mock('@/lib/utils', () => ({ cn: (...args: unknown[]) => { const out: string[] = []; @@ -40,6 +85,8 @@ vi.mock('@/lib/utils', () => ({ logo: `/logo-${provider}-${name}.png`, name: provider, }), + getProviderName: (name: string) => + (name ?? '').toLowerCase().replace(/[^a-z0-9]/g, ''), })); const buildProvider = (): LLMProvider => ({ @@ -82,6 +129,8 @@ describe('LLMProviderModal', () => { vi.clearAllMocks(); mockSwitchLLm = true; mockSwitchProvider = true; + mockLocalAvailable = false; + mockInstalledModels = []; }); it('does not render content when closed', () => { @@ -125,6 +174,46 @@ describe('LLMProviderModal', () => { expect(screen.queryByText('gpt-4')).not.toBeInTheDocument(); }); + it('lists available (installed) local models before unavailable (downloadable) ones', () => { + // Catalog order is [Zeta Download (not installed), Alpha Ready (installed)]; + // the merged list must reorder so the ready one comes first. + mockLocalAvailable = true; + mockInstalledModels = ['ready-model']; + render(); + + const ready = screen.getByText('Alpha Ready'); + const download = screen.getByText('Zeta Download'); + // "Zeta Download" must FOLLOW "Alpha Ready" in the DOM (installed sorts + // first), even though it comes first in the catalog. + expect( + ready.compareDocumentPosition(download) & + Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + }); + + it('lists an available local model before unavailable cloud models', () => { + // The reported case: the provider has no credentials, so its cloud models + // are unavailable — an installed on-device model must lead the whole list, + // not sit behind the cloud models. + mockSwitchLLm = false; // provider not switchable → cloud models unavailable + mockLocalAvailable = true; + mockInstalledModels = ['ready-model']; + render( + , + ); + + const ready = screen.getByText('Alpha Ready'); + const cloud = screen.getByText('gpt-4'); + // The installed local model precedes the unavailable cloud model. + expect( + ready.compareDocumentPosition(cloud) & Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + }); + it('disables the active model and enables the non-active one (no grayscale on active)', () => { render(); diff --git a/components/modals/edit-mentor-modal/tabs/__tests__/llm-tab.test.tsx b/components/modals/edit-mentor-modal/tabs/__tests__/llm-tab.test.tsx index c53e2f66..608a6c5e 100644 --- a/components/modals/edit-mentor-modal/tabs/__tests__/llm-tab.test.tsx +++ b/components/modals/edit-mentor-modal/tabs/__tests__/llm-tab.test.tsx @@ -88,10 +88,6 @@ vi.mock('@/components/modals/llm-provider-modal', () => ({
) : null; }, - // Faithful to the real normalizer (lowercase + strip non-alphanumerics); the - // tab now calls this unconditionally to compute the highlighted provider. - providerKey: (name: string) => - (name ?? '').toLowerCase().replace(/[^a-z0-9]/g, ''), })); // ============================================================================ diff --git a/components/modals/edit-mentor-modal/tabs/llm-tab.tsx b/components/modals/edit-mentor-modal/tabs/llm-tab.tsx index 811a4676..242e7cf2 100644 --- a/components/modals/edit-mentor-modal/tabs/llm-tab.tsx +++ b/components/modals/edit-mentor-modal/tabs/llm-tab.tsx @@ -18,11 +18,15 @@ import { useUsername } from '@/hooks/use-user'; import { LLMProvider, LLMProviderModal, - providerKey, } from '@/components/modals/llm-provider-modal'; import { TenantKeyMentorIdParams } from '@/lib/types'; import { toast } from 'sonner'; -import { cn, getLLMProviderDetails, Provider } from '@/lib/utils'; +import { + cn, + getLLMProviderDetails, + getProviderName, + Provider, +} from '@/lib/utils'; import { useNavigate } from '@/hooks/user-navigate'; import { Spinner } from '@/components/spinner'; import WithFormPermissions from '@/hoc/withPermissions'; @@ -81,12 +85,12 @@ export function LLMTab({ showConfigurationHeader = true }: LLMTabProps) { const localOnlyProviders = React.useMemo(() => { if (!isTauriApp()) return [] as { key: string; provider: string }[]; const cloudKeys = new Set( - (llmProviders ?? []).map((p) => providerKey(p.name)), + (llmProviders ?? []).map((p) => getProviderName(p.name)), ); const seen = new Set(); const result: { key: string; provider: string }[] = []; for (const model of LOCAL_MODELS) { - const key = providerKey(model.provider); + const key = getProviderName(model.provider); if (cloudKeys.has(key) || seen.has(key)) continue; seen.add(key); result.push({ key, provider: model.provider }); @@ -102,9 +106,9 @@ export function LLMTab({ showConfigurationHeader = true }: LLMTabProps) { const selectedLocal = useSelectedLocalModel(); const activeProviderKey = selectedLocal.isLocal && selectedLocal.model - ? providerKey(selectedLocal.model.provider) + ? getProviderName(selectedLocal.model.provider) : mentorSettings?.llm_provider - ? providerKey(mentorSettings.llm_provider) + ? getProviderName(mentorSettings.llm_provider) : ''; async function updateMentorLLM(llmProvider: string, llmName: string) { @@ -199,7 +203,7 @@ export function LLMTab({ showConfigurationHeader = true }: LLMTabProps) { { 'border-blue-500': !!activeProviderKey && - providerKey(model.name) === activeProviderKey, + getProviderName(model.name) === activeProviderKey, }, )} onClick={() => { diff --git a/components/modals/llm-provider-modal.tsx b/components/modals/llm-provider-modal.tsx index e453f4d9..bbda8a2a 100644 --- a/components/modals/llm-provider-modal.tsx +++ b/components/modals/llm-provider-modal.tsx @@ -19,6 +19,7 @@ import { canSwitchProvider, cn, getLLMProviderDetails, + getProviderName, Provider, } from '@/lib/utils'; import { useModelDownload } from '@/hooks/use-model-download'; @@ -79,16 +80,6 @@ function isModelInstalled(modelId: string, tags?: string[]): boolean { return !!tags?.some((t) => t === modelId || t.startsWith(`${modelId}:`)); } -// Match a local model's `provider` (e.g. "Mistral AI") to a cloud provider name -// (e.g. "mistral") tolerantly: strip non-alphanumerics + a couple of aliases. -const PROVIDER_ALIASES: Record = { - mistralai: 'mistral', - metallama: 'meta', -}; -export function providerKey(name: string): string { - const normalized = name.toLowerCase().replace(/[^a-z0-9]/g, ''); - return PROVIDER_ALIASES[normalized] ?? normalized; -} export function LLMProviderModal({ isOpen, @@ -132,10 +123,10 @@ export function LLMProviderModal({ // Local models belonging to THIS provider (merged into the same list). const localModels = React.useMemo(() => { if (!isAvailable) return [] as LocalModel[]; - const key = providerKey(llmProvider.name); + const key = getProviderName(llmProvider.name); return LOCAL_MODELS.filter( (m) => - providerKey(m.provider) === key && + getProviderName(m.provider) === key && m.name.toLowerCase().includes(searchQuery.toLowerCase()), ); }, [isAvailable, llmProvider.name, searchQuery]); @@ -156,6 +147,39 @@ export function LLMProviderModal({ return 'not-installed'; }; + // A cloud model is "in use" when it's the mentor's selected LLM and local mode + // is off. + const isCloudActive = (llm: LLM): boolean => + mentorSettings?.llm_name === llm.llm_name && + mentorSettings?.llm_provider === llmProvider.name && + !localSel.enabled; + + // Order the WHOLE list — cloud + local — available (usable now) first, + // unavailable last, with the in-use model leading. "Available" for a cloud + // model means the provider is switchable (has credentials + models); for a + // local model it's the install state. So an installed on-device model outranks + // cloud models whose provider has no credentials. Ranked by state, not the + // transient download status, so rows don't jump mid-pull; a stable sort keeps + // the original (catalog) order within each rank. + const cloudRank = (llm: LLM): number => + isCloudActive(llm) ? 0 : switchLLMAllowed && switchProviderAllowed ? 1 : 2; + const localRank = (m: LocalModel): number => { + const s = statusFor(m); + return s === 'selected' ? 0 : s === 'installed' ? 1 : 2; + }; + const sortedModels = [ + ...filteredLLMs.map((llm) => ({ + kind: 'cloud' as const, + llm, + rank: cloudRank(llm), + })), + ...localModels.map((model) => ({ + kind: 'local' as const, + model, + rank: localRank(model), + })), + ].sort((a, b) => a.rank - b.rank); + const activateLocal = (m: LocalModel, status: LocalRowStatus) => { switch (status) { case 'not-installed': @@ -239,66 +263,67 @@ export function LLMProviderModal({
- {filteredLLMs.map((llm) => { - const isActive = - mentorSettings?.llm_name === llm.llm_name && - mentorSettings?.llm_provider === llmProvider.name && - !localSel.enabled; + {/* Cloud + on-device models in one availability-ranked list: + available (usable now) first, unavailable (no credentials / needs + download) last. */} + {sortedModels.map((item) => { + if (item.kind === 'cloud') { + const llm = item.llm; + const isActive = isCloudActive(llm); - const providerDetails = getLLMProviderDetails( - llmProvider.name, - llm.llm_name, - ); + const providerDetails = getLLMProviderDetails( + llmProvider.name, + llm.llm_name, + ); - const isDisabled = - !switchLLMAllowed || - isSelecting || - isActive || - !switchProviderAllowed; + const isDisabled = + !switchLLMAllowed || + isSelecting || + isActive || + !switchProviderAllowed; - return ( - - ); - })} + return ( + + ); + } - {/* On-device models for this provider, merged into the same list. */} - {localModels.map((m) => { + const m = item.model; const status = statusFor(m); return ( )} - {status === 'installed' && ( - <> - - Use - - )} + {/* Installed but not selected: no extra label. The absence of the + size + download icon (present only on downloadable rows) signals + the model is ready. */} {status === 'selected' && (
- {/* Cloud + on-device models in one availability-ranked list: - available (usable now) first, unavailable (no credentials / needs - download) last. */} - {sortedModels.map((item) => { + {/* Cloud + on-device models in one availability-ranked list: + available (usable now) first, unavailable (no credentials / needs + download) last. */} + {sortedModels.map((item) => { if (item.kind === 'cloud') { const llm = item.llm; const isActive = isCloudActive(llm); @@ -329,15 +477,77 @@ export function LLMProviderModal({ logo={getLLMProviderDetails(llmProvider.name, m.name).logo} status={status} progress={ - downloadState.activeModel === m.id ? downloadState.progress : 0 + sameModel(downloadingModel, m.id) ? downloadState.progress : 0 } - disabled={busyDownloading && downloadState.activeModel !== m.id} - disabledReason="Another model is downloading" onActivate={() => activateLocal(m, status)} /> ); })}
+ + {/* "Model too large" confirmation — a dialog layered on top of the + models page (not a toast), so it doesn't dismiss the picker and the + confirm actually starts the download. */} + { + if (!open) setPendingModel(null); + }} + > + + + + This model may be too large for your system + + + {pendingModel?.name} needs about {pendingModel?.size}. It may run + very slowly or fail to load on this machine. Download anyway? + + + + setPendingModel(null)}> + Cancel + + { + if (pendingModel) { + setRequestedModel(pendingModel.id); + startDownload(pendingModel.id); + } + setPendingModel(null); + }} + > + Download anyway + + + + + + {/* "Another model is downloading" notice — shown when the user clicks a + different model while one is pulling (one download at a time). The + download is cancelled by clicking the downloading model itself. */} + { + if (!open) setBusyDownloadName(null); + }} + > + + + A model is already downloading + + {busyDownloadName} is being downloaded. Only one on-device model + downloads at a time — wait for it to finish, or click{' '} + {busyDownloadName} in the list to cancel it. + + + + setBusyDownloadName(null)}> + Got it + + + + ); diff --git a/components/modals/llm-provider-modal/local-model-row.tsx b/components/modals/llm-provider-modal/local-model-row.tsx index 77e13639..0db0f23a 100644 --- a/components/modals/llm-provider-modal/local-model-row.tsx +++ b/components/modals/llm-provider-modal/local-model-row.tsx @@ -1,6 +1,5 @@ 'use client'; -import React from 'react'; import Image from 'next/image'; import { Download, X, Check, RotateCcw } from 'lucide-react'; import { cn } from '@/lib/utils'; @@ -31,64 +30,6 @@ interface LocalModelRowProps { onActivate: () => void; } -const RING_SIZE = 32; -const RING_STROKE = 3; - -/** - * Determinate progress ring drawn around the model logo. The fill transition is - * gated behind `motion-safe` so it stays static under prefers-reduced-motion. - */ -function ProgressRing({ - value, - children, -}: { - value: number; - children: React.ReactNode; -}) { - const r = (RING_SIZE - RING_STROKE) / 2; - const circumference = 2 * Math.PI * r; - const clamped = Math.max(0, Math.min(100, value)); - const offset = circumference - (clamped / 100) * circumference; - return ( - - - {/* Slightly inset logo so the ring reads clearly around it. */} - - {children} - - - ); -} - /** * A local (on-device) model presented in the same list as cloud models. It uses * the same provider logo as the cloud rows; the model name gets its own line and @@ -142,7 +83,10 @@ export function LocalModelRow({ if (!inert) onActivate(); }} className={cn( - 'group flex items-center gap-3 rounded-lg border p-4 text-left transition-colors', + // `isolate` makes the button its own stacking context so the absolute + // progress fill (z-0) layers above the button's background and below the + // content (z-10) — without it the fill can render behind the background. + 'group relative isolate flex items-center gap-3 overflow-hidden rounded-lg border p-4 text-left transition-colors', selected ? 'cursor-default border-blue-500 bg-blue-50' : isBusyDisabled @@ -150,37 +94,35 @@ export function LocalModelRow({ : 'cursor-pointer border-gray-200 hover:border-blue-500 hover:bg-blue-50', )} > - {/* Same logo treatment as cloud rows; ringed with progress while downloading. */} - - {downloading ? ( - - - - ) : ( - - )} + {/* Download progress: a card-tall, translucent bar filling left→right + behind the row content. Static under prefers-reduced-motion. */} + {downloading && ( +