feat(models): show LangBot Models availability

This commit is contained in:
RockChinQ
2026-09-11 21:17:01 +08:00
parent e631da0073
commit a3509b3626
20 changed files with 375 additions and 6 deletions
@@ -71,6 +71,8 @@ import { LANGBOT_MODELS_PROVIDER_REQUESTER } from '@/app/home/components/models-
import ReasoningLevelPicker, {
REASONING_LEVELS,
} 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 }>(
item: T,
@@ -153,6 +155,33 @@ export default function DynamicFormItemComponent({
const [modelsDialogOpen, setModelsDialogOpen] = useState(false);
const [settingsSection, setSettingsSection] =
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 = () => {
httpClient
@@ -584,6 +613,7 @@ export default function DynamicFormItemComponent({
<SelectItem key={model.uuid} value={model.uuid}>
<span className="inline-flex items-center gap-1">
{model.name}
{renderModelAvailability(model)}
{model.abilities?.includes('vision') && (
<Eye className="h-3 w-3 text-muted-foreground" />
)}
@@ -688,6 +718,7 @@ export default function DynamicFormItemComponent({
<SelectItem key={model.uuid} value={model.uuid}>
<span className="inline-flex items-center gap-1">
{model.name}
{renderModelAvailability(model)}
{model.abilities?.includes('vision') && (
<Eye className="h-3 w-3 text-muted-foreground" />
)}
@@ -784,6 +815,7 @@ export default function DynamicFormItemComponent({
{models.map((model) => (
<SelectItem key={model.uuid} value={model.uuid}>
{model.name}
{renderModelAvailability(model)}
</SelectItem>
))}
</SelectGroup>
@@ -876,6 +908,7 @@ export default function DynamicFormItemComponent({
{models.map((model) => (
<SelectItem key={model.uuid} value={model.uuid}>
{model.name}
{renderModelAvailability(model)}
</SelectItem>
))}
</SelectGroup>
@@ -941,6 +974,7 @@ export default function DynamicFormItemComponent({
{models.map((model) => (
<SelectItem key={model.uuid} value={model.uuid}>
{model.name}
{renderModelAvailability(model)}
</SelectItem>
))}
</SelectGroup>
@@ -1060,6 +1094,7 @@ export default function DynamicFormItemComponent({
<SelectItem key={model.uuid} value={model.uuid}>
<span className="inline-flex items-center gap-1">
{model.name}
{renderModelAvailability(model)}
{model.abilities?.includes('vision') && (
<Eye className="h-3 w-3 text-muted-foreground" />
)}
@@ -1165,6 +1200,7 @@ export default function DynamicFormItemComponent({
<SelectItem key={model.uuid} value={model.uuid}>
<span className="inline-flex items-center gap-1">
{model.name}
{renderModelAvailability(model)}
{model.abilities?.includes('vision') && (
<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 { useCurrentWorkspace } from '@/app/infra/http';
import type { WorkspaceSpaceBilling } from '@/app/infra/entities/workspace';
import { useLangBotModelAvailability } from '../model-availability/useLangBotModelAvailability';
interface ModelsPanelProps {
// 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 canManage =
currentWorkspace?.permissions.includes('provider_secret.manage') ?? false;
const {
availability: langbotModelAvailability,
loaded: langbotModelAvailabilityLoaded,
} = useLangBotModelAvailability(active && !systemInfo.disable_models_service);
const [providers, setProviders] = useState<ModelProvider[]>([]);
const [spaceBilling, setSpaceBilling] =
@@ -554,6 +559,8 @@ export default function ModelsPanel({
isWorkspaceOwner={currentWorkspace?.membership.role === 'owner'}
ownerSpaceBound={spaceBilling?.owner_space_bound ?? false}
spaceCredits={spaceBilling?.credits ?? null}
modelAvailability={langbotModelAvailability}
modelAvailabilityLoaded={langbotModelAvailabilityLoaded}
addModelPopoverOpen={addModelPopoverOpen}
editModelPopoverOpen={editModelPopoverOpen}
deleteConfirmOpen={deleteConfirmOpen}
@@ -14,6 +14,7 @@ import { useTranslation } from 'react-i18next';
import {
LLMModel,
EmbeddingModel,
LangBotModelAvailability,
ReasoningConfig,
} from '@/app/infra/entities/api';
import {
@@ -24,12 +25,15 @@ import {
} from '../types';
import ExtraArgsEditor from './ExtraArgsEditor';
import { userInfo } from '@/app/infra/http';
import ModelAvailabilityIndicator from '../../model-availability/ModelAvailabilityIndicator';
interface ModelItemProps {
model: LLMModel | EmbeddingModel;
canManage: boolean;
modelType: ModelType;
isLangBotModels: boolean;
availability?: LangBotModelAvailability;
availabilityLoaded: boolean;
editModelPopoverOpen: string | null;
deleteConfirmOpen: string | null;
onOpenEditModel: (modelId: string) => void;
@@ -86,6 +90,8 @@ export default function ModelItem({
canManage,
modelType,
isLangBotModels,
availability,
availabilityLoaded,
editModelPopoverOpen,
deleteConfirmOpen,
onOpenEditModel,
@@ -199,6 +205,12 @@ export default function ModelItem({
>
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium">{model.name}</span>
{isLangBotModels && (
<ModelAvailabilityIndicator
availability={availability}
show={availabilityLoaded}
/>
)}
<Badge variant="secondary" className="text-xs">
{modelType === 'llm'
? t('models.chat')
@@ -9,7 +9,11 @@ import {
Radar,
} from 'lucide-react';
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 {
Collapsible,
@@ -47,6 +51,8 @@ interface ProviderCardProps {
isWorkspaceOwner: boolean;
ownerSpaceBound: boolean;
spaceCredits: number | null;
modelAvailability: Record<string, LangBotModelAvailability>;
modelAvailabilityLoaded: boolean;
// Popover states
addModelPopoverOpen: string | null;
editModelPopoverOpen: string | null;
@@ -115,6 +121,8 @@ export default function ProviderCard({
isWorkspaceOwner,
ownerSpaceBound,
spaceCredits,
modelAvailability,
modelAvailabilityLoaded,
addModelPopoverOpen,
editModelPopoverOpen,
deleteConfirmOpen,
@@ -424,6 +432,11 @@ export default function ProviderCard({
canManage={canManage}
modelType="llm"
isLangBotModels={isLangBotModels}
availability={
modelAvailability[model.uuid] ??
modelAvailability[model.name]
}
availabilityLoaded={modelAvailabilityLoaded}
editModelPopoverOpen={editModelPopoverOpen}
deleteConfirmOpen={deleteConfirmOpen}
onOpenEditModel={onOpenEditModel}
@@ -475,6 +488,11 @@ export default function ProviderCard({
canManage={canManage}
modelType="embedding"
isLangBotModels={isLangBotModels}
availability={
modelAvailability[model.uuid] ??
modelAvailability[model.name]
}
availabilityLoaded={modelAvailabilityLoaded}
editModelPopoverOpen={editModelPopoverOpen}
deleteConfirmOpen={deleteConfirmOpen}
onOpenEditModel={onOpenEditModel}
@@ -524,6 +542,11 @@ export default function ProviderCard({
canManage={canManage}
modelType="rerank"
isLangBotModels={isLangBotModels}
availability={
modelAvailability[model.uuid] ??
modelAvailability[model.name]
}
availabilityLoaded={modelAvailabilityLoaded}
editModelPopoverOpen={editModelPopoverOpen}
deleteConfirmOpen={deleteConfirmOpen}
onOpenEditModel={onOpenEditModel}
+18
View File
@@ -158,6 +158,24 @@ export interface RerankModel {
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 {
pipelines: Pipeline[];
}
+5
View File
@@ -63,6 +63,7 @@ import {
BotRouteDryRunRequest,
BotRouteDryRunResult,
BotEventRouteStatusResponse,
ApiRespLangBotModelAvailability,
} from '@/app/infra/entities/api';
import { Plugin } 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');
}
public getLangBotModelAvailability(): Promise<ApiRespLangBotModelAvailability> {
return this.get('/api/v1/system/model-availability');
}
public getAsyncTasks(params?: {
type?: string;
kind?: string;
+6
View File
@@ -304,6 +304,12 @@ const enUS = {
usesOwnerSpaceBilling:
"Uses the Workspace owner's LangBot Account billing and credits.",
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',
spaceTrialTooltip:
'Free trial credits available! Login with LangBot Account to access cloud models with zero configuration.',
+6
View File
@@ -306,6 +306,12 @@ const esES = {
loginToUseModels:
'Inicia sesión con una cuenta de LangBot para usar modelos en la nube',
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',
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.',
+6
View File
@@ -309,6 +309,12 @@ const jaJP = {
usesOwnerSpaceBilling:
'ワークスペース所有者の LangBot アカウント課金とクレジットを使用します。',
noModels: 'モデルがありません',
availability: {
available: '前回のチェックで利用可能',
unavailable: '前回のチェックで利用不可',
notChecked: 'チェック結果なし',
lastChecked: '{{time}} にチェック',
},
langbotModels: 'LangBot モデル',
spaceTrialTooltip:
'無料トライアルクレジットが利用可能!LangBot アカウントでログインして、設定不要でクラウドモデルを使用できます。',
+6
View File
@@ -303,6 +303,12 @@ const ruRU = {
loginToUseModels:
'Войдите с аккаунтом LangBot, чтобы использовать облачные модели',
noModels: 'Модели не настроены',
availability: {
available: 'Доступна при последней проверке',
unavailable: 'Недоступна при последней проверке',
notChecked: 'Нет результата проверки',
lastChecked: 'Проверено {{time}}',
},
langbotModels: 'Модели LangBot',
spaceTrialTooltip:
'Доступны бесплатные пробные кредиты! Войдите с аккаунтом LangBot, чтобы получить доступ к облачным моделям без настройки.',
+6
View File
@@ -292,6 +292,12 @@ const thTH = {
loginWithSpace: 'เข้าสู่ระบบด้วยบัญชี LangBot',
loginToUseModels: 'เข้าสู่ระบบด้วยบัญชี LangBot เพื่อใช้โมเดลคลาวด์',
noModels: 'ยังไม่มีโมเดลที่กำหนดค่า',
availability: {
available: 'พร้อมใช้งานในการตรวจสอบล่าสุด',
unavailable: 'ไม่พร้อมใช้งานในการตรวจสอบล่าสุด',
notChecked: 'ไม่มีผลการตรวจสอบ',
lastChecked: 'ตรวจสอบเมื่อ {{time}}',
},
langbotModels: 'โมเดล LangBot',
spaceTrialTooltip:
'มีเครดิตทดลองใช้งานฟรี! เข้าสู่ระบบด้วยบัญชี LangBot เพื่อเข้าถึงโมเดลคลาวด์โดยไม่ต้องตั้งค่า',
+6
View File
@@ -300,6 +300,12 @@ const viVN = {
loginToUseModels:
'Đă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',
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',
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.',
+6
View File
@@ -290,6 +290,12 @@ const zhHans = {
'工作区所有者需要绑定 LangBot 账号才能使用 LangBot 模型。',
usesOwnerSpaceBilling: '使用工作区所有者的 LangBot 账号计费与积分。',
noModels: '暂无模型',
availability: {
available: '上次检测可用',
unavailable: '上次检测不可用',
notChecked: '暂无检测结果',
lastChecked: '检测于 {{time}}',
},
langbotModels: 'LangBot 模型',
spaceTrialTooltip:
'免费试用积分已就绪!通过 LangBot 账号登录即可零配置使用云端模型。',
+6
View File
@@ -281,6 +281,12 @@ const zhHant = {
loginWithSpace: '使用 LangBot 帳號登入',
loginToUseModels: '使用 LangBot 帳號登入以使用雲端模型',
noModels: '暫無模型',
availability: {
available: '上次檢測可用',
unavailable: '上次檢測不可用',
notChecked: '暫無檢測結果',
lastChecked: '檢測於 {{time}}',
},
langbotModels: 'LangBot 模型',
spaceTrialTooltip:
'免費試用積分已就緒!使用 LangBot 帳號登入即可零設定使用雲端模型。',