mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
feat(desktop): five Capabilities-tab UX fixes from live testing — hints, vision link, web split, key deep-links (#67482)
* fix(desktop): stop contradicting the Ready pill with the one-time-install hint
When a provider's server-computed status is 'ready' (post_setup install
verifiably satisfied, e.g. cua-driver on PATH), the PostSetupRunner row
still said 'This backend needs a one-time install (…)'. Swap the copy for
a muted installed-confirmation one-liner and keep the Run setup button for
repair re-runs. Gated purely on the provider status prop so it composes
with the server-driven resting state work in the sibling lane.
* feat(tools): surface the web search/extract capability split in the Capabilities UI
The runtime has dispatched web_search and web_extract to independently
configurable backends for a long time (web.search_backend /
web.extract_backend overrides with web.backend as the shared fallback),
but the Capabilities tab still presented one monolithic 'Web Search &
Extract' choice that only wrote web.backend.
Backend:
- GET /api/tools/toolsets/web/config now returns active_search_backend /
active_extract_backend resolved via the REAL runtime getters
(tools.web_tools._get_search_backend/_get_extract_backend), plus each
provider row's web_backend key and supported capabilities (from the
registry's supports_search/supports_extract flags).
- PUT /api/tools/toolsets/web/provider accepts an optional capability
('search'|'extract') that writes web.<capability>_backend without
touching web.backend; validates the provider actually supports the
requested capability (ddgs/brave-free are search-only). Omitted →
unchanged legacy apply_provider_selection path.
- New tools_config.web_provider_capabilities() helper reads the plugin
registry's capability flags.
Frontend: 'Search: <backend>' / 'Extract: <backend>' pills above the web
provider matrix, per-row 'Search backend'/'Extract backend' assignment
pills, and 'Use for Search'/'Use for Extract' actions gated on each
backend's declared capabilities.
Tests: endpoint tests assert the runtime getters resolve to the written
backend (searxng for search, firecrawl for extract) after the endpoint
write; vitest covers badges, capability-gated buttons, and non-web
toolsets staying untouched.
* feat(desktop): deep-link Capabilities key rows to Settings → API Keys
Set env-var rows in the toolset config panel now offer 'Manage in API
Keys' in the row actions menu — an internal route change to
/settings?tab=keys&key=<ENV_KEY>. KeysSettings consumes the ?key= param
via the shared useDeepLinkHighlight hook (same mechanism as the command
palette's ?field= config deep links and ?session= archived-session
links): scrolls the credential card into view, flashes it, and expands
it. Applies generically to every env-var row, and only when the key is
set (unset keys are managed inline via Set). i18n in en/zh/zh-hant/ja.
* feat(desktop): point the vision Capabilities detail at Settings → Models
The vision toolset has no TOOL_CATEGORIES provider matrix — its
provider/model resolution runs through the auxiliary model config
(agent/auxiliary_client.py), so the Capabilities detail pane looked
empty with no hint of where the model choice lives.
Add a short explainer + an internal deep link
(/settings?tab=config:model&aux=vision) rendered only for
toolset.name === 'vision'. ModelSettings consumes the ?aux= param via
the shared useDeepLinkHighlight hook and scrolls/flashes the matching
auxiliary task row (rows now carry aux-task-<key> anchor ids). No
external URLs. i18n in en/zh/zh-hant/ja.
* test(desktop): use type-alias imports for the react-router mock (lint)
* chore: drop accidentally committed node_modules symlinks
* chore: drop remaining committed node_modules symlinks (apps/desktop, apps/shared)
This commit is contained in:
parent
3fc006ebe1
commit
2ae0d67f63
18 changed files with 716 additions and 33 deletions
|
|
@ -12,7 +12,7 @@ import {
|
|||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { ExternalLink, Eye, EyeOff, Trash2 } from '@/lib/icons'
|
||||
import { ExternalLink, Eye, EyeOff, KeyRound, Trash2 } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface EnvVarActionsMenuProps extends Pick<
|
||||
|
|
@ -27,6 +27,10 @@ interface EnvVarActionsMenuProps extends Pick<
|
|||
label: string
|
||||
onClear?: () => void
|
||||
onEdit: () => void
|
||||
/** Internal navigation to Settings → API Keys with this key highlighted.
|
||||
* Rendered only when provided AND the key is set (an unset key is managed
|
||||
* right here via Set). */
|
||||
onManageKeys?: () => void
|
||||
onReveal?: () => void
|
||||
showReveal?: boolean
|
||||
}
|
||||
|
|
@ -41,6 +45,7 @@ export function EnvVarActionsMenu({
|
|||
label,
|
||||
onClear,
|
||||
onEdit,
|
||||
onManageKeys,
|
||||
onReveal,
|
||||
showReveal = true,
|
||||
sideOffset = 6
|
||||
|
|
@ -49,6 +54,7 @@ export function EnvVarActionsMenu({
|
|||
const copy = t.settings.envActions
|
||||
const hasClear = isSet && onClear
|
||||
const hasReveal = isSet && showReveal && onReveal
|
||||
const hasManageKeys = isSet && onManageKeys
|
||||
const hasDocs = Boolean(docsUrl?.trim())
|
||||
|
||||
return (
|
||||
|
|
@ -90,6 +96,18 @@ export function EnvVarActionsMenu({
|
|||
<span>{isSet ? copy.replace : copy.set}</span>
|
||||
</DropdownMenuItem>
|
||||
|
||||
{hasManageKeys && (
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
triggerHaptic('selection')
|
||||
onManageKeys()
|
||||
}}
|
||||
>
|
||||
<KeyRound className="size-3.5" />
|
||||
<span>{copy.manageInKeys}</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
{hasClear && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { CredentialKeyCard, credentialPlaceholder, credentialRowLabel } from './
|
|||
import { useEnvCredentials } from './env-credentials'
|
||||
import { asText } from './helpers'
|
||||
import { LoadingState, SettingsContent } from './primitives'
|
||||
import { useDeepLinkHighlight } from './use-deep-link-highlight'
|
||||
|
||||
// Sub-views surfaced as sidebar subnav under Tools & Keys (see settings/index.tsx).
|
||||
export const KEYS_VIEWS = ['tools', 'settings'] as const
|
||||
|
|
@ -36,6 +37,16 @@ export function KeysSettings({ view }: KeysSettingsProps) {
|
|||
setOpenKey(null)
|
||||
}, [view])
|
||||
|
||||
// Deep link from Capabilities env-var rows (?tab=keys&key=<ENV_KEY>): scroll
|
||||
// the credential card into view, flash it, and expand it. Same mechanism the
|
||||
// command palette uses for config fields / archived sessions.
|
||||
useDeepLinkHighlight({
|
||||
elementId: key => `credential-key-${key}`,
|
||||
onResolve: key => setOpenKey(key),
|
||||
param: 'key',
|
||||
ready: key => Boolean(vars && key in vars)
|
||||
})
|
||||
|
||||
const groups = useMemo(() => {
|
||||
if (!vars) {
|
||||
return []
|
||||
|
|
@ -66,17 +77,18 @@ export function KeysSettings({ view }: KeysSettingsProps) {
|
|||
const label = credentialRowLabel(key, info)
|
||||
|
||||
return (
|
||||
<CredentialKeyCard
|
||||
expanded={openKey === key}
|
||||
info={info}
|
||||
key={key}
|
||||
label={label}
|
||||
onExpand={() => setOpenKey(key)}
|
||||
onToggle={() => setOpenKey(prev => (prev === key ? null : key))}
|
||||
placeholder={credentialPlaceholder(key, info, label)}
|
||||
rowProps={rowProps}
|
||||
varKey={key}
|
||||
/>
|
||||
<div className="scroll-mt-6 rounded-[6px]" id={`credential-key-${key}`} key={key}>
|
||||
<CredentialKeyCard
|
||||
expanded={openKey === key}
|
||||
info={info}
|
||||
label={label}
|
||||
onExpand={() => setOpenKey(key)}
|
||||
onToggle={() => setOpenKey(prev => (prev === key ? null : key))}
|
||||
placeholder={credentialPlaceholder(key, info, label)}
|
||||
rowProps={rowProps}
|
||||
varKey={key}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// Radix Select calls scrollIntoView on its items when the content opens; jsdom
|
||||
|
|
@ -88,9 +89,13 @@ async function renderModelSettings() {
|
|||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
|
||||
return render(
|
||||
<QueryClientProvider client={client}>
|
||||
<ModelSettings />
|
||||
</QueryClientProvider>
|
||||
// The aux-task deep-link highlight reads useSearchParams, so the page
|
||||
// needs a router context in tests (the app provides HashRouter at root).
|
||||
<MemoryRouter>
|
||||
<QueryClientProvider client={client}>
|
||||
<ModelSettings />
|
||||
</QueryClientProvider>
|
||||
</MemoryRouter>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ import { useOnProfileSwitch } from '../hooks/use-on-profile-switch'
|
|||
import { CONTROL_TEXT } from './constants'
|
||||
import { getNested, setNested } from './helpers'
|
||||
import { ListRow, Pill, SectionHeading } from './primitives'
|
||||
import { useDeepLinkHighlight } from './use-deep-link-highlight'
|
||||
|
||||
// Skeleton mirror of the Model settings DOM so the page keeps its shape while
|
||||
// the provider/model catalog loads, instead of collapsing to a centered
|
||||
|
|
@ -215,6 +216,14 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
|
|||
const [apiKeyDraft, setApiKeyDraft] = useState('')
|
||||
const [activating, setActivating] = useState(false)
|
||||
|
||||
// Deep link from the vision Capabilities detail (?tab=config:model&aux=vision):
|
||||
// scroll the auxiliary task row into view and flash it once the list loads.
|
||||
useDeepLinkHighlight({
|
||||
elementId: task => `aux-task-${task}`,
|
||||
param: 'aux',
|
||||
ready: task => AUX_TASKS.some(meta => meta.key === task)
|
||||
})
|
||||
|
||||
// Every profile-scoped async here captures this and bails before writing back,
|
||||
// so a request in flight when the user switches profiles can't paint profile
|
||||
// A's models/providers into profile B (or fire onMainModelChanged for A).
|
||||
|
|
@ -877,7 +886,8 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
|
|||
const isEditing = editingAuxTask === meta.key
|
||||
|
||||
return (
|
||||
<ListRow
|
||||
<div className="scroll-mt-6 rounded-lg" id={`aux-task-${meta.key}`} key={meta.key}>
|
||||
<ListRow
|
||||
action={
|
||||
!isEditing && (
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
|
|
@ -951,7 +961,6 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
|
|||
{isAuto ? m.autoUseMain : `${current.provider} · ${current.model || m.providerDefault}`}
|
||||
</span>
|
||||
}
|
||||
key={meta.key}
|
||||
title={
|
||||
<span className="flex items-baseline gap-2">
|
||||
{copy.label}
|
||||
|
|
@ -959,6 +968,7 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
|
|||
</span>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,22 @@
|
|||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { cleanup, fireEvent, render as rtlRender, screen, waitFor } from '@testing-library/react'
|
||||
import type { ReactElement } from 'react'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import type * as ReactRouterDom from 'react-router-dom'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { ToolsetConfig } from '@/types/hermes'
|
||||
|
||||
// EnvVarField navigates to Settings → Keys via useNavigate, so every render
|
||||
// needs a router context. The navigate spy asserts the deep-link target.
|
||||
const navigateSpy = vi.fn()
|
||||
|
||||
vi.mock('react-router-dom', async importOriginal => ({
|
||||
...(await importOriginal<typeof ReactRouterDom>()),
|
||||
useNavigate: () => navigateSpy
|
||||
}))
|
||||
|
||||
const render = (ui: ReactElement) => rtlRender(ui, { wrapper: MemoryRouter })
|
||||
|
||||
const getToolsetConfig = vi.fn()
|
||||
const getToolsetModels = vi.fn()
|
||||
const selectToolsetModel = vi.fn()
|
||||
|
|
@ -19,7 +33,8 @@ vi.mock('@/hermes', () => ({
|
|||
getToolsetConfig: (name: string) => getToolsetConfig(name),
|
||||
getToolsetModels: (name: string, provider?: string) => getToolsetModels(name, provider),
|
||||
selectToolsetModel: (name: string, model: string, provider?: string) => selectToolsetModel(name, model, provider),
|
||||
selectToolsetProvider: (name: string, provider: string) => selectToolsetProvider(name, provider),
|
||||
selectToolsetProvider: (name: string, provider: string, capability?: string) =>
|
||||
capability === undefined ? selectToolsetProvider(name, provider) : selectToolsetProvider(name, provider, capability),
|
||||
setEnvVar: (key: string, value: string) => setEnvVar(key, value),
|
||||
deleteEnvVar: (key: string) => deleteEnvVar(key),
|
||||
revealEnvVar: (key: string) => revealEnvVar(key),
|
||||
|
|
@ -363,6 +378,40 @@ describe('ToolsetConfigPanel', () => {
|
|||
})
|
||||
})
|
||||
|
||||
|
||||
it('swaps the install hint for the installed one-liner when the provider is ready', async () => {
|
||||
// Server says the post_setup install is already satisfied (status ready) —
|
||||
// the "needs a one-time install" copy would contradict the Ready pill.
|
||||
getToolsetConfig.mockResolvedValue(
|
||||
config({
|
||||
name: 'browser',
|
||||
active_provider: 'Camofox',
|
||||
providers: [
|
||||
{
|
||||
name: 'Camofox',
|
||||
badge: 'local',
|
||||
tag: 'Stealth local browser',
|
||||
env_vars: [],
|
||||
post_setup: 'camofox',
|
||||
requires_nous_auth: false,
|
||||
is_active: true,
|
||||
status: 'ready'
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
|
||||
const { ToolsetConfigPanel } = await import('./toolset-config-panel')
|
||||
render(<ToolsetConfigPanel onConfiguredChange={vi.fn()} toolset="browser" />)
|
||||
|
||||
// Installed confirmation replaces the contradictory install prompt…
|
||||
expect(await screen.findByText(/Installed\. Re-run setup only if something is broken\./)).toBeTruthy()
|
||||
expect(screen.queryByText(/needs a one-time install/)).toBeNull()
|
||||
// …but a repair affordance stays available (c9's resting state renders
|
||||
// the low-key Re-run setup button instead of the primary CTA).
|
||||
expect(screen.getByRole('button', { name: /Re-run setup/ })).toBeTruthy()
|
||||
})
|
||||
|
||||
describe('readiness pills', () => {
|
||||
it('renders the server status instead of assuming keyless rows are Ready', async () => {
|
||||
// The false-Ready bug: a logged-out Nous Subscription row and a
|
||||
|
|
@ -740,4 +789,143 @@ describe('ToolsetConfigPanel', () => {
|
|||
expect(startOAuthLogin).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('API key deep link', () => {
|
||||
it('offers "Manage in API Keys" on a set key and navigates to Settings → Keys', async () => {
|
||||
getToolsetConfig.mockResolvedValue(
|
||||
config({
|
||||
active_provider: 'ElevenLabs',
|
||||
providers: [
|
||||
{
|
||||
name: 'ElevenLabs',
|
||||
badge: 'paid',
|
||||
tag: 'Most natural voices',
|
||||
env_vars: [
|
||||
{
|
||||
key: 'ELEVENLABS_API_KEY',
|
||||
prompt: 'ElevenLabs API key',
|
||||
url: 'https://x',
|
||||
default: null,
|
||||
is_set: true
|
||||
}
|
||||
],
|
||||
post_setup: null,
|
||||
requires_nous_auth: false,
|
||||
is_active: true,
|
||||
status: 'ready'
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
|
||||
const { ToolsetConfigPanel } = await import('./toolset-config-panel')
|
||||
render(<ToolsetConfigPanel onConfiguredChange={vi.fn()} toolset="tts" />)
|
||||
|
||||
const trigger = await screen.findByRole('button', { name: /Actions for ELEVENLABS_API_KEY/ })
|
||||
fireEvent.pointerDown(trigger, { button: 0, ctrlKey: false, pointerType: 'mouse' })
|
||||
fireEvent.click(await screen.findByRole('menuitem', { name: 'Manage in API Keys' }))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(navigateSpy).toHaveBeenCalledWith('/settings?tab=keys&key=ELEVENLABS_API_KEY')
|
||||
)
|
||||
})
|
||||
|
||||
it('hides "Manage in API Keys" while the key is unset', async () => {
|
||||
// Default config(): ElevenLabs key is not set. An unset key is managed
|
||||
// right here via Set — no point bouncing the user to another page.
|
||||
const { ToolsetConfigPanel } = await import('./toolset-config-panel')
|
||||
render(<ToolsetConfigPanel onConfiguredChange={vi.fn()} toolset="tts" />)
|
||||
|
||||
// Expand the keyed provider so its env row renders.
|
||||
fireEvent.click(await screen.findByRole('button', { name: /ElevenLabs/ }))
|
||||
const trigger = await screen.findByRole('button', { name: /Actions for ELEVENLABS_API_KEY/ })
|
||||
fireEvent.pointerDown(trigger, { button: 0, ctrlKey: false, pointerType: 'mouse' })
|
||||
|
||||
await screen.findByRole('menuitem', { name: 'Set' })
|
||||
expect(screen.queryByRole('menuitem', { name: 'Manage in API Keys' })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('web capability split', () => {
|
||||
function webConfig(overrides: Partial<ToolsetConfig> = {}): ToolsetConfig {
|
||||
return {
|
||||
name: 'web',
|
||||
has_category: true,
|
||||
active_provider: 'SearXNG',
|
||||
active_search_backend: 'searxng',
|
||||
active_extract_backend: 'firecrawl',
|
||||
providers: [
|
||||
{
|
||||
name: 'SearXNG',
|
||||
badge: 'free · self-hosted',
|
||||
tag: 'Free metasearch',
|
||||
env_vars: [],
|
||||
post_setup: null,
|
||||
requires_nous_auth: false,
|
||||
is_active: true,
|
||||
status: 'ready',
|
||||
web_backend: 'searxng',
|
||||
capabilities: ['search']
|
||||
},
|
||||
{
|
||||
name: 'Firecrawl',
|
||||
badge: 'paid',
|
||||
tag: 'Full search + extract',
|
||||
env_vars: [],
|
||||
post_setup: null,
|
||||
requires_nous_auth: false,
|
||||
is_active: false,
|
||||
status: 'ready',
|
||||
web_backend: 'firecrawl',
|
||||
capabilities: ['search', 'extract']
|
||||
}
|
||||
],
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
it('shows the resolved per-capability backends as badges', async () => {
|
||||
getToolsetConfig.mockResolvedValue(webConfig())
|
||||
|
||||
const { ToolsetConfigPanel } = await import('./toolset-config-panel')
|
||||
render(<ToolsetConfigPanel onConfiguredChange={vi.fn()} toolset="web" />)
|
||||
|
||||
expect(await screen.findByText('Search: searxng')).toBeTruthy()
|
||||
expect(screen.getByText('Extract: firecrawl')).toBeTruthy()
|
||||
// The row backing each capability gets an assignment pill.
|
||||
expect(screen.getByText('Search backend')).toBeTruthy()
|
||||
expect(screen.getByText('Extract backend')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('hides "Use for Extract" on a search-only provider and wires capability selection', async () => {
|
||||
getToolsetConfig.mockResolvedValue(webConfig())
|
||||
selectToolsetProvider.mockResolvedValue({ ok: true, name: 'web', provider: 'SearXNG', capability: 'search' })
|
||||
|
||||
const { ToolsetConfigPanel } = await import('./toolset-config-panel')
|
||||
render(<ToolsetConfigPanel onConfiguredChange={vi.fn()} toolset="web" />)
|
||||
|
||||
// Active/expanded provider is search-only SearXNG.
|
||||
await screen.findByText('Search: searxng')
|
||||
expect(await screen.findByRole('button', { name: 'Use for Search' })).toBeTruthy()
|
||||
expect(screen.queryByRole('button', { name: 'Use for Extract' })).toBeNull()
|
||||
|
||||
// Expand Firecrawl (search + extract) and assign it as the search backend.
|
||||
fireEvent.click(screen.getByRole('button', { name: /Firecrawl/ }))
|
||||
const useForSearch = await screen.findByRole('button', { name: 'Use for Search' })
|
||||
fireEvent.click(useForSearch)
|
||||
|
||||
await waitFor(() => expect(selectToolsetProvider).toHaveBeenCalledWith('web', 'Firecrawl', 'search'))
|
||||
// Badge tracks the local write without a refetch.
|
||||
await waitFor(() => expect(screen.getByText('Search: firecrawl')).toBeTruthy())
|
||||
})
|
||||
|
||||
it('does not render capability chrome for non-web toolsets', async () => {
|
||||
const { ToolsetConfigPanel } = await import('./toolset-config-panel')
|
||||
render(<ToolsetConfigPanel onConfiguredChange={vi.fn()} toolset="tts" />)
|
||||
|
||||
await screen.findByText('Microsoft Edge TTS')
|
||||
expect(screen.queryByText(/^Search: /)).toBeNull()
|
||||
expect(screen.queryByRole('button', { name: 'Use for Search' })).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
import { SETTINGS_ROUTE } from '@/app/routes'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
|
|
@ -83,11 +85,16 @@ interface EnvVarFieldProps {
|
|||
function EnvVarField({ envVar, isSet, onSaved, onCleared }: EnvVarFieldProps) {
|
||||
const { t } = useI18n()
|
||||
const copy = t.settings.toolsets
|
||||
const navigate = useNavigate()
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [value, setValue] = useState('')
|
||||
const [revealed, setRevealed] = useState<string | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
// Internal route change to Settings → API Keys (tools sub-view) with the
|
||||
// deep-link param keys-settings consumes to scroll + flash this key's card.
|
||||
const openInKeys = () => navigate(`${SETTINGS_ROUTE}?tab=keys&key=${encodeURIComponent(envVar.key)}`)
|
||||
|
||||
async function handleSave() {
|
||||
if (!value) {
|
||||
return
|
||||
|
|
@ -166,6 +173,7 @@ function EnvVarField({ envVar, isSet, onSaved, onCleared }: EnvVarFieldProps) {
|
|||
label={envVar.key}
|
||||
onClear={() => void handleClear()}
|
||||
onEdit={() => setEditing(true)}
|
||||
onManageKeys={openInKeys}
|
||||
onReveal={() => void handleReveal()}
|
||||
>
|
||||
<EnvVarActionsTrigger label={envVar.key} onClick={event => event.stopPropagation()} />
|
||||
|
|
@ -642,6 +650,36 @@ export function ToolsetConfigPanel({ toolset, onConfiguredChange }: ToolsetConfi
|
|||
onConfiguredChange?.()
|
||||
}
|
||||
|
||||
async function handleSelectCapability(provider: ToolProvider, capability: 'search' | 'extract') {
|
||||
setSelecting(provider.name)
|
||||
|
||||
try {
|
||||
await selectToolsetProvider(toolset, provider.name, capability)
|
||||
// Mirror the backend write locally so the Search:/Extract: badges track
|
||||
// the new per-capability backend without a refetch.
|
||||
setCfg(current =>
|
||||
current
|
||||
? {
|
||||
...current,
|
||||
...(capability === 'search'
|
||||
? { active_search_backend: provider.web_backend ?? provider.name }
|
||||
: { active_extract_backend: provider.web_backend ?? provider.name })
|
||||
}
|
||||
: current
|
||||
)
|
||||
notify({
|
||||
kind: 'success',
|
||||
title: copy.selectedTitle,
|
||||
message: copy.webCapabilitySelectedMessage(provider.name, capability)
|
||||
})
|
||||
onConfiguredChange?.()
|
||||
} catch (err) {
|
||||
notifyError(err, copy.failedSelectCapability(provider.name))
|
||||
} finally {
|
||||
setSelecting(null)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
// Inline row, not a full block loader — a big centered spinner is what
|
||||
// caused the Skills/Tools tab-switch layout jump; this reads as "more
|
||||
|
|
@ -667,9 +705,21 @@ export function ToolsetConfigPanel({ toolset, onConfiguredChange }: ToolsetConfi
|
|||
|
||||
return (
|
||||
<div className="grid gap-2">
|
||||
{toolset === 'web' && cfg.active_search_backend !== undefined && (
|
||||
// The runtime dispatches web_search and web_extract independently
|
||||
// (web.search_backend / web.extract_backend) — show which backend
|
||||
// each capability resolves to right now.
|
||||
<div className="flex flex-wrap items-center gap-2 px-1">
|
||||
<Pill>{copy.webSearchActive(cfg.active_search_backend || copy.webCapabilityUnset)}</Pill>
|
||||
<Pill>{copy.webExtractActive(cfg.active_extract_backend || copy.webCapabilityUnset)}</Pill>
|
||||
</div>
|
||||
)}
|
||||
{providers.map(provider => {
|
||||
const isActive = activeProvider === provider.name
|
||||
const status = providerStatus(provider, envState)
|
||||
const webCaps = toolset === 'web' ? (provider.capabilities ?? []) : []
|
||||
const isSearchBackend = Boolean(provider.web_backend && cfg.active_search_backend === provider.web_backend)
|
||||
const isExtractBackend = Boolean(provider.web_backend && cfg.active_extract_backend === provider.web_backend)
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-xl bg-background/60" key={provider.name}>
|
||||
|
|
@ -693,6 +743,8 @@ export function ToolsetConfigPanel({ toolset, onConfiguredChange }: ToolsetConfi
|
|||
)}
|
||||
{status === 'needs_auth' && <Pill tone="warn">{copy.needsSignIn}</Pill>}
|
||||
{status === 'needs_setup' && <Pill tone="warn">{copy.needsSetup}</Pill>}
|
||||
{isSearchBackend && <Pill tone="primary">{copy.webUsedForSearch}</Pill>}
|
||||
{isExtractBackend && <Pill tone="primary">{copy.webUsedForExtract}</Pill>}
|
||||
</span>
|
||||
{selecting === provider.name && <Loader2 className="size-3.5 shrink-0 animate-spin" />}
|
||||
</button>
|
||||
|
|
@ -700,6 +752,34 @@ export function ToolsetConfigPanel({ toolset, onConfiguredChange }: ToolsetConfi
|
|||
{isActive && (
|
||||
<div className="grid gap-2 bg-muted/20 p-3">
|
||||
{provider.tag && <p className="text-[0.72rem] text-muted-foreground">{provider.tag}</p>}
|
||||
{webCaps.length > 0 && (
|
||||
// Per-capability assignment: writes web.search_backend /
|
||||
// web.extract_backend without touching the shared
|
||||
// web.backend key. Hidden for capabilities the backend
|
||||
// can't serve (e.g. ddgs is search-only).
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{webCaps.includes('search') && (
|
||||
<Button
|
||||
disabled={selecting !== null || isSearchBackend}
|
||||
onClick={() => void handleSelectCapability(provider, 'search')}
|
||||
size="xs"
|
||||
variant="text"
|
||||
>
|
||||
{copy.webUseForSearch}
|
||||
</Button>
|
||||
)}
|
||||
{webCaps.includes('extract') && (
|
||||
<Button
|
||||
disabled={selecting !== null || isExtractBackend}
|
||||
onClick={() => void handleSelectCapability(provider, 'extract')}
|
||||
size="xs"
|
||||
variant="text"
|
||||
>
|
||||
{copy.webUseForExtract}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{provider.requires_nous_auth && (
|
||||
<p className="text-[0.72rem] text-muted-foreground">{copy.nousIncluded}</p>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
import { QueryClientProvider } from '@tanstack/react-query'
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import type * as ReactRouterDom from 'react-router-dom'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type * as HermesApi from '@/hermes'
|
||||
|
|
@ -35,6 +36,15 @@ vi.mock('@/store/notifications', () => ({
|
|||
notifyError: vi.fn()
|
||||
}))
|
||||
|
||||
// The vision detail navigates to Settings → Models via useNavigate; spy on it
|
||||
// so the deep-link target is assertable.
|
||||
const navigateSpy = vi.fn()
|
||||
|
||||
vi.mock('react-router-dom', async importOriginal => ({
|
||||
...(await importOriginal<typeof ReactRouterDom>()),
|
||||
useNavigate: () => navigateSpy
|
||||
}))
|
||||
|
||||
function toolset(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
name: 'web',
|
||||
|
|
@ -115,4 +125,32 @@ describe('SkillsView toolset management', () => {
|
|||
await screen.findByRole('switch', { name: 'Toggle Web Search toolset' })
|
||||
await waitFor(() => expect(getToolsetConfig).toHaveBeenCalledWith('web'))
|
||||
})
|
||||
|
||||
it('shows a vision explainer that deep-links to Settings → Models', async () => {
|
||||
// Vision has no TOOL_CATEGORIES provider matrix — its model lives in the
|
||||
// auxiliary model config, so the detail pane must point there instead of
|
||||
// rendering an empty panel.
|
||||
getToolsets.mockResolvedValue([
|
||||
toolset({
|
||||
name: 'vision',
|
||||
label: 'Vision / Image Analysis',
|
||||
description: 'vision_analyze',
|
||||
tools: ['vision_analyze']
|
||||
})
|
||||
])
|
||||
getToolsetConfig.mockResolvedValue({ has_category: false, active_provider: null, providers: [] })
|
||||
|
||||
await renderSkills()
|
||||
|
||||
expect(await screen.findByText(/auxiliary model configuration/)).toBeTruthy()
|
||||
const link = screen.getByRole('button', { name: /Choose vision model in Settings/ })
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(link)
|
||||
})
|
||||
|
||||
// Internal route change into the Models section with the aux slot target —
|
||||
// consumed by ModelSettings' deep-link highlight. Never an external URL.
|
||||
await waitFor(() => expect(navigateSpy).toHaveBeenCalledWith('/settings?tab=config:model&aux=vision'))
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { useStore } from '@nanostores/react'
|
|||
import { useQuery } from '@tanstack/react-query'
|
||||
import type * as React from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
import { ArchiveSkillConfirmDialog } from '@/app/learning/archive-skill-confirm-dialog'
|
||||
import { CodeEditor } from '@/components/chat/code-editor'
|
||||
|
|
@ -46,6 +47,7 @@ import {
|
|||
} from '../master-detail'
|
||||
import { PanelEmpty, PanelPill } from '../overlays/panel'
|
||||
import { PageSearchShell } from '../page-search-shell'
|
||||
import { SETTINGS_ROUTE } from '../routes'
|
||||
import { ComputerUsePanel } from '../settings/computer-use-panel'
|
||||
import { asText, includesQuery, prettyName, toolNames, toolsetDisplayLabel } from '../settings/helpers'
|
||||
import { TerminalBackendPanel } from '../settings/terminal-backend-panel'
|
||||
|
|
@ -760,6 +762,7 @@ function ToolsetDetail({
|
|||
onConfiguredChange: () => void
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const navigate = useNavigate()
|
||||
const tools = toolNames(toolset)
|
||||
const label = toolsetDisplayLabel(toolset)
|
||||
|
||||
|
|
@ -783,6 +786,26 @@ function ToolsetDetail({
|
|||
))}
|
||||
</div>
|
||||
)}
|
||||
{toolset.name === 'vision' && (
|
||||
// Vision has no provider matrix — model resolution runs through the
|
||||
// auxiliary model config. Point at the actual home (Settings → Models,
|
||||
// aux "vision" row) via an internal deep link instead of leaving the
|
||||
// detail pane empty.
|
||||
<div className="grid gap-1.5">
|
||||
<p className="text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
|
||||
{t.skills.visionModelHint}
|
||||
</p>
|
||||
<div>
|
||||
<Button
|
||||
onClick={() => navigate(`${SETTINGS_ROUTE}?tab=config:model&aux=vision`)}
|
||||
size="xs"
|
||||
variant="textStrong"
|
||||
>
|
||||
{t.skills.visionModelLink}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{toolset.name === 'computer_use' && <ComputerUsePanel onConfiguredChange={onConfiguredChange} />}
|
||||
{toolset.name === 'terminal' && <TerminalBackendPanel onConfiguredChange={onConfiguredChange} />}
|
||||
<ToolsetConfigPanel key={toolset.name} onConfiguredChange={onConfiguredChange} toolset={toolset.name} />
|
||||
|
|
|
|||
|
|
@ -869,6 +869,8 @@ export interface SelectToolsetProviderResponse {
|
|||
ok: boolean
|
||||
name: string
|
||||
provider: string
|
||||
/** Present when the selection was scoped to one web capability. */
|
||||
capability?: string
|
||||
/** Present (true) when a managed Nous row was selected but the Portal
|
||||
* entitlement is missing — the row won't activate until the user signs
|
||||
* in to Nous Portal. */
|
||||
|
|
@ -877,12 +879,16 @@ export interface SelectToolsetProviderResponse {
|
|||
feature?: string
|
||||
}
|
||||
|
||||
export function selectToolsetProvider(name: string, provider: string): Promise<SelectToolsetProviderResponse> {
|
||||
export function selectToolsetProvider(
|
||||
name: string,
|
||||
provider: string,
|
||||
capability?: 'search' | 'extract'
|
||||
): Promise<SelectToolsetProviderResponse> {
|
||||
return window.hermesDesktop.api<SelectToolsetProviderResponse>({
|
||||
...profileScoped(),
|
||||
path: `/api/tools/toolsets/${encodeURIComponent(name)}/provider`,
|
||||
method: 'PUT',
|
||||
body: { provider }
|
||||
body: capability ? { provider, capability } : { provider }
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -537,6 +537,7 @@ export const en: Translations = {
|
|||
envActions: {
|
||||
actionsFor: label => `Actions for ${label}`,
|
||||
credentialActions: 'Credential actions',
|
||||
manageInKeys: 'Manage in API Keys',
|
||||
docs: 'Docs',
|
||||
hideValue: 'Hide value',
|
||||
revealValue: 'Reveal value',
|
||||
|
|
@ -832,7 +833,7 @@ export const en: Translations = {
|
|||
noApiKeyRequired: 'No API key required.',
|
||||
postSetupHint: step =>
|
||||
`This backend needs a one-time install (${step}). Runs on this machine — may take a few minutes.`,
|
||||
postSetupInstalledHint: 'This backend is installed on this machine.',
|
||||
postSetupInstalledHint: 'Installed. Re-run setup only if something is broken.',
|
||||
postSetupRun: 'Run setup',
|
||||
postSetupRerun: 'Re-run setup',
|
||||
postSetupInstalled: 'Installed',
|
||||
|
|
@ -843,6 +844,15 @@ export const en: Translations = {
|
|||
postSetupErrorTitle: 'Setup finished with errors',
|
||||
postSetupErrorMessage: step => `Check the ${step} log.`,
|
||||
postSetupFailed: step => `Failed to run ${step} setup`,
|
||||
webSearchActive: backend => `Search: ${backend}`,
|
||||
webExtractActive: backend => `Extract: ${backend}`,
|
||||
webCapabilityUnset: 'not set',
|
||||
webUseForSearch: 'Use for Search',
|
||||
webUseForExtract: 'Use for Extract',
|
||||
webUsedForSearch: 'Search backend',
|
||||
webUsedForExtract: 'Extract backend',
|
||||
webCapabilitySelectedMessage: (provider, capability) => `${provider} now handles web ${capability}.`,
|
||||
failedSelectCapability: provider => `Failed to set ${provider}`,
|
||||
loadingModels: 'Loading model catalog...',
|
||||
modelSectionTitle: 'Model',
|
||||
modelCount: count => `${count} model${count === 1 ? '' : 's'}`,
|
||||
|
|
@ -886,6 +896,9 @@ export const en: Translations = {
|
|||
noDescription: 'No description.',
|
||||
configured: 'Configured',
|
||||
needsKeys: 'Needs keys',
|
||||
visionModelHint:
|
||||
'Vision uses your auxiliary model configuration — the image-capable model is picked there, not per-provider here.',
|
||||
visionModelLink: 'Choose vision model in Settings → Models',
|
||||
toolsetsEnabled: (enabled, total) => `${enabled}/${total} toolsets enabled`,
|
||||
configureToolset: label => `Configure ${label}`,
|
||||
toggleToolset: label => `Toggle ${label} toolset`,
|
||||
|
|
|
|||
|
|
@ -635,6 +635,7 @@ export const ja = defineLocale({
|
|||
envActions: {
|
||||
actionsFor: label => `${label} のアクション`,
|
||||
credentialActions: '認証情報のアクション',
|
||||
manageInKeys: 'API キーで管理',
|
||||
docs: 'ドキュメント',
|
||||
hideValue: '値を非表示',
|
||||
revealValue: '値を表示',
|
||||
|
|
@ -872,7 +873,7 @@ export const ja = defineLocale({
|
|||
noApiKeyRequired: 'API キーは不要です。',
|
||||
postSetupHint: step =>
|
||||
`このバックエンドは一度だけインストールが必要です (${step})。このマシン上で実行され、数分かかる場合があります。`,
|
||||
postSetupInstalledHint: 'このバックエンドはこのマシンにインストール済みです。',
|
||||
postSetupInstalledHint: 'インストール済みです。問題がある場合のみセットアップを再実行してください。',
|
||||
postSetupRun: 'セットアップを実行',
|
||||
postSetupRerun: 'セットアップを再実行',
|
||||
postSetupInstalled: 'インストール済み',
|
||||
|
|
@ -883,6 +884,16 @@ export const ja = defineLocale({
|
|||
postSetupErrorTitle: 'セットアップはエラーで終了しました',
|
||||
postSetupErrorMessage: step => `${step} のログを確認してください。`,
|
||||
postSetupFailed: step => `${step} のセットアップの実行に失敗しました`,
|
||||
webSearchActive: backend => `検索: ${backend}`,
|
||||
webExtractActive: backend => `抽出: ${backend}`,
|
||||
webCapabilityUnset: '未設定',
|
||||
webUseForSearch: '検索に使用',
|
||||
webUseForExtract: '抽出に使用',
|
||||
webUsedForSearch: '検索バックエンド',
|
||||
webUsedForExtract: '抽出バックエンド',
|
||||
webCapabilitySelectedMessage: (provider, capability) =>
|
||||
`${provider} がウェブ${capability === 'search' ? '検索' : '抽出'}を担当します。`,
|
||||
failedSelectCapability: provider => `${provider} の設定に失敗しました`,
|
||||
terminalBackend: {
|
||||
sectionTitle: '実行バックエンド',
|
||||
loading: '実行バックエンドを確認中…',
|
||||
|
|
@ -916,6 +927,9 @@ export const ja = defineLocale({
|
|||
noDescription: '説明はありません。',
|
||||
configured: '設定済み',
|
||||
needsKeys: 'キーが必要',
|
||||
visionModelHint:
|
||||
'ビジョンは補助モデル設定を使用します。画像対応モデルはそこで選択され、ここでプロバイダーごとに選ぶものではありません。',
|
||||
visionModelLink: '設定 → モデル でビジョンモデルを選択',
|
||||
toolsetsEnabled: (enabled, total) => `${enabled}/${total} ツールセットが有効`,
|
||||
configureToolset: label => `${label} を設定`,
|
||||
toggleToolset: label => `${label} ツールセットを切り替え`,
|
||||
|
|
|
|||
|
|
@ -448,6 +448,7 @@ export interface Translations {
|
|||
envActions: {
|
||||
actionsFor: (label: string) => string
|
||||
credentialActions: string
|
||||
manageInKeys: string
|
||||
docs: string
|
||||
hideValue: string
|
||||
revealValue: string
|
||||
|
|
@ -731,6 +732,15 @@ export interface Translations {
|
|||
postSetupErrorTitle: string
|
||||
postSetupErrorMessage: (step: string) => string
|
||||
postSetupFailed: (step: string) => string
|
||||
webSearchActive: (backend: string) => string
|
||||
webExtractActive: (backend: string) => string
|
||||
webCapabilityUnset: string
|
||||
webUseForSearch: string
|
||||
webUseForExtract: string
|
||||
webUsedForSearch: string
|
||||
webUsedForExtract: string
|
||||
webCapabilitySelectedMessage: (provider: string, capability: string) => string
|
||||
failedSelectCapability: (provider: string) => string
|
||||
loadingModels: string
|
||||
modelSectionTitle: string
|
||||
modelCount: (count: number) => string
|
||||
|
|
@ -774,6 +784,8 @@ export interface Translations {
|
|||
noDescription: string
|
||||
configured: string
|
||||
needsKeys: string
|
||||
visionModelHint: string
|
||||
visionModelLink: string
|
||||
toolsetsEnabled: (enabled: number, total: number) => string
|
||||
configureToolset: (label: string) => string
|
||||
toggleToolset: (label: string) => string
|
||||
|
|
|
|||
|
|
@ -623,6 +623,7 @@ export const zhHant = defineLocale({
|
|||
envActions: {
|
||||
actionsFor: label => `${label} 的動作`,
|
||||
credentialActions: '憑證動作',
|
||||
manageInKeys: '在 API 金鑰中管理',
|
||||
docs: '文件',
|
||||
hideValue: '隱藏值',
|
||||
revealValue: '顯示值',
|
||||
|
|
@ -843,7 +844,7 @@ export const zhHant = defineLocale({
|
|||
nousAuthFailed: 'Nous Portal 登入未完成',
|
||||
noApiKeyRequired: '不需要 API 金鑰。',
|
||||
postSetupHint: step => `此後端需要一次性安裝 (${step})。將在此機器上執行,可能需要幾分鐘。`,
|
||||
postSetupInstalledHint: '此後端已在此機器上安裝。',
|
||||
postSetupInstalledHint: '已安裝。僅在出現問題時才需要重新執行安裝。',
|
||||
postSetupRun: '執行設定',
|
||||
postSetupRerun: '重新執行設定',
|
||||
postSetupInstalled: '已安裝',
|
||||
|
|
@ -854,6 +855,16 @@ export const zhHant = defineLocale({
|
|||
postSetupErrorTitle: '設定完成但有錯誤',
|
||||
postSetupErrorMessage: step => `請檢查 ${step} 日誌。`,
|
||||
postSetupFailed: step => `執行 ${step} 設定失敗`,
|
||||
webSearchActive: backend => `搜尋:${backend}`,
|
||||
webExtractActive: backend => `擷取:${backend}`,
|
||||
webCapabilityUnset: '未設定',
|
||||
webUseForSearch: '用於搜尋',
|
||||
webUseForExtract: '用於擷取',
|
||||
webUsedForSearch: '搜尋後端',
|
||||
webUsedForExtract: '擷取後端',
|
||||
webCapabilitySelectedMessage: (provider, capability) =>
|
||||
`${provider} 現在負責網頁${capability === 'search' ? '搜尋' : '擷取'}。`,
|
||||
failedSelectCapability: provider => `無法設定 ${provider}`,
|
||||
terminalBackend: {
|
||||
sectionTitle: '執行後端',
|
||||
loading: '正在檢查執行後端…',
|
||||
|
|
@ -887,6 +898,8 @@ export const zhHant = defineLocale({
|
|||
noDescription: '無可用描述。',
|
||||
configured: '已設定',
|
||||
needsKeys: '需要金鑰',
|
||||
visionModelHint: '視覺功能使用你的輔助模型設定——支援影像的模型在那裡選擇,而不是在此處按供應商選擇。',
|
||||
visionModelLink: '在 設定 → 模型 中選擇視覺模型',
|
||||
toolsetsEnabled: (enabled, total) => `已啟用 ${enabled}/${total} 個工具集`,
|
||||
configureToolset: label => `設定 ${label}`,
|
||||
toggleToolset: label => `切換 ${label} 工具集`,
|
||||
|
|
|
|||
|
|
@ -734,6 +734,7 @@ export const zh: Translations = {
|
|||
envActions: {
|
||||
actionsFor: label => `${label} 的操作`,
|
||||
credentialActions: '凭据操作',
|
||||
manageInKeys: '在 API 密钥中管理',
|
||||
docs: '文档',
|
||||
hideValue: '隐藏值',
|
||||
revealValue: '显示值',
|
||||
|
|
@ -1021,7 +1022,7 @@ export const zh: Translations = {
|
|||
nousAuthFailed: 'Nous Portal 登录未完成',
|
||||
noApiKeyRequired: '不需要 API 密钥。',
|
||||
postSetupHint: step => `此后端需要一次性安装 (${step})。将在此机器上执行,可能需要几分钟。`,
|
||||
postSetupInstalledHint: '此后端已在此机器上安装。',
|
||||
postSetupInstalledHint: '已安装。仅在出现问题时才需要重新运行安装。',
|
||||
postSetupRun: '运行设置',
|
||||
postSetupRerun: '重新运行设置',
|
||||
postSetupInstalled: '已安装',
|
||||
|
|
@ -1032,6 +1033,16 @@ export const zh: Translations = {
|
|||
postSetupErrorTitle: '设置完成但有错误',
|
||||
postSetupErrorMessage: step => `请检查 ${step} 日志。`,
|
||||
postSetupFailed: step => `运行 ${step} 设置失败`,
|
||||
webSearchActive: backend => `搜索:${backend}`,
|
||||
webExtractActive: backend => `提取:${backend}`,
|
||||
webCapabilityUnset: '未设置',
|
||||
webUseForSearch: '用于搜索',
|
||||
webUseForExtract: '用于提取',
|
||||
webUsedForSearch: '搜索后端',
|
||||
webUsedForExtract: '提取后端',
|
||||
webCapabilitySelectedMessage: (provider, capability) =>
|
||||
`${provider} 现在负责网页${capability === 'search' ? '搜索' : '提取'}。`,
|
||||
failedSelectCapability: provider => `无法设置 ${provider}`,
|
||||
loadingModels: '正在加载模型目录…',
|
||||
modelSectionTitle: '模型',
|
||||
modelCount: count => `${count} 个模型`,
|
||||
|
|
@ -1075,6 +1086,8 @@ export const zh: Translations = {
|
|||
noDescription: '暂无描述。',
|
||||
configured: '已配置',
|
||||
needsKeys: '需要密钥',
|
||||
visionModelHint: '视觉功能使用你的辅助模型配置——支持图像的模型在那里选择,而不是在此处按提供商选择。',
|
||||
visionModelLink: '在 设置 → 模型 中选择视觉模型',
|
||||
toolsetsEnabled: (enabled, total) => `已启用 ${enabled}/${total} 个工具集`,
|
||||
configureToolset: label => `配置 ${label}`,
|
||||
toggleToolset: label => `切换 ${label} 工具集`,
|
||||
|
|
|
|||
|
|
@ -714,14 +714,29 @@ export interface ToolProvider {
|
|||
/** Honest readiness computed server-side (keys ∧ Nous entitlement ∧
|
||||
* post-setup install state). Optional for older backends. */
|
||||
status?: ToolProviderStatus
|
||||
/** Web toolset only: the backend key written to web.*backend config
|
||||
* (e.g. 'searxng'). Absent on other toolsets and older backends. */
|
||||
web_backend?: string
|
||||
/** Web toolset only: capabilities this backend can serve. Search-only
|
||||
* providers (ddgs, brave-free) report ['search']. */
|
||||
capabilities?: WebCapability[]
|
||||
}
|
||||
|
||||
/** A web toolset capability — the runtime dispatches web_search and
|
||||
* web_extract to independently configurable backends. */
|
||||
export type WebCapability = 'search' | 'extract'
|
||||
|
||||
export interface ToolsetConfig {
|
||||
name: string
|
||||
has_category: boolean
|
||||
providers: ToolProvider[]
|
||||
/** Name of the currently active provider, or null if none is configured. */
|
||||
active_provider: string | null
|
||||
/** Web toolset only: backend the web_search tool resolves to right now
|
||||
* (web.search_backend → web.backend → credential auto-detect). */
|
||||
active_search_backend?: string | null
|
||||
/** Web toolset only: backend the web_extract tool resolves to right now. */
|
||||
active_extract_backend?: string | null
|
||||
}
|
||||
|
||||
/** Health status of a terminal execution backend row.
|
||||
|
|
|
|||
|
|
@ -2417,6 +2417,33 @@ def _plugin_web_search_providers() -> list[dict]:
|
|||
return rows
|
||||
|
||||
|
||||
def web_provider_capabilities(backend: str) -> list:
|
||||
"""Return the capabilities (``search`` / ``extract``) a web backend supports.
|
||||
|
||||
Consults the plugin registry's provider instance (``supports_search`` /
|
||||
``supports_extract``) so the Capabilities GUI can offer per-capability
|
||||
selection (``web.search_backend`` / ``web.extract_backend``) only where it
|
||||
makes sense — e.g. ddgs and brave-free are search-only. Falls back to both
|
||||
capabilities when the backend isn't registered (hardcoded setup-flow rows
|
||||
like the managed Firecrawl entries resolve before plugin discovery in some
|
||||
test contexts, and firecrawl itself supports both).
|
||||
"""
|
||||
try:
|
||||
from agent.web_search_registry import get_provider
|
||||
|
||||
provider = get_provider(backend)
|
||||
if provider is not None:
|
||||
caps = []
|
||||
if provider.supports_search():
|
||||
caps.append("search")
|
||||
if provider.supports_extract():
|
||||
caps.append("extract")
|
||||
return caps
|
||||
except Exception:
|
||||
pass
|
||||
return ["search", "extract"]
|
||||
|
||||
|
||||
# Mirror of _plugin_web_search_providers for cloud browser backends. After
|
||||
# PR #25214, Browserbase / Browser Use / Firecrawl live as plugins under
|
||||
# plugins/browser/<vendor>/; this helper is the sole source of provider rows
|
||||
|
|
|
|||
|
|
@ -14670,6 +14670,7 @@ async def get_toolset_config(name: str, profile: Optional[str] = None):
|
|||
_is_provider_active,
|
||||
_visible_providers,
|
||||
provider_readiness_status,
|
||||
web_provider_capabilities,
|
||||
)
|
||||
from hermes_cli.config import get_env_value
|
||||
from hermes_cli.nous_subscription import get_nous_subscription_features
|
||||
|
|
@ -14683,6 +14684,8 @@ async def get_toolset_config(name: str, profile: Optional[str] = None):
|
|||
cat = TOOL_CATEGORIES.get(name)
|
||||
providers = []
|
||||
active_provider = None
|
||||
active_search_backend = None
|
||||
active_extract_backend = None
|
||||
if cat:
|
||||
# Fetch portal/entitlement state once for the whole matrix — the
|
||||
# per-provider readiness computation below reuses it instead of
|
||||
|
|
@ -14706,7 +14709,7 @@ async def get_toolset_config(name: str, profile: Optional[str] = None):
|
|||
is_active = _is_provider_active(prov, config, force_fresh=True)
|
||||
if is_active and active_provider is None:
|
||||
active_provider = prov["name"]
|
||||
providers.append({
|
||||
row = {
|
||||
"name": prov["name"],
|
||||
"badge": prov.get("badge", ""),
|
||||
"tag": prov.get("tag", ""),
|
||||
|
|
@ -14721,17 +14724,45 @@ async def get_toolset_config(name: str, profile: Optional[str] = None):
|
|||
"status": provider_readiness_status(
|
||||
prov, config, features=features, is_active=is_active
|
||||
),
|
||||
})
|
||||
return {
|
||||
}
|
||||
if name == "web" and prov.get("web_backend"):
|
||||
# The runtime split web into two capabilities long ago
|
||||
# (web.search_backend / web.extract_backend); surface each
|
||||
# row's backend key and which capabilities it can serve so
|
||||
# the GUI can offer per-capability selection.
|
||||
row["web_backend"] = prov["web_backend"]
|
||||
row["capabilities"] = web_provider_capabilities(prov["web_backend"])
|
||||
providers.append(row)
|
||||
if name == "web":
|
||||
# Resolve the per-capability active backends exactly the way the
|
||||
# web_search / web_extract dispatchers do (per-capability key →
|
||||
# shared web.backend → credential auto-detect), so the GUI badges
|
||||
# reflect what a tool call would actually hit right now.
|
||||
try:
|
||||
from tools.web_tools import _get_extract_backend, _get_search_backend
|
||||
|
||||
active_search_backend = _get_search_backend()
|
||||
active_extract_backend = _get_extract_backend()
|
||||
except Exception:
|
||||
active_search_backend = None
|
||||
active_extract_backend = None
|
||||
payload = {
|
||||
"name": name,
|
||||
"has_category": cat is not None,
|
||||
"providers": providers,
|
||||
"active_provider": active_provider,
|
||||
}
|
||||
if name == "web":
|
||||
payload["active_search_backend"] = active_search_backend
|
||||
payload["active_extract_backend"] = active_extract_backend
|
||||
return payload
|
||||
|
||||
|
||||
class ToolsetProviderSelect(BaseModel):
|
||||
provider: str
|
||||
# Web-only capability scope: 'search' | 'extract'. Omitted → whole-provider
|
||||
# selection through the legacy apply_provider_selection path (web.backend).
|
||||
capability: Optional[str] = None
|
||||
profile: Optional[str] = None
|
||||
|
||||
|
||||
|
|
@ -14917,6 +14948,15 @@ async def select_toolset_provider(
|
|||
API keys and post-setup flows are handled by separate endpoints. Returns
|
||||
400 for unknown toolset or provider names.
|
||||
|
||||
For the ``web`` toolset only, an optional ``capability`` ('search' |
|
||||
'extract') scopes the selection to ``web.search_backend`` /
|
||||
``web.extract_backend`` — the same per-capability overrides the runtime
|
||||
dispatchers (``tools.web_tools._get_search_backend`` /
|
||||
``_get_extract_backend``) resolve first. The provider must actually
|
||||
support the requested capability (a search-only backend can't be the
|
||||
extract backend). Omitting ``capability`` keeps the legacy whole-provider
|
||||
behavior (writes ``web.backend``).
|
||||
|
||||
Managed Nous rows (``managed_nous_feature``) additionally report the
|
||||
Portal entitlement state: the CLI flow gates these selections on
|
||||
``ensure_nous_portal_access`` (inline login), but the GUI has no inline
|
||||
|
|
@ -14930,6 +14970,7 @@ async def select_toolset_provider(
|
|||
from hermes_cli.tools_config import (
|
||||
TOOL_CATEGORIES,
|
||||
apply_provider_selection,
|
||||
web_provider_capabilities,
|
||||
_get_effective_configurable_toolsets,
|
||||
_visible_providers,
|
||||
)
|
||||
|
|
@ -14942,15 +14983,58 @@ async def select_toolset_provider(
|
|||
if name not in valid:
|
||||
raise HTTPException(status_code=400, detail=f"Unknown toolset: {name}")
|
||||
|
||||
if body.capability is not None:
|
||||
if name != "web":
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="capability selection is only supported for the web toolset",
|
||||
)
|
||||
if body.capability not in ("search", "extract"):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Unknown capability: {body.capability!r} (expected 'search' or 'extract')",
|
||||
)
|
||||
|
||||
with _profile_scope(body.profile or profile):
|
||||
config = load_config()
|
||||
try:
|
||||
apply_provider_selection(name, body.provider, config)
|
||||
except KeyError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc).strip('"'))
|
||||
if body.capability is not None:
|
||||
# Per-capability path: resolve the picker row to its backend key
|
||||
# and write web.<capability>_backend. Does NOT touch web.backend,
|
||||
# so the other capability keeps resolving through the shared
|
||||
# fallback chain.
|
||||
cat = TOOL_CATEGORIES.get(name)
|
||||
providers = _visible_providers(cat, config, force_fresh=True) if cat else []
|
||||
prov = next((p for p in providers if p.get("name") == body.provider), None)
|
||||
if prov is None:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Unknown provider {body.provider!r} for toolset {name!r}",
|
||||
)
|
||||
backend = prov.get("web_backend")
|
||||
if not backend:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Provider {body.provider!r} has no web backend key",
|
||||
)
|
||||
if body.capability not in web_provider_capabilities(backend):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"{body.provider} does not support {body.capability}",
|
||||
)
|
||||
web_cfg = config.setdefault("web", {})
|
||||
if not isinstance(web_cfg, dict):
|
||||
web_cfg = {}
|
||||
config["web"] = web_cfg
|
||||
web_cfg[f"{body.capability}_backend"] = backend
|
||||
else:
|
||||
try:
|
||||
apply_provider_selection(name, body.provider, config)
|
||||
except KeyError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc).strip('"'))
|
||||
save_config(config)
|
||||
|
||||
response: Dict[str, Any] = {"ok": True, "name": name, "provider": body.provider}
|
||||
if body.capability is not None:
|
||||
response["capability"] = body.capability
|
||||
|
||||
# Entitlement check for managed Nous rows — mirrors the gate the CLI
|
||||
# applies via ensure_nous_portal_access at selection time.
|
||||
|
|
|
|||
|
|
@ -5635,6 +5635,118 @@ class TestNewEndpoints:
|
|||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
# -- Web capability split (search vs extract backends) ------------------
|
||||
|
||||
def test_web_config_reports_per_capability_backends(self):
|
||||
"""GET web/config carries the resolved search/extract backends.
|
||||
|
||||
The runtime resolves web_search and web_extract independently
|
||||
(web.search_backend / web.extract_backend → web.backend → auto-detect);
|
||||
the config payload must surface both so the GUI can show which backend
|
||||
each capability actually hits.
|
||||
"""
|
||||
resp = self.client.get("/api/tools/toolsets/web/config")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "active_search_backend" in data
|
||||
assert "active_extract_backend" in data
|
||||
# Provider rows carry their backend key + supported capabilities so
|
||||
# the GUI can hide "Use for Extract" on search-only rows.
|
||||
rows_with_backend = [p for p in data["providers"] if p.get("web_backend")]
|
||||
assert rows_with_backend, "expected at least one provider with a web backend key"
|
||||
for prov in rows_with_backend:
|
||||
assert isinstance(prov["capabilities"], list)
|
||||
assert set(prov["capabilities"]) <= {"search", "extract"}
|
||||
assert prov["capabilities"], "a web provider must support at least one capability"
|
||||
|
||||
def test_web_capability_fields_only_on_web_toolset(self):
|
||||
resp = self.client.get("/api/tools/toolsets/tts/config")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "active_search_backend" not in data
|
||||
assert "active_extract_backend" not in data
|
||||
|
||||
def test_select_web_search_backend_matches_runtime_resolution(self, monkeypatch):
|
||||
"""PUT provider with capability=search writes web.search_backend and the
|
||||
runtime search dispatcher resolves to it — while extract is untouched."""
|
||||
# Make SearXNG available so both the endpoint gate and the runtime
|
||||
# availability check agree it's usable.
|
||||
monkeypatch.setenv("SEARXNG_URL", "http://localhost:8888")
|
||||
# Give extract an explicit shared backend so the assertion isn't
|
||||
# hostage to whatever creds exist on the machine running the tests.
|
||||
monkeypatch.setenv("FIRECRAWL_API_URL", "http://localhost:3002")
|
||||
base = self.client.put(
|
||||
"/api/tools/toolsets/web/provider",
|
||||
json={"provider": "Firecrawl Self-Hosted"},
|
||||
)
|
||||
assert base.status_code == 200
|
||||
|
||||
resp = self.client.put(
|
||||
"/api/tools/toolsets/web/provider",
|
||||
json={"provider": "SearXNG", "capability": "search"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["ok"] is True
|
||||
assert body["capability"] == "search"
|
||||
|
||||
from hermes_cli.config import load_config
|
||||
cfg = load_config()
|
||||
assert cfg["web"]["search_backend"] == "searxng"
|
||||
# The shared backend selected first must be preserved for extract.
|
||||
assert cfg["web"]["backend"] == "firecrawl"
|
||||
|
||||
# The REAL runtime resolution — not a parallel reimplementation.
|
||||
from tools.web_tools import _get_extract_backend, _get_search_backend
|
||||
assert _get_search_backend() == "searxng"
|
||||
assert _get_extract_backend() == "firecrawl"
|
||||
|
||||
# And the config endpoint reports the same split.
|
||||
data = self.client.get("/api/tools/toolsets/web/config").json()
|
||||
assert data["active_search_backend"] == "searxng"
|
||||
assert data["active_extract_backend"] == "firecrawl"
|
||||
|
||||
def test_select_web_extract_backend_writes_extract_key(self, monkeypatch):
|
||||
monkeypatch.setenv("FIRECRAWL_API_URL", "http://localhost:3002")
|
||||
resp = self.client.put(
|
||||
"/api/tools/toolsets/web/provider",
|
||||
json={"provider": "Firecrawl Self-Hosted", "capability": "extract"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
from hermes_cli.config import load_config
|
||||
cfg = load_config()
|
||||
assert cfg["web"]["extract_backend"] == "firecrawl"
|
||||
# Whole-provider/search keys untouched by a capability-scoped write
|
||||
# (the default config seeds them as empty strings).
|
||||
assert not cfg["web"].get("search_backend")
|
||||
|
||||
from tools.web_tools import _get_extract_backend
|
||||
assert _get_extract_backend() == "firecrawl"
|
||||
|
||||
def test_select_web_capability_rejects_unsupported_capability(self):
|
||||
"""A search-only provider (ddgs) can't be set as the extract backend."""
|
||||
resp = self.client.put(
|
||||
"/api/tools/toolsets/web/provider",
|
||||
json={"provider": "DuckDuckGo (ddgs)", "capability": "extract"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "does not support extract" in resp.json()["detail"]
|
||||
|
||||
def test_select_web_capability_rejects_bad_values(self):
|
||||
resp = self.client.put(
|
||||
"/api/tools/toolsets/web/provider",
|
||||
json={"provider": "Firecrawl Self-Hosted", "capability": "browse"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
# capability is a web-only concept.
|
||||
resp = self.client.put(
|
||||
"/api/tools/toolsets/tts/provider",
|
||||
json={"provider": "Microsoft Edge TTS", "capability": "search"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
# -- Terminal execution backend picker ---------------------------------
|
||||
|
||||
def test_get_terminal_backends_shape_and_local_ready(self, monkeypatch):
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue