mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-09-17 15:27:15 +00:00
feat(models): show LangBot Models availability
This commit is contained in:
@@ -220,6 +220,21 @@ class SystemRouterGroup(group.RouterGroup):
|
|||||||
return self.http_status(503, -1, str(exc))
|
return self.http_status(503, -1, str(exc))
|
||||||
return self.success(data=model)
|
return self.success(data=model)
|
||||||
|
|
||||||
|
@self.route(
|
||||||
|
'/model-availability',
|
||||||
|
methods=['GET'],
|
||||||
|
auth_type=group.AuthType.USER_TOKEN,
|
||||||
|
permission=Permission.RESOURCE_VIEW,
|
||||||
|
)
|
||||||
|
async def _(request_context: RequestContext) -> str:
|
||||||
|
"""Expose Space's latest persisted model probes to the WebUI."""
|
||||||
|
try:
|
||||||
|
models = await self.ap.space_service.get_model_selection()
|
||||||
|
except Exception as exc:
|
||||||
|
self.ap.logger.warning(f'Failed to load LangBot Models availability: {exc}')
|
||||||
|
return self.http_status(503, -1, 'Model availability is unavailable')
|
||||||
|
return self.success(data={'models': [model.model_dump(mode='json') for model in models]})
|
||||||
|
|
||||||
@self.route(
|
@self.route(
|
||||||
'/tasks',
|
'/tasks',
|
||||||
methods=['GET'],
|
methods=['GET'],
|
||||||
|
|||||||
@@ -242,13 +242,14 @@ class SpaceService:
|
|||||||
models_data = data.get('data', {}).get('models', [])
|
models_data = data.get('data', {}).get('models', [])
|
||||||
return [SpaceModel.model_validate(model_dict) for model_dict in models_data]
|
return [SpaceModel.model_validate(model_dict) for model_dict in models_data]
|
||||||
|
|
||||||
async def get_model_selection(self, category: str) -> typing.List[SpaceModelSelection]:
|
async def get_model_selection(self, category: str | None = None) -> typing.List[SpaceModelSelection]:
|
||||||
"""Return Space models in the availability-ranked selection order."""
|
"""Return Space models in the availability-ranked selection order."""
|
||||||
space_url = self._get_space_config()['url']
|
space_url = self._get_space_config()['url']
|
||||||
session = httpclient.get_session()
|
session = httpclient.get_session()
|
||||||
|
params = {'category': category} if category else None
|
||||||
async with session.get(
|
async with session.get(
|
||||||
f'{space_url}/api/v1/models/selection',
|
f'{space_url}/api/v1/models/selection',
|
||||||
params={'category': category},
|
params=params,
|
||||||
) as response:
|
) as response:
|
||||||
if response.status != 200:
|
if response.status != 200:
|
||||||
error = await httpclient.read_text_limited(response)
|
error = await httpclient.read_text_limited(response)
|
||||||
@@ -266,7 +267,17 @@ class SpaceService:
|
|||||||
models = []
|
models = []
|
||||||
for selection in data:
|
for selection in data:
|
||||||
if isinstance(selection, dict) and isinstance(selection.get('model'), dict):
|
if isinstance(selection, dict) and isinstance(selection.get('model'), dict):
|
||||||
models.append(selection['model'])
|
model = dict(selection['model'])
|
||||||
|
availability = selection.get('availability')
|
||||||
|
if not isinstance(availability, dict):
|
||||||
|
# Accept the short-lived pre-release response shape.
|
||||||
|
availability = {
|
||||||
|
key: selection[key]
|
||||||
|
for key in ('up', 'last_probed_at', 'latency_ms', 'http_code')
|
||||||
|
if key in selection
|
||||||
|
}
|
||||||
|
model['availability'] = availability
|
||||||
|
models.append(model)
|
||||||
else:
|
else:
|
||||||
models.append(selection)
|
models.append(selection)
|
||||||
return [SpaceModelSelection.model_validate(model) for model in models]
|
return [SpaceModelSelection.model_validate(model) for model in models]
|
||||||
|
|||||||
@@ -49,8 +49,19 @@ class SpaceModel(pydantic.BaseModel):
|
|||||||
updated_at: str | None = None
|
updated_at: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class SpaceModelAvailability(pydantic.BaseModel):
|
||||||
|
"""Latest availability probe. ``up`` is None when no probe exists."""
|
||||||
|
|
||||||
|
up: bool | None = None
|
||||||
|
last_probed_at: str | None = None
|
||||||
|
latency_ms: int = 0
|
||||||
|
http_code: int = 0
|
||||||
|
|
||||||
|
|
||||||
class SpaceModelSelection(pydantic.BaseModel):
|
class SpaceModelSelection(pydantic.BaseModel):
|
||||||
"""Minimal model identity returned by the ranked selection endpoint."""
|
"""Model identity and the latest persisted probe from Space."""
|
||||||
|
|
||||||
uuid: str
|
uuid: str
|
||||||
model_id: str
|
model_id: str
|
||||||
|
category: str | None = None
|
||||||
|
availability: SpaceModelAvailability = pydantic.Field(default_factory=SpaceModelAvailability)
|
||||||
|
|||||||
@@ -847,7 +847,15 @@ class TestSpaceServiceGetModelSelection:
|
|||||||
data = {'models': models}
|
data = {'models': models}
|
||||||
elif response_shape == 'availability-wrapper':
|
elif response_shape == 'availability-wrapper':
|
||||||
data = [
|
data = [
|
||||||
{'model': model, 'latency_ms': index + 10, 'http_code': 200}
|
{
|
||||||
|
'model': model,
|
||||||
|
'availability': {
|
||||||
|
'up': True,
|
||||||
|
'last_probed_at': '2026-09-11T12:01:18Z',
|
||||||
|
'latency_ms': index + 10,
|
||||||
|
'http_code': 200,
|
||||||
|
},
|
||||||
|
}
|
||||||
for index, model in enumerate(models)
|
for index, model in enumerate(models)
|
||||||
]
|
]
|
||||||
else:
|
else:
|
||||||
@@ -870,11 +878,56 @@ class TestSpaceServiceGetModelSelection:
|
|||||||
result = await service.get_model_selection('chat')
|
result = await service.get_model_selection('chat')
|
||||||
|
|
||||||
assert [model.uuid for model in result] == ['best-model', 'fallback-model']
|
assert [model.uuid for model in result] == ['best-model', 'fallback-model']
|
||||||
|
if response_shape == 'availability-wrapper':
|
||||||
|
assert result[0].availability.up is True
|
||||||
|
assert result[0].availability.last_probed_at == '2026-09-11T12:01:18Z'
|
||||||
|
assert result[0].availability.latency_ms == 10
|
||||||
session.get.assert_called_once_with(
|
session.get.assert_called_once_with(
|
||||||
'https://space.langbot.app/api/v1/models/selection',
|
'https://space.langbot.app/api/v1/models/selection',
|
||||||
params={'category': 'chat'},
|
params={'category': 'chat'},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def test_selection_without_category_fetches_all_model_statuses(self):
|
||||||
|
ap = SimpleNamespace(instance_config=SimpleNamespace(data={}))
|
||||||
|
service = SpaceService(ap)
|
||||||
|
payload = {
|
||||||
|
'code': 0,
|
||||||
|
'data': {
|
||||||
|
'models': [
|
||||||
|
{
|
||||||
|
'model': {
|
||||||
|
'uuid': 'embedding-model',
|
||||||
|
'model_id': 'text-embedding',
|
||||||
|
'category': 'embedding',
|
||||||
|
},
|
||||||
|
'availability': {'up': None, 'last_probed_at': None},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
}
|
||||||
|
mock_response = MagicMock(status=200)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch('langbot.pkg.api.http.service.space.httpclient.get_session') as get_session,
|
||||||
|
patch(
|
||||||
|
'langbot.pkg.api.http.service.space.httpclient.read_json_limited',
|
||||||
|
new=AsyncMock(return_value=payload),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
session = MagicMock()
|
||||||
|
session.get.return_value.__aenter__ = AsyncMock(return_value=mock_response)
|
||||||
|
session.get.return_value.__aexit__ = AsyncMock(return_value=None)
|
||||||
|
get_session.return_value = session
|
||||||
|
|
||||||
|
result = await service.get_model_selection()
|
||||||
|
|
||||||
|
assert result[0].category == 'embedding'
|
||||||
|
assert result[0].availability.up is None
|
||||||
|
session.get.assert_called_once_with(
|
||||||
|
'https://space.langbot.app/api/v1/models/selection',
|
||||||
|
params=None,
|
||||||
|
)
|
||||||
|
|
||||||
async def test_recommended_model_uses_first_selection_and_refreshes_once(self):
|
async def test_recommended_model_uses_first_selection_and_refreshes_once(self):
|
||||||
local_model = SimpleNamespace(uuid='local-model-uuid', name='best-chat-model')
|
local_model = SimpleNamespace(uuid='local-model-uuid', name='best-chat-model')
|
||||||
persistence = SimpleNamespace(
|
persistence = SimpleNamespace(
|
||||||
|
|||||||
@@ -71,6 +71,8 @@ import { LANGBOT_MODELS_PROVIDER_REQUESTER } from '@/app/home/components/models-
|
|||||||
import ReasoningLevelPicker, {
|
import ReasoningLevelPicker, {
|
||||||
REASONING_LEVELS,
|
REASONING_LEVELS,
|
||||||
} from '@/app/home/components/reasoning/ReasoningLevelPicker';
|
} from '@/app/home/components/reasoning/ReasoningLevelPicker';
|
||||||
|
import ModelAvailabilityIndicator from '@/app/home/components/model-availability/ModelAvailabilityIndicator';
|
||||||
|
import { useLangBotModelAvailability } from '@/app/home/components/model-availability/useLangBotModelAvailability';
|
||||||
|
|
||||||
function hasUsableUuid<T extends { uuid?: string | null }>(
|
function hasUsableUuid<T extends { uuid?: string | null }>(
|
||||||
item: T,
|
item: T,
|
||||||
@@ -153,6 +155,33 @@ export default function DynamicFormItemComponent({
|
|||||||
const [modelsDialogOpen, setModelsDialogOpen] = useState(false);
|
const [modelsDialogOpen, setModelsDialogOpen] = useState(false);
|
||||||
const [settingsSection, setSettingsSection] =
|
const [settingsSection, setSettingsSection] =
|
||||||
useState<SettingsSection>('models');
|
useState<SettingsSection>('models');
|
||||||
|
const isModelSelector = [
|
||||||
|
DynamicFormItemType.LLM_MODEL_SELECTOR,
|
||||||
|
DynamicFormItemType.EMBEDDING_MODEL_SELECTOR,
|
||||||
|
DynamicFormItemType.RERANK_MODEL_SELECTOR,
|
||||||
|
DynamicFormItemType.MODEL_FALLBACK_SELECTOR,
|
||||||
|
].includes(config.type);
|
||||||
|
const {
|
||||||
|
availability: langbotModelAvailability,
|
||||||
|
loaded: langbotModelAvailabilityLoaded,
|
||||||
|
} = useLangBotModelAvailability(
|
||||||
|
isModelSelector && !systemInfo.disable_models_service,
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderModelAvailability = (model: {
|
||||||
|
uuid: string;
|
||||||
|
name: string;
|
||||||
|
provider?: { requester?: string };
|
||||||
|
}) =>
|
||||||
|
model.provider?.requester === LANGBOT_MODELS_PROVIDER_REQUESTER ? (
|
||||||
|
<ModelAvailabilityIndicator
|
||||||
|
availability={
|
||||||
|
langbotModelAvailability[model.uuid] ??
|
||||||
|
langbotModelAvailability[model.name]
|
||||||
|
}
|
||||||
|
show={langbotModelAvailabilityLoaded}
|
||||||
|
/>
|
||||||
|
) : null;
|
||||||
|
|
||||||
const fetchLlmModels = () => {
|
const fetchLlmModels = () => {
|
||||||
httpClient
|
httpClient
|
||||||
@@ -584,6 +613,7 @@ export default function DynamicFormItemComponent({
|
|||||||
<SelectItem key={model.uuid} value={model.uuid}>
|
<SelectItem key={model.uuid} value={model.uuid}>
|
||||||
<span className="inline-flex items-center gap-1">
|
<span className="inline-flex items-center gap-1">
|
||||||
{model.name}
|
{model.name}
|
||||||
|
{renderModelAvailability(model)}
|
||||||
{model.abilities?.includes('vision') && (
|
{model.abilities?.includes('vision') && (
|
||||||
<Eye className="h-3 w-3 text-muted-foreground" />
|
<Eye className="h-3 w-3 text-muted-foreground" />
|
||||||
)}
|
)}
|
||||||
@@ -688,6 +718,7 @@ export default function DynamicFormItemComponent({
|
|||||||
<SelectItem key={model.uuid} value={model.uuid}>
|
<SelectItem key={model.uuid} value={model.uuid}>
|
||||||
<span className="inline-flex items-center gap-1">
|
<span className="inline-flex items-center gap-1">
|
||||||
{model.name}
|
{model.name}
|
||||||
|
{renderModelAvailability(model)}
|
||||||
{model.abilities?.includes('vision') && (
|
{model.abilities?.includes('vision') && (
|
||||||
<Eye className="h-3 w-3 text-muted-foreground" />
|
<Eye className="h-3 w-3 text-muted-foreground" />
|
||||||
)}
|
)}
|
||||||
@@ -784,6 +815,7 @@ export default function DynamicFormItemComponent({
|
|||||||
{models.map((model) => (
|
{models.map((model) => (
|
||||||
<SelectItem key={model.uuid} value={model.uuid}>
|
<SelectItem key={model.uuid} value={model.uuid}>
|
||||||
{model.name}
|
{model.name}
|
||||||
|
{renderModelAvailability(model)}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectGroup>
|
</SelectGroup>
|
||||||
@@ -876,6 +908,7 @@ export default function DynamicFormItemComponent({
|
|||||||
{models.map((model) => (
|
{models.map((model) => (
|
||||||
<SelectItem key={model.uuid} value={model.uuid}>
|
<SelectItem key={model.uuid} value={model.uuid}>
|
||||||
{model.name}
|
{model.name}
|
||||||
|
{renderModelAvailability(model)}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectGroup>
|
</SelectGroup>
|
||||||
@@ -941,6 +974,7 @@ export default function DynamicFormItemComponent({
|
|||||||
{models.map((model) => (
|
{models.map((model) => (
|
||||||
<SelectItem key={model.uuid} value={model.uuid}>
|
<SelectItem key={model.uuid} value={model.uuid}>
|
||||||
{model.name}
|
{model.name}
|
||||||
|
{renderModelAvailability(model)}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectGroup>
|
</SelectGroup>
|
||||||
@@ -1060,6 +1094,7 @@ export default function DynamicFormItemComponent({
|
|||||||
<SelectItem key={model.uuid} value={model.uuid}>
|
<SelectItem key={model.uuid} value={model.uuid}>
|
||||||
<span className="inline-flex items-center gap-1">
|
<span className="inline-flex items-center gap-1">
|
||||||
{model.name}
|
{model.name}
|
||||||
|
{renderModelAvailability(model)}
|
||||||
{model.abilities?.includes('vision') && (
|
{model.abilities?.includes('vision') && (
|
||||||
<Eye className="h-3 w-3 text-muted-foreground" />
|
<Eye className="h-3 w-3 text-muted-foreground" />
|
||||||
)}
|
)}
|
||||||
@@ -1165,6 +1200,7 @@ export default function DynamicFormItemComponent({
|
|||||||
<SelectItem key={model.uuid} value={model.uuid}>
|
<SelectItem key={model.uuid} value={model.uuid}>
|
||||||
<span className="inline-flex items-center gap-1">
|
<span className="inline-flex items-center gap-1">
|
||||||
{model.name}
|
{model.name}
|
||||||
|
{renderModelAvailability(model)}
|
||||||
{model.abilities?.includes('vision') && (
|
{model.abilities?.includes('vision') && (
|
||||||
<Eye className="h-3 w-3 text-muted-foreground" />
|
<Eye className="h-3 w-3 text-muted-foreground" />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import type { LangBotModelAvailability } from '@/app/infra/entities/api';
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from '@/components/ui/tooltip';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
interface ModelAvailabilityIndicatorProps {
|
||||||
|
availability?: LangBotModelAvailability;
|
||||||
|
show: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ModelAvailabilityIndicator({
|
||||||
|
availability,
|
||||||
|
show,
|
||||||
|
}: ModelAvailabilityIndicatorProps) {
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
|
if (!show) return null;
|
||||||
|
|
||||||
|
const state = availability?.up;
|
||||||
|
const label =
|
||||||
|
state === true
|
||||||
|
? t('models.availability.available')
|
||||||
|
: state === false
|
||||||
|
? t('models.availability.unavailable')
|
||||||
|
: t('models.availability.notChecked');
|
||||||
|
const dotClass =
|
||||||
|
state === true
|
||||||
|
? 'bg-emerald-500'
|
||||||
|
: state === false
|
||||||
|
? 'bg-destructive'
|
||||||
|
: 'bg-muted-foreground/50';
|
||||||
|
const checkedAt = availability?.last_probed_at
|
||||||
|
? new Intl.DateTimeFormat(i18n.language, {
|
||||||
|
dateStyle: 'medium',
|
||||||
|
timeStyle: 'short',
|
||||||
|
}).format(new Date(availability.last_probed_at))
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<span
|
||||||
|
className="inline-flex size-4 shrink-0 items-center justify-center"
|
||||||
|
aria-label={label}
|
||||||
|
onMouseDown={(event) => event.preventDefault()}
|
||||||
|
>
|
||||||
|
<span className={`size-1.5 rounded-full ${dotClass}`} />
|
||||||
|
</span>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top" className="max-w-64">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<p>{label}</p>
|
||||||
|
{checkedAt && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t('models.availability.lastChecked', { time: checkedAt })}
|
||||||
|
{availability && availability.latency_ms > 0
|
||||||
|
? ` · ${availability.latency_ms} ms`
|
||||||
|
: ''}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import type { LangBotModelAvailability } from '@/app/infra/entities/api';
|
||||||
|
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||||
|
|
||||||
|
const CACHE_TTL_MS = 60_000;
|
||||||
|
|
||||||
|
type AvailabilityMap = Record<string, LangBotModelAvailability>;
|
||||||
|
|
||||||
|
let cachedAvailability: AvailabilityMap | null = null;
|
||||||
|
let cacheExpiresAt = 0;
|
||||||
|
let pendingRequest: Promise<AvailabilityMap> | null = null;
|
||||||
|
|
||||||
|
async function loadAvailability(): Promise<AvailabilityMap> {
|
||||||
|
if (cachedAvailability && Date.now() < cacheExpiresAt) {
|
||||||
|
return cachedAvailability;
|
||||||
|
}
|
||||||
|
if (pendingRequest) return pendingRequest;
|
||||||
|
|
||||||
|
pendingRequest = httpClient
|
||||||
|
.getLangBotModelAvailability()
|
||||||
|
.then((response) => {
|
||||||
|
const next: AvailabilityMap = {};
|
||||||
|
for (const item of response.models) {
|
||||||
|
next[item.uuid] = item.availability;
|
||||||
|
next[item.model_id] = item.availability;
|
||||||
|
}
|
||||||
|
cachedAvailability = next;
|
||||||
|
cacheExpiresAt = Date.now() + CACHE_TTL_MS;
|
||||||
|
return next;
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
pendingRequest = null;
|
||||||
|
});
|
||||||
|
return pendingRequest;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLangBotModelAvailability(enabled = true) {
|
||||||
|
const [availability, setAvailability] = useState<AvailabilityMap>(
|
||||||
|
cachedAvailability ?? {},
|
||||||
|
);
|
||||||
|
const [loaded, setLoaded] = useState(
|
||||||
|
cachedAvailability !== null && Date.now() < cacheExpiresAt,
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled) return;
|
||||||
|
let active = true;
|
||||||
|
loadAvailability()
|
||||||
|
.then((result) => {
|
||||||
|
if (!active) return;
|
||||||
|
setAvailability(result);
|
||||||
|
setLoaded(true);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// Availability is supplementary; model configuration remains usable.
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
};
|
||||||
|
}, [enabled]);
|
||||||
|
|
||||||
|
return { availability, loaded };
|
||||||
|
}
|
||||||
@@ -27,6 +27,7 @@ import { CustomApiError } from '@/app/infra/entities/common';
|
|||||||
import { PanelBody } from '../settings-dialog/panel-layout';
|
import { PanelBody } from '../settings-dialog/panel-layout';
|
||||||
import { useCurrentWorkspace } from '@/app/infra/http';
|
import { useCurrentWorkspace } from '@/app/infra/http';
|
||||||
import type { WorkspaceSpaceBilling } from '@/app/infra/entities/workspace';
|
import type { WorkspaceSpaceBilling } from '@/app/infra/entities/workspace';
|
||||||
|
import { useLangBotModelAvailability } from '../model-availability/useLangBotModelAvailability';
|
||||||
|
|
||||||
interface ModelsPanelProps {
|
interface ModelsPanelProps {
|
||||||
// True when this panel is the active section and the dialog is open.
|
// True when this panel is the active section and the dialog is open.
|
||||||
@@ -89,6 +90,10 @@ export default function ModelsPanel({
|
|||||||
const currentWorkspace = useCurrentWorkspace();
|
const currentWorkspace = useCurrentWorkspace();
|
||||||
const canManage =
|
const canManage =
|
||||||
currentWorkspace?.permissions.includes('provider_secret.manage') ?? false;
|
currentWorkspace?.permissions.includes('provider_secret.manage') ?? false;
|
||||||
|
const {
|
||||||
|
availability: langbotModelAvailability,
|
||||||
|
loaded: langbotModelAvailabilityLoaded,
|
||||||
|
} = useLangBotModelAvailability(active && !systemInfo.disable_models_service);
|
||||||
|
|
||||||
const [providers, setProviders] = useState<ModelProvider[]>([]);
|
const [providers, setProviders] = useState<ModelProvider[]>([]);
|
||||||
const [spaceBilling, setSpaceBilling] =
|
const [spaceBilling, setSpaceBilling] =
|
||||||
@@ -554,6 +559,8 @@ export default function ModelsPanel({
|
|||||||
isWorkspaceOwner={currentWorkspace?.membership.role === 'owner'}
|
isWorkspaceOwner={currentWorkspace?.membership.role === 'owner'}
|
||||||
ownerSpaceBound={spaceBilling?.owner_space_bound ?? false}
|
ownerSpaceBound={spaceBilling?.owner_space_bound ?? false}
|
||||||
spaceCredits={spaceBilling?.credits ?? null}
|
spaceCredits={spaceBilling?.credits ?? null}
|
||||||
|
modelAvailability={langbotModelAvailability}
|
||||||
|
modelAvailabilityLoaded={langbotModelAvailabilityLoaded}
|
||||||
addModelPopoverOpen={addModelPopoverOpen}
|
addModelPopoverOpen={addModelPopoverOpen}
|
||||||
editModelPopoverOpen={editModelPopoverOpen}
|
editModelPopoverOpen={editModelPopoverOpen}
|
||||||
deleteConfirmOpen={deleteConfirmOpen}
|
deleteConfirmOpen={deleteConfirmOpen}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import {
|
import {
|
||||||
LLMModel,
|
LLMModel,
|
||||||
EmbeddingModel,
|
EmbeddingModel,
|
||||||
|
LangBotModelAvailability,
|
||||||
ReasoningConfig,
|
ReasoningConfig,
|
||||||
} from '@/app/infra/entities/api';
|
} from '@/app/infra/entities/api';
|
||||||
import {
|
import {
|
||||||
@@ -24,12 +25,15 @@ import {
|
|||||||
} from '../types';
|
} from '../types';
|
||||||
import ExtraArgsEditor from './ExtraArgsEditor';
|
import ExtraArgsEditor from './ExtraArgsEditor';
|
||||||
import { userInfo } from '@/app/infra/http';
|
import { userInfo } from '@/app/infra/http';
|
||||||
|
import ModelAvailabilityIndicator from '../../model-availability/ModelAvailabilityIndicator';
|
||||||
|
|
||||||
interface ModelItemProps {
|
interface ModelItemProps {
|
||||||
model: LLMModel | EmbeddingModel;
|
model: LLMModel | EmbeddingModel;
|
||||||
canManage: boolean;
|
canManage: boolean;
|
||||||
modelType: ModelType;
|
modelType: ModelType;
|
||||||
isLangBotModels: boolean;
|
isLangBotModels: boolean;
|
||||||
|
availability?: LangBotModelAvailability;
|
||||||
|
availabilityLoaded: boolean;
|
||||||
editModelPopoverOpen: string | null;
|
editModelPopoverOpen: string | null;
|
||||||
deleteConfirmOpen: string | null;
|
deleteConfirmOpen: string | null;
|
||||||
onOpenEditModel: (modelId: string) => void;
|
onOpenEditModel: (modelId: string) => void;
|
||||||
@@ -86,6 +90,8 @@ export default function ModelItem({
|
|||||||
canManage,
|
canManage,
|
||||||
modelType,
|
modelType,
|
||||||
isLangBotModels,
|
isLangBotModels,
|
||||||
|
availability,
|
||||||
|
availabilityLoaded,
|
||||||
editModelPopoverOpen,
|
editModelPopoverOpen,
|
||||||
deleteConfirmOpen,
|
deleteConfirmOpen,
|
||||||
onOpenEditModel,
|
onOpenEditModel,
|
||||||
@@ -199,6 +205,12 @@ export default function ModelItem({
|
|||||||
>
|
>
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<span className="text-sm font-medium">{model.name}</span>
|
<span className="text-sm font-medium">{model.name}</span>
|
||||||
|
{isLangBotModels && (
|
||||||
|
<ModelAvailabilityIndicator
|
||||||
|
availability={availability}
|
||||||
|
show={availabilityLoaded}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<Badge variant="secondary" className="text-xs">
|
<Badge variant="secondary" className="text-xs">
|
||||||
{modelType === 'llm'
|
{modelType === 'llm'
|
||||||
? t('models.chat')
|
? t('models.chat')
|
||||||
|
|||||||
@@ -9,7 +9,11 @@ import {
|
|||||||
Radar,
|
Radar,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { httpClient, systemInfo } from '@/app/infra/http/HttpClient';
|
import { httpClient, systemInfo } from '@/app/infra/http/HttpClient';
|
||||||
import { ModelProvider, ReasoningConfig } from '@/app/infra/entities/api';
|
import {
|
||||||
|
LangBotModelAvailability,
|
||||||
|
ModelProvider,
|
||||||
|
ReasoningConfig,
|
||||||
|
} from '@/app/infra/entities/api';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
Collapsible,
|
Collapsible,
|
||||||
@@ -47,6 +51,8 @@ interface ProviderCardProps {
|
|||||||
isWorkspaceOwner: boolean;
|
isWorkspaceOwner: boolean;
|
||||||
ownerSpaceBound: boolean;
|
ownerSpaceBound: boolean;
|
||||||
spaceCredits: number | null;
|
spaceCredits: number | null;
|
||||||
|
modelAvailability: Record<string, LangBotModelAvailability>;
|
||||||
|
modelAvailabilityLoaded: boolean;
|
||||||
// Popover states
|
// Popover states
|
||||||
addModelPopoverOpen: string | null;
|
addModelPopoverOpen: string | null;
|
||||||
editModelPopoverOpen: string | null;
|
editModelPopoverOpen: string | null;
|
||||||
@@ -115,6 +121,8 @@ export default function ProviderCard({
|
|||||||
isWorkspaceOwner,
|
isWorkspaceOwner,
|
||||||
ownerSpaceBound,
|
ownerSpaceBound,
|
||||||
spaceCredits,
|
spaceCredits,
|
||||||
|
modelAvailability,
|
||||||
|
modelAvailabilityLoaded,
|
||||||
addModelPopoverOpen,
|
addModelPopoverOpen,
|
||||||
editModelPopoverOpen,
|
editModelPopoverOpen,
|
||||||
deleteConfirmOpen,
|
deleteConfirmOpen,
|
||||||
@@ -424,6 +432,11 @@ export default function ProviderCard({
|
|||||||
canManage={canManage}
|
canManage={canManage}
|
||||||
modelType="llm"
|
modelType="llm"
|
||||||
isLangBotModels={isLangBotModels}
|
isLangBotModels={isLangBotModels}
|
||||||
|
availability={
|
||||||
|
modelAvailability[model.uuid] ??
|
||||||
|
modelAvailability[model.name]
|
||||||
|
}
|
||||||
|
availabilityLoaded={modelAvailabilityLoaded}
|
||||||
editModelPopoverOpen={editModelPopoverOpen}
|
editModelPopoverOpen={editModelPopoverOpen}
|
||||||
deleteConfirmOpen={deleteConfirmOpen}
|
deleteConfirmOpen={deleteConfirmOpen}
|
||||||
onOpenEditModel={onOpenEditModel}
|
onOpenEditModel={onOpenEditModel}
|
||||||
@@ -475,6 +488,11 @@ export default function ProviderCard({
|
|||||||
canManage={canManage}
|
canManage={canManage}
|
||||||
modelType="embedding"
|
modelType="embedding"
|
||||||
isLangBotModels={isLangBotModels}
|
isLangBotModels={isLangBotModels}
|
||||||
|
availability={
|
||||||
|
modelAvailability[model.uuid] ??
|
||||||
|
modelAvailability[model.name]
|
||||||
|
}
|
||||||
|
availabilityLoaded={modelAvailabilityLoaded}
|
||||||
editModelPopoverOpen={editModelPopoverOpen}
|
editModelPopoverOpen={editModelPopoverOpen}
|
||||||
deleteConfirmOpen={deleteConfirmOpen}
|
deleteConfirmOpen={deleteConfirmOpen}
|
||||||
onOpenEditModel={onOpenEditModel}
|
onOpenEditModel={onOpenEditModel}
|
||||||
@@ -524,6 +542,11 @@ export default function ProviderCard({
|
|||||||
canManage={canManage}
|
canManage={canManage}
|
||||||
modelType="rerank"
|
modelType="rerank"
|
||||||
isLangBotModels={isLangBotModels}
|
isLangBotModels={isLangBotModels}
|
||||||
|
availability={
|
||||||
|
modelAvailability[model.uuid] ??
|
||||||
|
modelAvailability[model.name]
|
||||||
|
}
|
||||||
|
availabilityLoaded={modelAvailabilityLoaded}
|
||||||
editModelPopoverOpen={editModelPopoverOpen}
|
editModelPopoverOpen={editModelPopoverOpen}
|
||||||
deleteConfirmOpen={deleteConfirmOpen}
|
deleteConfirmOpen={deleteConfirmOpen}
|
||||||
onOpenEditModel={onOpenEditModel}
|
onOpenEditModel={onOpenEditModel}
|
||||||
|
|||||||
@@ -158,6 +158,24 @@ export interface RerankModel {
|
|||||||
extra_args?: object;
|
extra_args?: object;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface LangBotModelAvailability {
|
||||||
|
up: boolean | null;
|
||||||
|
last_probed_at: string | null;
|
||||||
|
latency_ms: number;
|
||||||
|
http_code: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LangBotModelAvailabilityItem {
|
||||||
|
uuid: string;
|
||||||
|
model_id: string;
|
||||||
|
category: string | null;
|
||||||
|
availability: LangBotModelAvailability;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiRespLangBotModelAvailability {
|
||||||
|
models: LangBotModelAvailabilityItem[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface ApiRespPipelines {
|
export interface ApiRespPipelines {
|
||||||
pipelines: Pipeline[];
|
pipelines: Pipeline[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ import {
|
|||||||
BotRouteDryRunRequest,
|
BotRouteDryRunRequest,
|
||||||
BotRouteDryRunResult,
|
BotRouteDryRunResult,
|
||||||
BotEventRouteStatusResponse,
|
BotEventRouteStatusResponse,
|
||||||
|
ApiRespLangBotModelAvailability,
|
||||||
} from '@/app/infra/entities/api';
|
} from '@/app/infra/entities/api';
|
||||||
import { Plugin } from '@/app/infra/entities/plugin';
|
import { Plugin } from '@/app/infra/entities/plugin';
|
||||||
import type { PluginLogEntry } from '@/app/infra/entities/plugin';
|
import type { PluginLogEntry } from '@/app/infra/entities/plugin';
|
||||||
@@ -1250,6 +1251,10 @@ export class BackendClient extends BaseHttpClient {
|
|||||||
return this.get('/api/v1/system/wizard/recommended-model');
|
return this.get('/api/v1/system/wizard/recommended-model');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getLangBotModelAvailability(): Promise<ApiRespLangBotModelAvailability> {
|
||||||
|
return this.get('/api/v1/system/model-availability');
|
||||||
|
}
|
||||||
|
|
||||||
public getAsyncTasks(params?: {
|
public getAsyncTasks(params?: {
|
||||||
type?: string;
|
type?: string;
|
||||||
kind?: string;
|
kind?: string;
|
||||||
|
|||||||
@@ -304,6 +304,12 @@ const enUS = {
|
|||||||
usesOwnerSpaceBilling:
|
usesOwnerSpaceBilling:
|
||||||
"Uses the Workspace owner's LangBot Account billing and credits.",
|
"Uses the Workspace owner's LangBot Account billing and credits.",
|
||||||
noModels: 'No models configured',
|
noModels: 'No models configured',
|
||||||
|
availability: {
|
||||||
|
available: 'Available at last check',
|
||||||
|
unavailable: 'Unavailable at last check',
|
||||||
|
notChecked: 'No check result',
|
||||||
|
lastChecked: 'Checked {{time}}',
|
||||||
|
},
|
||||||
langbotModels: 'LangBot Models',
|
langbotModels: 'LangBot Models',
|
||||||
spaceTrialTooltip:
|
spaceTrialTooltip:
|
||||||
'Free trial credits available! Login with LangBot Account to access cloud models with zero configuration.',
|
'Free trial credits available! Login with LangBot Account to access cloud models with zero configuration.',
|
||||||
|
|||||||
@@ -306,6 +306,12 @@ const esES = {
|
|||||||
loginToUseModels:
|
loginToUseModels:
|
||||||
'Inicia sesión con una cuenta de LangBot para usar modelos en la nube',
|
'Inicia sesión con una cuenta de LangBot para usar modelos en la nube',
|
||||||
noModels: 'No hay modelos configurados',
|
noModels: 'No hay modelos configurados',
|
||||||
|
availability: {
|
||||||
|
available: 'Disponible en la última comprobación',
|
||||||
|
unavailable: 'No disponible en la última comprobación',
|
||||||
|
notChecked: 'Sin resultado de comprobación',
|
||||||
|
lastChecked: 'Comprobado {{time}}',
|
||||||
|
},
|
||||||
langbotModels: 'Modelos LangBot',
|
langbotModels: 'Modelos LangBot',
|
||||||
spaceTrialTooltip:
|
spaceTrialTooltip:
|
||||||
'¡Créditos de prueba gratuitos disponibles! Inicia sesión con una cuenta de LangBot para acceder a modelos en la nube sin configuración.',
|
'¡Créditos de prueba gratuitos disponibles! Inicia sesión con una cuenta de LangBot para acceder a modelos en la nube sin configuración.',
|
||||||
|
|||||||
@@ -309,6 +309,12 @@ const jaJP = {
|
|||||||
usesOwnerSpaceBilling:
|
usesOwnerSpaceBilling:
|
||||||
'ワークスペース所有者の LangBot アカウント課金とクレジットを使用します。',
|
'ワークスペース所有者の LangBot アカウント課金とクレジットを使用します。',
|
||||||
noModels: 'モデルがありません',
|
noModels: 'モデルがありません',
|
||||||
|
availability: {
|
||||||
|
available: '前回のチェックで利用可能',
|
||||||
|
unavailable: '前回のチェックで利用不可',
|
||||||
|
notChecked: 'チェック結果なし',
|
||||||
|
lastChecked: '{{time}} にチェック',
|
||||||
|
},
|
||||||
langbotModels: 'LangBot モデル',
|
langbotModels: 'LangBot モデル',
|
||||||
spaceTrialTooltip:
|
spaceTrialTooltip:
|
||||||
'無料トライアルクレジットが利用可能!LangBot アカウントでログインして、設定不要でクラウドモデルを使用できます。',
|
'無料トライアルクレジットが利用可能!LangBot アカウントでログインして、設定不要でクラウドモデルを使用できます。',
|
||||||
|
|||||||
@@ -303,6 +303,12 @@ const ruRU = {
|
|||||||
loginToUseModels:
|
loginToUseModels:
|
||||||
'Войдите с аккаунтом LangBot, чтобы использовать облачные модели',
|
'Войдите с аккаунтом LangBot, чтобы использовать облачные модели',
|
||||||
noModels: 'Модели не настроены',
|
noModels: 'Модели не настроены',
|
||||||
|
availability: {
|
||||||
|
available: 'Доступна при последней проверке',
|
||||||
|
unavailable: 'Недоступна при последней проверке',
|
||||||
|
notChecked: 'Нет результата проверки',
|
||||||
|
lastChecked: 'Проверено {{time}}',
|
||||||
|
},
|
||||||
langbotModels: 'Модели LangBot',
|
langbotModels: 'Модели LangBot',
|
||||||
spaceTrialTooltip:
|
spaceTrialTooltip:
|
||||||
'Доступны бесплатные пробные кредиты! Войдите с аккаунтом LangBot, чтобы получить доступ к облачным моделям без настройки.',
|
'Доступны бесплатные пробные кредиты! Войдите с аккаунтом LangBot, чтобы получить доступ к облачным моделям без настройки.',
|
||||||
|
|||||||
@@ -292,6 +292,12 @@ const thTH = {
|
|||||||
loginWithSpace: 'เข้าสู่ระบบด้วยบัญชี LangBot',
|
loginWithSpace: 'เข้าสู่ระบบด้วยบัญชี LangBot',
|
||||||
loginToUseModels: 'เข้าสู่ระบบด้วยบัญชี LangBot เพื่อใช้โมเดลคลาวด์',
|
loginToUseModels: 'เข้าสู่ระบบด้วยบัญชี LangBot เพื่อใช้โมเดลคลาวด์',
|
||||||
noModels: 'ยังไม่มีโมเดลที่กำหนดค่า',
|
noModels: 'ยังไม่มีโมเดลที่กำหนดค่า',
|
||||||
|
availability: {
|
||||||
|
available: 'พร้อมใช้งานในการตรวจสอบล่าสุด',
|
||||||
|
unavailable: 'ไม่พร้อมใช้งานในการตรวจสอบล่าสุด',
|
||||||
|
notChecked: 'ไม่มีผลการตรวจสอบ',
|
||||||
|
lastChecked: 'ตรวจสอบเมื่อ {{time}}',
|
||||||
|
},
|
||||||
langbotModels: 'โมเดล LangBot',
|
langbotModels: 'โมเดล LangBot',
|
||||||
spaceTrialTooltip:
|
spaceTrialTooltip:
|
||||||
'มีเครดิตทดลองใช้งานฟรี! เข้าสู่ระบบด้วยบัญชี LangBot เพื่อเข้าถึงโมเดลคลาวด์โดยไม่ต้องตั้งค่า',
|
'มีเครดิตทดลองใช้งานฟรี! เข้าสู่ระบบด้วยบัญชี LangBot เพื่อเข้าถึงโมเดลคลาวด์โดยไม่ต้องตั้งค่า',
|
||||||
|
|||||||
@@ -300,6 +300,12 @@ const viVN = {
|
|||||||
loginToUseModels:
|
loginToUseModels:
|
||||||
'Đăng nhập bằng tài khoản LangBot để sử dụng mô hình đám mây',
|
'Đăng nhập bằng tài khoản LangBot để sử dụng mô hình đám mây',
|
||||||
noModels: 'Chưa cấu hình mô hình nào',
|
noModels: 'Chưa cấu hình mô hình nào',
|
||||||
|
availability: {
|
||||||
|
available: 'Khả dụng ở lần kiểm tra gần nhất',
|
||||||
|
unavailable: 'Không khả dụng ở lần kiểm tra gần nhất',
|
||||||
|
notChecked: 'Chưa có kết quả kiểm tra',
|
||||||
|
lastChecked: 'Đã kiểm tra {{time}}',
|
||||||
|
},
|
||||||
langbotModels: 'Mô hình LangBot',
|
langbotModels: 'Mô hình LangBot',
|
||||||
spaceTrialTooltip:
|
spaceTrialTooltip:
|
||||||
'Có tín dụng dùng thử miễn phí! Đăng nhập bằng tài khoản LangBot để truy cập mô hình đám mây không cần cấu hình.',
|
'Có tín dụng dùng thử miễn phí! Đăng nhập bằng tài khoản LangBot để truy cập mô hình đám mây không cần cấu hình.',
|
||||||
|
|||||||
@@ -290,6 +290,12 @@ const zhHans = {
|
|||||||
'工作区所有者需要绑定 LangBot 账号才能使用 LangBot 模型。',
|
'工作区所有者需要绑定 LangBot 账号才能使用 LangBot 模型。',
|
||||||
usesOwnerSpaceBilling: '使用工作区所有者的 LangBot 账号计费与积分。',
|
usesOwnerSpaceBilling: '使用工作区所有者的 LangBot 账号计费与积分。',
|
||||||
noModels: '暂无模型',
|
noModels: '暂无模型',
|
||||||
|
availability: {
|
||||||
|
available: '上次检测可用',
|
||||||
|
unavailable: '上次检测不可用',
|
||||||
|
notChecked: '暂无检测结果',
|
||||||
|
lastChecked: '检测于 {{time}}',
|
||||||
|
},
|
||||||
langbotModels: 'LangBot 模型',
|
langbotModels: 'LangBot 模型',
|
||||||
spaceTrialTooltip:
|
spaceTrialTooltip:
|
||||||
'免费试用积分已就绪!通过 LangBot 账号登录即可零配置使用云端模型。',
|
'免费试用积分已就绪!通过 LangBot 账号登录即可零配置使用云端模型。',
|
||||||
|
|||||||
@@ -281,6 +281,12 @@ const zhHant = {
|
|||||||
loginWithSpace: '使用 LangBot 帳號登入',
|
loginWithSpace: '使用 LangBot 帳號登入',
|
||||||
loginToUseModels: '使用 LangBot 帳號登入以使用雲端模型',
|
loginToUseModels: '使用 LangBot 帳號登入以使用雲端模型',
|
||||||
noModels: '暫無模型',
|
noModels: '暫無模型',
|
||||||
|
availability: {
|
||||||
|
available: '上次檢測可用',
|
||||||
|
unavailable: '上次檢測不可用',
|
||||||
|
notChecked: '暫無檢測結果',
|
||||||
|
lastChecked: '檢測於 {{time}}',
|
||||||
|
},
|
||||||
langbotModels: 'LangBot 模型',
|
langbotModels: 'LangBot 模型',
|
||||||
spaceTrialTooltip:
|
spaceTrialTooltip:
|
||||||
'免費試用積分已就緒!使用 LangBot 帳號登入即可零設定使用雲端模型。',
|
'免費試用積分已就緒!使用 LangBot 帳號登入即可零設定使用雲端模型。',
|
||||||
|
|||||||
Reference in New Issue
Block a user