feat(web): clarify model availability and status display
- Rename model availability field from configured to available across backend API and frontend usage - Keep status as reason classification (configured/unconfigured/unreachable) and show unreachable in UI - Preserve API key preview even when local service is unreachable - Update backend tests to assert both availability and status semantics
This commit is contained in:
parent
dd54601f2d
commit
134779b266
12 changed files with 159 additions and 68 deletions
|
|
@ -15,6 +15,17 @@ import (
|
||||||
|
|
||||||
const modelProbeTimeout = 800 * time.Millisecond
|
const modelProbeTimeout = 800 * time.Millisecond
|
||||||
|
|
||||||
|
const (
|
||||||
|
modelStatusConfigured = "configured"
|
||||||
|
modelStatusUnconfigured = "unconfigured"
|
||||||
|
modelStatusUnreachable = "unreachable"
|
||||||
|
)
|
||||||
|
|
||||||
|
type modelConfigurationSummary struct {
|
||||||
|
Available bool
|
||||||
|
Status string
|
||||||
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
probeTCPServiceFunc = probeTCPService
|
probeTCPServiceFunc = probeTCPService
|
||||||
probeOllamaModelFunc = probeOllamaModel
|
probeOllamaModelFunc = probeOllamaModel
|
||||||
|
|
@ -43,16 +54,17 @@ func hasModelConfiguration(m *config.ModelConfig) bool {
|
||||||
return apiKey != ""
|
return apiKey != ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// isModelConfigured reports whether a model is currently available to use.
|
func modelConfigurationStatus(m *config.ModelConfig) modelConfigurationSummary {
|
||||||
// Local models must be reachable; remote/API-key models only need saved config.
|
|
||||||
func isModelConfigured(m *config.ModelConfig) bool {
|
|
||||||
if !hasModelConfiguration(m) {
|
if !hasModelConfiguration(m) {
|
||||||
return false
|
return modelConfigurationSummary{Available: false, Status: modelStatusUnconfigured}
|
||||||
}
|
}
|
||||||
if requiresRuntimeProbe(m) {
|
if requiresRuntimeProbe(m) {
|
||||||
return probeLocalModelAvailability(m)
|
if probeLocalModelAvailability(m) {
|
||||||
|
return modelConfigurationSummary{Available: true, Status: modelStatusConfigured}
|
||||||
|
}
|
||||||
|
return modelConfigurationSummary{Available: false, Status: modelStatusUnreachable}
|
||||||
}
|
}
|
||||||
return true
|
return modelConfigurationSummary{Available: true, Status: modelStatusConfigured}
|
||||||
}
|
}
|
||||||
|
|
||||||
func requiresRuntimeProbe(m *config.ModelConfig) bool {
|
func requiresRuntimeProbe(m *config.ModelConfig) bool {
|
||||||
|
|
|
||||||
|
|
@ -40,10 +40,11 @@ type modelResponse struct {
|
||||||
ThinkingLevel string `json:"thinking_level,omitempty"`
|
ThinkingLevel string `json:"thinking_level,omitempty"`
|
||||||
ExtraBody map[string]any `json:"extra_body,omitempty"`
|
ExtraBody map[string]any `json:"extra_body,omitempty"`
|
||||||
// Meta
|
// Meta
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
Configured bool `json:"configured"`
|
Available bool `json:"available"`
|
||||||
IsDefault bool `json:"is_default"`
|
Status string `json:"status"`
|
||||||
IsVirtual bool `json:"is_virtual"`
|
IsDefault bool `json:"is_default"`
|
||||||
|
IsVirtual bool `json:"is_virtual"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleListModels returns all model_list entries with masked API keys.
|
// handleListModels returns all model_list entries with masked API keys.
|
||||||
|
|
@ -57,14 +58,14 @@ func (h *Handler) handleListModels(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
|
|
||||||
defaultModel := cfg.Agents.Defaults.GetModelName()
|
defaultModel := cfg.Agents.Defaults.GetModelName()
|
||||||
configured := make([]bool, len(cfg.ModelList))
|
modelStatuses := make([]modelConfigurationSummary, len(cfg.ModelList))
|
||||||
|
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
wg.Add(len(cfg.ModelList))
|
wg.Add(len(cfg.ModelList))
|
||||||
for i, m := range cfg.ModelList {
|
for i, m := range cfg.ModelList {
|
||||||
go func(i int, m *config.ModelConfig) {
|
go func(i int, m *config.ModelConfig) {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
configured[i] = isModelConfigured(m)
|
modelStatuses[i] = modelConfigurationStatus(m)
|
||||||
}(i, m)
|
}(i, m)
|
||||||
}
|
}
|
||||||
wg.Wait()
|
wg.Wait()
|
||||||
|
|
@ -87,9 +88,10 @@ func (h *Handler) handleListModels(w http.ResponseWriter, r *http.Request) {
|
||||||
ThinkingLevel: m.ThinkingLevel,
|
ThinkingLevel: m.ThinkingLevel,
|
||||||
ExtraBody: m.ExtraBody,
|
ExtraBody: m.ExtraBody,
|
||||||
Enabled: m.Enabled,
|
Enabled: m.Enabled,
|
||||||
Configured: configured[i],
|
Available: modelStatuses[i].Available,
|
||||||
IsDefault: m.ModelName == defaultModel,
|
Status: modelStatuses[i].Status,
|
||||||
IsVirtual: m.IsVirtual(),
|
IsDefault: m.ModelName == defaultModel,
|
||||||
|
IsVirtual: m.IsVirtual(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ func resetModelProbeHooks(t *testing.T) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHandleListModels_ConfiguredStatusUsesRuntimeProbesForLocalModels(t *testing.T) {
|
func TestHandleListModels_AvailabilityUsesRuntimeProbesForLocalModels(t *testing.T) {
|
||||||
configPath, cleanup := setupOAuthTestEnv(t)
|
configPath, cleanup := setupOAuthTestEnv(t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
resetOAuthHooks(t)
|
resetOAuthHooks(t)
|
||||||
|
|
@ -113,25 +113,42 @@ func TestHandleListModels_ConfiguredStatusUsesRuntimeProbesForLocalModels(t *tes
|
||||||
t.Fatalf("Unmarshal() error = %v", err)
|
t.Fatalf("Unmarshal() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
got := make(map[string]bool, len(resp.Models))
|
gotAvailable := make(map[string]bool, len(resp.Models))
|
||||||
|
gotStatus := make(map[string]string, len(resp.Models))
|
||||||
for _, model := range resp.Models {
|
for _, model := range resp.Models {
|
||||||
got[model.ModelName] = model.Configured
|
gotAvailable[model.ModelName] = model.Available
|
||||||
|
gotStatus[model.ModelName] = model.Status
|
||||||
}
|
}
|
||||||
|
|
||||||
if got["openai-oauth"] {
|
if gotAvailable["openai-oauth"] {
|
||||||
t.Fatalf("openai oauth model configured = true, want false without stored credential")
|
t.Fatalf("openai oauth model available = true, want false without stored credential")
|
||||||
}
|
}
|
||||||
if !got["vllm-local"] {
|
if !gotAvailable["vllm-local"] {
|
||||||
t.Fatalf("vllm local model configured = false, want true when local probe succeeds")
|
t.Fatalf("vllm local model available = false, want true when local probe succeeds")
|
||||||
}
|
}
|
||||||
if !got["ollama-default"] {
|
if !gotAvailable["ollama-default"] {
|
||||||
t.Fatalf("ollama default model configured = false, want true when default local probe succeeds")
|
t.Fatalf("ollama default model available = false, want true when default local probe succeeds")
|
||||||
}
|
}
|
||||||
if !got["vllm-remote"] {
|
if !gotAvailable["vllm-remote"] {
|
||||||
t.Fatalf("remote vllm model configured = false, want true with api_key")
|
t.Fatalf("remote vllm model available = false, want true with api_key")
|
||||||
}
|
}
|
||||||
if !got["copilot-gpt-5.4"] {
|
if !gotAvailable["copilot-gpt-5.4"] {
|
||||||
t.Fatalf("copilot model configured = false, want true when local bridge probe succeeds")
|
t.Fatalf("copilot model available = false, want true when local bridge probe succeeds")
|
||||||
|
}
|
||||||
|
if gotStatus["openai-oauth"] != modelStatusUnconfigured {
|
||||||
|
t.Fatalf("openai oauth model status = %q, want %q", gotStatus["openai-oauth"], modelStatusUnconfigured)
|
||||||
|
}
|
||||||
|
if gotStatus["vllm-local"] != modelStatusConfigured {
|
||||||
|
t.Fatalf("vllm local model status = %q, want %q", gotStatus["vllm-local"], modelStatusConfigured)
|
||||||
|
}
|
||||||
|
if gotStatus["ollama-default"] != modelStatusConfigured {
|
||||||
|
t.Fatalf("ollama default model status = %q, want %q", gotStatus["ollama-default"], modelStatusConfigured)
|
||||||
|
}
|
||||||
|
if gotStatus["vllm-remote"] != modelStatusConfigured {
|
||||||
|
t.Fatalf("remote vllm model status = %q, want %q", gotStatus["vllm-remote"], modelStatusConfigured)
|
||||||
|
}
|
||||||
|
if gotStatus["copilot-gpt-5.4"] != modelStatusConfigured {
|
||||||
|
t.Fatalf("copilot model status = %q, want %q", gotStatus["copilot-gpt-5.4"], modelStatusConfigured)
|
||||||
}
|
}
|
||||||
if len(openAIProbes) != 1 || openAIProbes[0] != "http://127.0.0.1:8000/v1|custom-model|" {
|
if len(openAIProbes) != 1 || openAIProbes[0] != "http://127.0.0.1:8000/v1|custom-model|" {
|
||||||
t.Fatalf("openAI probes = %#v, want only local vllm probe", openAIProbes)
|
t.Fatalf("openAI probes = %#v, want only local vllm probe", openAIProbes)
|
||||||
|
|
@ -144,7 +161,7 @@ func TestHandleListModels_ConfiguredStatusUsesRuntimeProbesForLocalModels(t *tes
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHandleListModels_ConfiguredStatusForOAuthModelWithCredential(t *testing.T) {
|
func TestHandleListModels_AvailabilityForOAuthModelWithCredential(t *testing.T) {
|
||||||
configPath, cleanup := setupOAuthTestEnv(t)
|
configPath, cleanup := setupOAuthTestEnv(t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
resetOAuthHooks(t)
|
resetOAuthHooks(t)
|
||||||
|
|
@ -193,8 +210,8 @@ func TestHandleListModels_ConfiguredStatusForOAuthModelWithCredential(t *testing
|
||||||
if len(resp.Models) != 1 {
|
if len(resp.Models) != 1 {
|
||||||
t.Fatalf("len(models) = %d, want 1", len(resp.Models))
|
t.Fatalf("len(models) = %d, want 1", len(resp.Models))
|
||||||
}
|
}
|
||||||
if !resp.Models[0].Configured {
|
if !resp.Models[0].Available {
|
||||||
t.Fatalf("oauth model configured = false, want true with stored credential")
|
t.Fatalf("oauth model available = false, want true with stored credential")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -306,14 +323,71 @@ func TestHandleListModels_NormalizesWildcardLocalAPIBaseForProbe(t *testing.T) {
|
||||||
if len(resp.Models) != 1 {
|
if len(resp.Models) != 1 {
|
||||||
t.Fatalf("len(models) = %d, want 1", len(resp.Models))
|
t.Fatalf("len(models) = %d, want 1", len(resp.Models))
|
||||||
}
|
}
|
||||||
if !resp.Models[0].Configured {
|
if !resp.Models[0].Available {
|
||||||
t.Fatal("wildcard-bound local model configured = false, want true after probe host normalization")
|
t.Fatal("wildcard-bound local model available = false, want true after probe host normalization")
|
||||||
}
|
}
|
||||||
if gotProbe != "http://127.0.0.1:8000/v1|custom-model|" {
|
if gotProbe != "http://127.0.0.1:8000/v1|custom-model|" {
|
||||||
t.Fatalf("probe api base = %q, want %q", gotProbe, "http://127.0.0.1:8000/v1|custom-model|")
|
t.Fatalf("probe api base = %q, want %q", gotProbe, "http://127.0.0.1:8000/v1|custom-model|")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHandleListModels_StatusMarksUnreachableLocalModel(t *testing.T) {
|
||||||
|
configPath, cleanup := setupOAuthTestEnv(t)
|
||||||
|
defer cleanup()
|
||||||
|
resetOAuthHooks(t)
|
||||||
|
resetModelProbeHooks(t)
|
||||||
|
|
||||||
|
probeOpenAICompatibleModelFunc = func(apiBase, modelID, apiKey string) bool {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := config.LoadConfig(configPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
cfg.ModelList = []*config.ModelConfig{{
|
||||||
|
ModelName: "vllm-local-down",
|
||||||
|
Model: "vllm/custom-model",
|
||||||
|
APIBase: "http://127.0.0.1:8000/v1",
|
||||||
|
APIKeys: config.SimpleSecureStrings("test-key"),
|
||||||
|
}}
|
||||||
|
if err := config.SaveConfig(configPath, cfg); err != nil {
|
||||||
|
t.Fatalf("SaveConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
h := NewHandler(configPath)
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
h.RegisterRoutes(mux)
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/models", nil)
|
||||||
|
mux.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp struct {
|
||||||
|
Models []modelResponse `json:"models"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("Unmarshal() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(resp.Models) != 1 {
|
||||||
|
t.Fatalf("len(models) = %d, want 1", len(resp.Models))
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.Models[0].Available {
|
||||||
|
t.Fatal("unreachable local model available = true, want false")
|
||||||
|
}
|
||||||
|
if resp.Models[0].Status != modelStatusUnreachable {
|
||||||
|
t.Fatalf("unreachable local model status = %q, want %q", resp.Models[0].Status, modelStatusUnreachable)
|
||||||
|
}
|
||||||
|
if resp.Models[0].APIKey == "" {
|
||||||
|
t.Fatal("masked API key preview should still be returned when API key is configured")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestHandleAddModel_PersistsAPIKey(t *testing.T) {
|
func TestHandleAddModel_PersistsAPIKey(t *testing.T) {
|
||||||
configPath, cleanup := setupOAuthTestEnv(t)
|
configPath, cleanup := setupOAuthTestEnv(t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,8 @@ export interface ModelInfo {
|
||||||
thinking_level?: string
|
thinking_level?: string
|
||||||
extra_body?: Record<string, unknown>
|
extra_body?: Record<string, unknown>
|
||||||
// Meta
|
// Meta
|
||||||
configured: boolean
|
available: boolean
|
||||||
|
status?: "configured" | "unconfigured" | "unreachable"
|
||||||
is_default: boolean
|
is_default: boolean
|
||||||
is_virtual: boolean
|
is_virtual: boolean
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,19 +10,19 @@ import { useTranslation } from "react-i18next"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
|
|
||||||
interface ChatEmptyStateProps {
|
interface ChatEmptyStateProps {
|
||||||
hasConfiguredModels: boolean
|
hasAvailableModels: boolean
|
||||||
defaultModelName: string
|
defaultModelName: string
|
||||||
isConnected: boolean
|
isConnected: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatEmptyState({
|
export function ChatEmptyState({
|
||||||
hasConfiguredModels,
|
hasAvailableModels,
|
||||||
defaultModelName,
|
defaultModelName,
|
||||||
isConnected,
|
isConnected,
|
||||||
}: ChatEmptyStateProps) {
|
}: ChatEmptyStateProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
|
||||||
if (!hasConfiguredModels) {
|
if (!hasAvailableModels) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center py-20 opacity-70">
|
<div className="flex flex-col items-center justify-center py-20 opacity-70">
|
||||||
<div className="mb-6 flex h-16 w-16 items-center justify-center rounded-2xl bg-amber-500/10 text-amber-500">
|
<div className="mb-6 flex h-16 w-16 items-center justify-center rounded-2xl bg-amber-500/10 text-amber-500">
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,7 @@ export function ChatPage() {
|
||||||
|
|
||||||
const {
|
const {
|
||||||
defaultModelName,
|
defaultModelName,
|
||||||
hasConfiguredModels,
|
hasAvailableModels,
|
||||||
apiKeyModels,
|
apiKeyModels,
|
||||||
oauthModels,
|
oauthModels,
|
||||||
localModels,
|
localModels,
|
||||||
|
|
@ -94,7 +94,7 @@ export function ChatPage() {
|
||||||
hasScrolled ? "shadow-sm" : "shadow-none"
|
hasScrolled ? "shadow-sm" : "shadow-none"
|
||||||
}`}
|
}`}
|
||||||
titleExtra={
|
titleExtra={
|
||||||
hasConfiguredModels && (
|
hasAvailableModels && (
|
||||||
<ModelSelector
|
<ModelSelector
|
||||||
defaultModelName={defaultModelName}
|
defaultModelName={defaultModelName}
|
||||||
apiKeyModels={apiKeyModels}
|
apiKeyModels={apiKeyModels}
|
||||||
|
|
@ -140,7 +140,7 @@ export function ChatPage() {
|
||||||
<div className="mx-auto flex w-full max-w-250 flex-col gap-8 pb-8">
|
<div className="mx-auto flex w-full max-w-250 flex-col gap-8 pb-8">
|
||||||
{messages.length === 0 && !isTyping && (
|
{messages.length === 0 && !isTyping && (
|
||||||
<ChatEmptyState
|
<ChatEmptyState
|
||||||
hasConfiguredModels={hasConfiguredModels}
|
hasAvailableModels={hasAvailableModels}
|
||||||
defaultModelName={defaultModelName}
|
defaultModelName={defaultModelName}
|
||||||
isConnected={isGatewayRunning}
|
isConnected={isGatewayRunning}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -133,7 +133,7 @@ export function EditModelSheet({
|
||||||
}
|
}
|
||||||
|
|
||||||
const isOAuth = model?.auth_method === "oauth"
|
const isOAuth = model?.auth_method === "oauth"
|
||||||
const apiKeyPlaceholder = model?.configured
|
const apiKeyPlaceholder = model?.available
|
||||||
? maskedSecretPlaceholder(
|
? maskedSecretPlaceholder(
|
||||||
model.api_key,
|
model.api_key,
|
||||||
t("models.field.apiKeyPlaceholderSet"),
|
t("models.field.apiKeyPlaceholderSet"),
|
||||||
|
|
@ -161,7 +161,7 @@ export function EditModelSheet({
|
||||||
<Field
|
<Field
|
||||||
label={t("models.field.apiKey")}
|
label={t("models.field.apiKey")}
|
||||||
hint={
|
hint={
|
||||||
model?.configured ? t("models.edit.apiKeyHint") : undefined
|
model?.available ? t("models.edit.apiKeyHint") : undefined
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<KeyInput
|
<KeyInput
|
||||||
|
|
|
||||||
|
|
@ -28,14 +28,16 @@ export function ModelCard({
|
||||||
}: ModelCardProps) {
|
}: ModelCardProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const isOAuth = model.auth_method === "oauth"
|
const isOAuth = model.auth_method === "oauth"
|
||||||
|
const status = model.status ?? (model.available ? "configured" : "unconfigured")
|
||||||
|
const statusLabel = t(`models.status.${status}`)
|
||||||
const canSetDefault =
|
const canSetDefault =
|
||||||
model.configured && !model.is_default && !model.is_virtual
|
model.available && !model.is_default && !model.is_virtual
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={[
|
className={[
|
||||||
"group/card hover:bg-muted/30 relative flex w-full max-w-[36rem] flex-col gap-3 justify-self-start rounded-xl border p-4 transition-colors hover:shadow-xs",
|
"group/card hover:bg-muted/30 relative flex w-full max-w-[36rem] flex-col gap-3 justify-self-start rounded-xl border p-4 transition-colors hover:shadow-xs",
|
||||||
model.configured
|
model.available
|
||||||
? "border-border/60 bg-card"
|
? "border-border/60 bg-card"
|
||||||
: "border-border/50 bg-card/60",
|
: "border-border/50 bg-card/60",
|
||||||
].join(" ")}
|
].join(" ")}
|
||||||
|
|
@ -47,15 +49,13 @@ export function ModelCard({
|
||||||
"mt-0.5 h-2 w-2 shrink-0 rounded-full",
|
"mt-0.5 h-2 w-2 shrink-0 rounded-full",
|
||||||
model.is_default
|
model.is_default
|
||||||
? "bg-green-400 shadow-[0_0_0_2px_rgba(74,222,128,0.35)]"
|
? "bg-green-400 shadow-[0_0_0_2px_rgba(74,222,128,0.35)]"
|
||||||
: model.configured
|
: status === "configured"
|
||||||
? "bg-green-500"
|
? "bg-green-500"
|
||||||
|
: status === "unreachable"
|
||||||
|
? "bg-amber-500"
|
||||||
: "bg-muted-foreground/25",
|
: "bg-muted-foreground/25",
|
||||||
].join(" ")}
|
].join(" ")}
|
||||||
title={
|
title={statusLabel}
|
||||||
model.configured
|
|
||||||
? t("models.status.configured")
|
|
||||||
: t("models.status.unconfigured")
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
<span className="text-foreground truncate text-sm font-semibold">
|
<span className="text-foreground truncate text-sm font-semibold">
|
||||||
{model.model_name}
|
{model.model_name}
|
||||||
|
|
@ -127,14 +127,14 @@ export function ModelCard({
|
||||||
<span className="text-muted-foreground bg-muted rounded px-1.5 py-0.5 text-[10px] font-medium">
|
<span className="text-muted-foreground bg-muted rounded px-1.5 py-0.5 text-[10px] font-medium">
|
||||||
OAuth
|
OAuth
|
||||||
</span>
|
</span>
|
||||||
) : model.configured && model.api_key ? (
|
) : model.api_key ? (
|
||||||
<span className="text-muted-foreground/70 flex items-center gap-1 font-mono text-[11px]">
|
<span className="text-muted-foreground/70 flex items-center gap-1 font-mono text-[11px]">
|
||||||
<IconKey className="size-3" />
|
<IconKey className="size-3" />
|
||||||
{model.api_key}
|
{model.api_key}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-muted-foreground/50 text-[11px]">
|
<span className="text-muted-foreground/50 text-[11px]">
|
||||||
{t("models.status.unconfigured")}
|
{statusLabel}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,7 @@ interface ProviderGroup {
|
||||||
label: string
|
label: string
|
||||||
models: ModelInfo[]
|
models: ModelInfo[]
|
||||||
hasDefault: boolean
|
hasDefault: boolean
|
||||||
configuredCount: number
|
availableCount: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ModelsPage() {
|
export function ModelsPage() {
|
||||||
|
|
@ -62,8 +62,8 @@ export function ModelsPage() {
|
||||||
const sorted = [...data.models].sort((a, b) => {
|
const sorted = [...data.models].sort((a, b) => {
|
||||||
if (a.is_default && !b.is_default) return -1
|
if (a.is_default && !b.is_default) return -1
|
||||||
if (!a.is_default && b.is_default) return 1
|
if (!a.is_default && b.is_default) return 1
|
||||||
if (a.configured && !b.configured) return -1
|
if (a.available && !b.available) return -1
|
||||||
if (!a.configured && b.configured) return 1
|
if (!a.available && b.available) return 1
|
||||||
return a.model_name.localeCompare(b.model_name)
|
return a.model_name.localeCompare(b.model_name)
|
||||||
})
|
})
|
||||||
setModels(sorted)
|
setModels(sorted)
|
||||||
|
|
@ -107,23 +107,23 @@ export function ModelsPage() {
|
||||||
|
|
||||||
const providerGroups: ProviderGroup[] = Object.entries(grouped)
|
const providerGroups: ProviderGroup[] = Object.entries(grouped)
|
||||||
.map(([key, group]) => {
|
.map(([key, group]) => {
|
||||||
const configuredCount = group.models.filter(
|
const availableCount = group.models.filter(
|
||||||
(model) => model.configured,
|
(model) => model.available,
|
||||||
).length
|
).length
|
||||||
return {
|
return {
|
||||||
key,
|
key,
|
||||||
label: group.label,
|
label: group.label,
|
||||||
models: group.models,
|
models: group.models,
|
||||||
hasDefault: group.models.some((model) => model.is_default),
|
hasDefault: group.models.some((model) => model.is_default),
|
||||||
configuredCount,
|
availableCount,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
if (a.hasDefault && !b.hasDefault) return -1
|
if (a.hasDefault && !b.hasDefault) return -1
|
||||||
if (!a.hasDefault && b.hasDefault) return 1
|
if (!a.hasDefault && b.hasDefault) return 1
|
||||||
|
|
||||||
if (a.configuredCount !== b.configuredCount) {
|
if (a.availableCount !== b.availableCount) {
|
||||||
return b.configuredCount - a.configuredCount
|
return b.availableCount - a.availableCount
|
||||||
}
|
}
|
||||||
|
|
||||||
const aPriority = PROVIDER_PRIORITY[a.key] ?? Number.MAX_SAFE_INTEGER
|
const aPriority = PROVIDER_PRIORITY[a.key] ?? Number.MAX_SAFE_INTEGER
|
||||||
|
|
|
||||||
|
|
@ -65,32 +65,32 @@ export function useChatModels({ isConnected }: UseChatModelsOptions) {
|
||||||
[defaultModelName],
|
[defaultModelName],
|
||||||
)
|
)
|
||||||
|
|
||||||
const hasConfiguredModels = useMemo(
|
const hasAvailableModels = useMemo(
|
||||||
() => modelList.some((m) => m.configured),
|
() => modelList.some((m) => m.available),
|
||||||
[modelList],
|
[modelList],
|
||||||
)
|
)
|
||||||
|
|
||||||
const oauthModels = useMemo(
|
const oauthModels = useMemo(
|
||||||
() => modelList.filter((m) => m.configured && m.auth_method === "oauth"),
|
() => modelList.filter((m) => m.available && m.auth_method === "oauth"),
|
||||||
[modelList],
|
[modelList],
|
||||||
)
|
)
|
||||||
|
|
||||||
const localModels = useMemo(
|
const localModels = useMemo(
|
||||||
() => modelList.filter((m) => m.configured && isLocalModel(m)),
|
() => modelList.filter((m) => m.available && isLocalModel(m)),
|
||||||
[modelList],
|
[modelList],
|
||||||
)
|
)
|
||||||
|
|
||||||
const apiKeyModels = useMemo(
|
const apiKeyModels = useMemo(
|
||||||
() =>
|
() =>
|
||||||
modelList.filter(
|
modelList.filter(
|
||||||
(m) => m.configured && m.auth_method !== "oauth" && !isLocalModel(m),
|
(m) => m.available && m.auth_method !== "oauth" && !isLocalModel(m),
|
||||||
),
|
),
|
||||||
[modelList],
|
[modelList],
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
defaultModelName,
|
defaultModelName,
|
||||||
hasConfiguredModels,
|
hasAvailableModels,
|
||||||
apiKeyModels,
|
apiKeyModels,
|
||||||
oauthModels,
|
oauthModels,
|
||||||
localModels,
|
localModels,
|
||||||
|
|
|
||||||
|
|
@ -171,7 +171,8 @@
|
||||||
"noDefaultHintSuffix": "to set one.",
|
"noDefaultHintSuffix": "to set one.",
|
||||||
"status": {
|
"status": {
|
||||||
"configured": "Configured",
|
"configured": "Configured",
|
||||||
"unconfigured": "Not configured"
|
"unconfigured": "Not configured",
|
||||||
|
"unreachable": "Service unreachable"
|
||||||
},
|
},
|
||||||
"badge": {
|
"badge": {
|
||||||
"default": "Default",
|
"default": "Default",
|
||||||
|
|
|
||||||
|
|
@ -171,7 +171,8 @@
|
||||||
"noDefaultHintSuffix": "设为默认。",
|
"noDefaultHintSuffix": "设为默认。",
|
||||||
"status": {
|
"status": {
|
||||||
"configured": "已配置",
|
"configured": "已配置",
|
||||||
"unconfigured": "未配置"
|
"unconfigured": "未配置",
|
||||||
|
"unreachable": "服务不可达"
|
||||||
},
|
},
|
||||||
"badge": {
|
"badge": {
|
||||||
"default": "默认",
|
"default": "默认",
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue