Просмотр исходного кода

feat(studio): 重构服务模型加载前端

fanghanjun 6 месяцев назад
Родитель
Сommit
effbe0b42c

+ 25 - 0
packages/studio/src/constants/service-groups.ts

@@ -0,0 +1,25 @@
+import type { EndpointGroup } from "../store/service/types";
+
+export const GROUP_ORDER: ReadonlyArray<EndpointGroup> = [
+  "overseas",
+  "china",
+  "aggregator",
+  "local",
+  "codingPlan",
+] as const;
+
+export const GROUP_LABELS: Record<EndpointGroup, string> = {
+  overseas: "海外原厂",
+  china: "国产原厂",
+  aggregator: "聚合 / 二手 API",
+  local: "本地 / 订阅",
+  codingPlan: "CodingPlan",
+};
+
+export const GROUP_SHORT_LABELS: Record<EndpointGroup, string> = {
+  overseas: "海外",
+  china: "国产",
+  aggregator: "聚合",
+  local: "本地",
+  codingPlan: "CodingPlan",
+};

+ 16 - 11
packages/studio/src/pages/ChatPage.tsx

@@ -89,30 +89,35 @@ export function ChatPage({ activeBookId, nav, theme, t, sse: _sse }: ChatPagePro
   // -- Model picker: read raw state, derive with useMemo (stable refs) --
   const services = useServiceStore((s) => s.services);
   const servicesLoading = useServiceStore((s) => s.servicesLoading);
+  const bankModelsLoading = useServiceStore((s) => s.bankModelsLoading);
+  const customModelsLoading = useServiceStore((s) => s.customModelsLoading);
   const modelsByService = useServiceStore((s) => s.modelsByService);
   const fetchServices = useServiceStore((s) => s.fetchServices);
-  const fetchModels = useServiceStore((s) => s.fetchModels);
+  const fetchBankModels = useServiceStore((s) => s.fetchBankModels);
+  const fetchCustomModels = useServiceStore((s) => s.fetchCustomModels);
 
   useEffect(() => { void fetchServices(); }, [fetchServices]);
   useEffect(() => {
-    for (const svc of services) {
-      if (svc.connected) void fetchModels(svc.service);
-    }
-  }, [services, fetchModels]);
+    void fetchBankModels();
+    void fetchCustomModels();
+  }, [fetchBankModels, fetchCustomModels]);
 
   const modelPickerStatus = useMemo(() => {
     if (servicesLoading || services.length === 0) return "loading" as const;
     const connected = services.filter((s) => s.connected);
     if (connected.length === 0) return "no-models" as const;
-    if (connected.some((s) => modelsByService[s.service]?.loading)) return "loading" as const;
-    return connected.some((s) => (modelsByService[s.service]?.models.length ?? 0) > 0)
-      ? "ready" as const : "no-models" as const;
-  }, [services, servicesLoading, modelsByService]);
+    if (bankModelsLoading) return "loading" as const;
+    if (connected.some((s) => (modelsByService[s.service]?.length ?? 0) > 0)) return "ready" as const;
+    const hasConnectedBank = connected.some((s) => !s.service.startsWith("custom"));
+    const hasConnectedCustom = connected.some((s) => s.service.startsWith("custom"));
+    if (!hasConnectedBank && hasConnectedCustom && customModelsLoading) return "loading" as const;
+    return "no-models" as const;
+  }, [services, servicesLoading, bankModelsLoading, customModelsLoading, modelsByService]);
 
   const groupedModels = useMemo(() => {
     return services
-      .filter((s) => s.connected && (modelsByService[s.service]?.models.length ?? 0) > 0)
-      .map((s) => ({ service: s.service, label: s.label, models: modelsByService[s.service]!.models }));
+      .filter((s) => s.connected && (modelsByService[s.service]?.length ?? 0) > 0)
+      .map((s) => ({ service: s.service, label: s.label, models: modelsByService[s.service]! }));
   }, [services, modelsByService]);
 
   // Auto-select first model when models load and none selected

+ 1 - 1
packages/studio/src/pages/ServiceDetailPage.tsx

@@ -33,7 +33,7 @@ export function ServiceDetailPage({ serviceId, nav }: { serviceId: string; nav:
   const loading = useServiceStore((s) => s.servicesLoading);
   const fetchServices = useServiceStore((s) => s.fetchServices);
   const refreshServices = useServiceStore((s) => s.refreshServices);
-  const setStoreModels = useServiceStore((s) => s.setModels);
+  const setStoreModels = useServiceStore((s) => s.setLiveModels);
   const clearStoreModels = useServiceStore((s) => s.clearModels);
 
   useEffect(() => { void fetchServices(); }, [fetchServices]);

+ 218 - 45
packages/studio/src/pages/ServiceListPage.tsx

@@ -1,7 +1,8 @@
-import { useEffect } from "react";
+import { useEffect, useMemo, useState } from "react";
+import { Check, Plus, Search, X } from "lucide-react";
+import { GROUP_LABELS, GROUP_ORDER, GROUP_SHORT_LABELS } from "../constants/service-groups";
 import { useServiceStore } from "../store/service";
-import type { ServiceInfo } from "../store/service";
-import { ServiceConfigSourceCard } from "../components/ServiceConfigSourceCard";
+import type { EndpointGroup, ServiceInfo } from "../store/service";
 
 interface Nav {
   toDashboard: () => void;
@@ -10,7 +11,7 @@ interface Nav {
 
 function SkeletonCard() {
   return (
-    <div className="rounded-xl border border-border/30 p-5 animate-pulse">
+    <div className="rounded-lg border border-border/30 p-5 animate-pulse">
       <div className="flex items-center justify-between mb-3">
         <div className="h-4 w-24 bg-muted rounded" />
         <div className="w-2 h-2 rounded-full bg-muted" />
@@ -20,16 +21,104 @@ function SkeletonCard() {
   );
 }
 
+function ServiceCard({ svc, onClick }: { svc: ServiceInfo; onClick: () => void }) {
+  return (
+    <button
+      onClick={onClick}
+      className={[
+        "flex min-h-[92px] flex-col gap-2 rounded-lg border p-5 text-left transition-all hover:shadow-sm",
+        svc.connected
+          ? "border-emerald-500/30 bg-emerald-500/[0.03]"
+          : "border-dashed border-border/40",
+      ].join(" ")}
+    >
+      <div className="flex items-center justify-between gap-3">
+        <span className="truncate text-sm font-medium">{svc.label}</span>
+        <span className={`h-1.5 w-1.5 rounded-full shrink-0 ${svc.connected ? "bg-emerald-500" : "bg-muted-foreground/30"}`} />
+      </div>
+      <span className="text-xs text-muted-foreground/60">
+        {svc.connected ? "已连接" : "未配置"}
+      </span>
+    </button>
+  );
+}
+
 export function ServiceListPage({ nav }: { nav: Nav }) {
   const services = useServiceStore((s) => s.services);
   const loading = useServiceStore((s) => s.servicesLoading);
   const fetchServices = useServiceStore((s) => s.fetchServices);
-  const refreshServices = useServiceStore((s) => s.refreshServices);
 
   useEffect(() => { void fetchServices(); }, [fetchServices]);
 
+  const [query, setQuery] = useState("");
+  const [selectedGroups, setSelectedGroups] = useState<Set<EndpointGroup>>(new Set());
+  const [onlyConnected, setOnlyConnected] = useState(false);
+
+  const bankServices = useMemo(
+    () => services.filter((s) => !s.service.startsWith("custom")),
+    [services],
+  );
+  const customServices = useMemo(
+    () => services.filter((s) => s.service.startsWith("custom")),
+    [services],
+  );
+
+  const groupCounts = useMemo(() => {
+    const counts = {} as Record<EndpointGroup, number>;
+    for (const group of GROUP_ORDER) {
+      counts[group] = bankServices.filter((s) => s.group === group).length;
+    }
+    return counts;
+  }, [bankServices]);
+
+  const connectedCount = useMemo(
+    () => services.filter((s) => s.connected).length,
+    [services],
+  );
+
+  const filtered = useMemo(() => {
+    const q = query.trim().toLowerCase();
+    return bankServices.filter((svc) => {
+      if (onlyConnected && !svc.connected) return false;
+      if (selectedGroups.size > 0 && (!svc.group || !selectedGroups.has(svc.group))) return false;
+      if (q && !svc.label.toLowerCase().includes(q) && !svc.service.toLowerCase().includes(q)) return false;
+      return true;
+    });
+  }, [bankServices, onlyConnected, query, selectedGroups]);
+
+  const filteredCustom = useMemo(() => {
+    const q = query.trim().toLowerCase();
+    if (selectedGroups.size > 0) return [];
+    return customServices.filter((svc) => {
+      if (onlyConnected && !svc.connected) return false;
+      if (q && !svc.label.toLowerCase().includes(q) && !svc.service.toLowerCase().includes(q)) return false;
+      return true;
+    });
+  }, [customServices, onlyConnected, query, selectedGroups]);
+
+  const byGroup = useMemo(() => {
+    const map = {} as Record<EndpointGroup, ServiceInfo[]>;
+    for (const group of GROUP_ORDER) map[group] = [];
+    for (const svc of filtered) {
+      if (svc.group) map[svc.group].push(svc);
+    }
+    return map;
+  }, [filtered]);
+
+  const toggleGroup = (group: EndpointGroup) => {
+    setSelectedGroups((prev) => {
+      const next = new Set(prev);
+      if (next.has(group)) next.delete(group);
+      else next.add(group);
+      return next;
+    });
+  };
+
+  const canCreateCustom = selectedGroups.size === 0 && query.trim() === "" && !onlyConnected;
+  const showCustomSection = !loading && selectedGroups.size === 0 && (filteredCustom.length > 0 || canCreateCustom);
+
   return (
-    <div className="max-w-2xl mx-auto space-y-8">
+    <div className="mx-auto max-w-2xl space-y-6">
       <div className="flex items-center gap-2 text-sm text-muted-foreground">
         <button
           onClick={nav.toDashboard}
@@ -43,51 +132,135 @@ export function ServiceListPage({ nav }: { nav: Nav }) {
 
       <h1 className="font-serif text-2xl">服务商管理</h1>
 
-      <ServiceConfigSourceCard onChange={() => { void refreshServices(); }} />
-
-      <div className="grid grid-cols-2 gap-3">
-        {loading
-          ? Array.from({ length: 6 }, (_, i) => <SkeletonCard key={i} />)
-          : services.map((svc) => (
-              <ServiceCard
-                key={svc.service}
-                svc={svc}
-                onClick={() => nav.toServiceDetail(svc.service)}
-              />
-            ))
-        }
-        {!loading && (
+      <div className="relative">
+        <Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground/40" />
+        <input
+          type="text"
+          value={query}
+          onChange={(event) => setQuery(event.target.value)}
+          placeholder="搜索服务商"
+          className="w-full rounded-lg border border-border/60 bg-background py-2 pl-9 pr-9 text-sm outline-none focus:border-primary/50"
+        />
+        {query && (
           <button
-            onClick={() => nav.toServiceDetail("custom")}
-            className="flex flex-col items-center justify-center gap-1.5 rounded-xl border border-dashed border-border/40 p-5 text-muted-foreground/60 hover:border-primary/30 hover:text-muted-foreground transition-all"
+            onClick={() => setQuery("")}
+            className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground/50 hover:text-muted-foreground"
+            aria-label="清空搜索"
           >
-            <span className="text-lg leading-none">+</span>
-            <span className="text-xs">自定义服务</span>
+            <X size={14} />
           </button>
         )}
       </div>
-    </div>
-  );
-}
 
-function ServiceCard({ svc, onClick }: { svc: ServiceInfo; onClick: () => void }) {
-  return (
-    <button
-      onClick={onClick}
-      className={[
-        "flex flex-col gap-2 rounded-xl border p-5 text-left transition-all hover:shadow-sm",
-        svc.connected
-          ? "border-emerald-500/30 bg-emerald-500/[0.03]"
-          : "border-dashed border-border/40",
-      ].join(" ")}
-    >
-      <div className="flex items-center justify-between">
-        <span className="font-medium text-sm">{svc.label}</span>
-        <span className={`w-1.5 h-1.5 rounded-full shrink-0 ${svc.connected ? "bg-emerald-500" : "bg-muted-foreground/30"}`} />
+      <div className="flex flex-wrap gap-2">
+        <button
+          onClick={() => setSelectedGroups(new Set())}
+          className={[
+            "inline-flex items-center gap-1 rounded-full border px-3 py-1 text-xs transition-colors",
+            selectedGroups.size === 0
+              ? "border-foreground bg-foreground text-background"
+              : "border-border/60 text-muted-foreground hover:bg-secondary/50",
+          ].join(" ")}
+        >
+          全部 {bankServices.length}
+        </button>
+        {GROUP_ORDER.map((group) => {
+          const selected = selectedGroups.has(group);
+          return (
+            <button
+              key={group}
+              onClick={() => toggleGroup(group)}
+              className={[
+                "inline-flex items-center gap-1 rounded-full border px-3 py-1 text-xs transition-colors",
+                selected
+                  ? "border-foreground bg-foreground text-background"
+                  : "border-border/60 text-muted-foreground hover:bg-secondary/50",
+              ].join(" ")}
+            >
+              {selected && <Check size={12} />}
+              {GROUP_SHORT_LABELS[group]} {groupCounts[group]}
+            </button>
+          );
+        })}
+        {selectedGroups.size > 0 && (
+          <button
+            onClick={() => setSelectedGroups(new Set())}
+            className="inline-flex items-center rounded-full px-3 py-1 text-xs text-muted-foreground transition-colors hover:text-foreground"
+          >
+            清除筛选
+          </button>
+        )}
       </div>
-      <span className="text-xs text-muted-foreground/60">
-        {svc.connected ? "已连接" : "未配置"}
-      </span>
-    </button>
+
+      <label className="inline-flex cursor-pointer select-none items-center gap-2 text-xs text-muted-foreground">
+        <input
+          type="checkbox"
+          checked={onlyConnected}
+          onChange={(event) => setOnlyConnected(event.target.checked)}
+        />
+        <span>只看已连接 ({connectedCount})</span>
+      </label>
+
+      <div className="h-px bg-border/30" />
+
+      {loading && (
+        <div className="grid grid-cols-2 gap-3">
+          {Array.from({ length: 6 }, (_, i) => <SkeletonCard key={i} />)}
+        </div>
+      )}
+
+      {!loading && GROUP_ORDER.map((group) => {
+        const list = byGroup[group];
+        if (!list || list.length === 0) return null;
+        return (
+          <section key={group} className="space-y-3">
+            <h2 className="text-xs font-medium uppercase tracking-wider text-muted-foreground/70">
+              {GROUP_LABELS[group]}
+            </h2>
+            <div className="grid grid-cols-2 gap-3">
+              {list.map((svc) => (
+                <ServiceCard
+                  key={svc.service}
+                  svc={svc}
+                  onClick={() => nav.toServiceDetail(svc.service)}
+                />
+              ))}
+            </div>
+          </section>
+        );
+      })}
+
+      {showCustomSection && (
+        <section className="space-y-3">
+          <h2 className="text-xs font-medium uppercase tracking-wider text-muted-foreground/70">
+            自定义服务
+          </h2>
+          <div className="grid grid-cols-2 gap-3">
+            {filteredCustom.map((svc) => (
+              <ServiceCard
+                key={svc.service}
+                svc={svc}
+                onClick={() => nav.toServiceDetail(svc.service)}
+              />
+            ))}
+            {canCreateCustom && (
+              <button
+                onClick={() => nav.toServiceDetail("custom")}
+                className="flex min-h-[92px] flex-col items-center justify-center gap-1.5 rounded-lg border border-dashed border-border/40 p-5 text-muted-foreground/60 transition-all hover:border-primary/30 hover:text-muted-foreground"
+              >
+                <Plus size={18} />
+                <span className="text-xs">自定义服务</span>
+              </button>
+            )}
+          </div>
+        </section>
+      )}
+
+      {!loading && filtered.length === 0 && filteredCustom.length === 0 && !canCreateCustom && (
+        <div className="rounded-lg border border-dashed border-border/40 p-8 text-center text-sm text-muted-foreground">
+          没有匹配的服务商
+        </div>
+      )}
+    </div>
   );
 }

+ 1 - 1
packages/studio/src/store/service/index.ts

@@ -1,2 +1,2 @@
 export { useServiceStore } from "./store";
-export type { ServiceStore, ServiceInfo, ModelInfo, ModelGroup, ModelPickerStatus } from "./types";
+export type { EndpointGroup, ServiceStore, ServiceInfo, ModelInfo, ModelGroup, ModelPickerStatus } from "./types";

+ 72 - 56
packages/studio/src/store/service/store.ts

@@ -1,70 +1,90 @@
 import { create } from "zustand";
-import type { ServiceStore } from "./types";
 import { fetchJson } from "../../hooks/use-api";
+import type { ModelInfo, ServiceInfo, ServiceStore } from "./types";
+
+interface GroupPayload {
+  readonly service: string;
+  readonly label: string;
+  readonly models: ReadonlyArray<ModelInfo>;
+}
 
 export const useServiceStore = create<ServiceStore>()((set, get) => ({
-  // -- State --
   services: [],
   servicesLoading: false,
-  modelsByService: {},
 
-  // -- Actions --
+  modelsByService: {},
+  bankModelsLoading: false,
+  customModelsLoading: false,
+  liveModelsLoading: {},
 
   fetchServices: async () => {
-    // Skip if already loaded
     if (get().services.length > 0 || get().servicesLoading) return;
     set({ servicesLoading: true });
     try {
-      const data = await fetchJson<{ services: any[] }>("/services");
+      const data = await fetchJson<{ services: ReadonlyArray<ServiceInfo> }>("/services");
       set({ services: data.services ?? [], servicesLoading: false });
     } catch {
       set({ servicesLoading: false });
     }
   },
 
-  fetchModels: async (service: string) => {
-    const existing = get().modelsByService[service];
-    // Skip if already loaded or loading
-    if (existing?.models.length || existing?.loading) return;
+  refreshServices: async () => {
+    set({ services: [], servicesLoading: false });
+    await get().fetchServices();
+  },
 
-    set((s) => ({
-      modelsByService: {
-        ...s.modelsByService,
-        [service]: { models: [], loading: true, error: null },
-      },
-    }));
+  fetchBankModels: async () => {
+    if (get().bankModelsLoading) return;
+    set({ bankModelsLoading: true });
+    try {
+      const data = await fetchJson<{ groups: ReadonlyArray<GroupPayload> }>("/services/models");
+      set((s) => {
+        const next = { ...s.modelsByService };
+        for (const group of data.groups ?? []) {
+          next[group.service] = group.models;
+        }
+        return { modelsByService: next, bankModelsLoading: false };
+      });
+    } catch {
+      set({ bankModelsLoading: false });
+    }
+  },
+
+  fetchCustomModels: async () => {
+    if (get().customModelsLoading) return;
+    set({ customModelsLoading: true });
+    try {
+      const data = await fetchJson<{ groups: ReadonlyArray<GroupPayload> }>("/services/models/custom");
+      set((s) => {
+        const next = { ...s.modelsByService };
+        for (const group of data.groups ?? []) {
+          next[group.service] = group.models;
+        }
+        return { modelsByService: next, customModelsLoading: false };
+      });
+    } catch {
+      set({ customModelsLoading: false });
+    }
+  },
 
+  fetchLiveModels: async (service: string) => {
+    if (get().liveModelsLoading[service]) return;
+    set((s) => ({ liveModelsLoading: { ...s.liveModelsLoading, [service]: true } }));
     try {
-      const data = await fetchJson<{ models: any[] }>(
+      const data = await fetchJson<{ models: ReadonlyArray<ModelInfo> }>(
         `/services/${encodeURIComponent(service)}/models`,
       );
       set((s) => ({
-        modelsByService: {
-          ...s.modelsByService,
-          [service]: { models: data.models ?? [], loading: false, error: null },
-        },
-      }));
-    } catch (e) {
-      set((s) => ({
-        modelsByService: {
-          ...s.modelsByService,
-          [service]: {
-            models: [],
-            loading: false,
-            error: e instanceof Error ? e.message : "Failed",
-          },
-        },
+        modelsByService: { ...s.modelsByService, [service]: data.models ?? [] },
+        liveModelsLoading: { ...s.liveModelsLoading, [service]: false },
       }));
+    } catch {
+      set((s) => ({ liveModelsLoading: { ...s.liveModelsLoading, [service]: false } }));
     }
   },
 
-  setModels: (service, models) => {
-    set((s) => ({
-      modelsByService: {
-        ...s.modelsByService,
-        [service]: { models, loading: false, error: null },
-      },
-    }));
+  setLiveModels: (service, models) => {
+    set((s) => ({ modelsByService: { ...s.modelsByService, [service]: models } }));
   },
 
   clearModels: (service) => {
@@ -75,31 +95,27 @@ export const useServiceStore = create<ServiceStore>()((set, get) => ({
     });
   },
 
-  refreshServices: async () => {
-    set({ services: [], servicesLoading: false });
-    await get().fetchServices();
-  },
-
-  // -- Selectors --
-
   getModelPickerStatus: () => {
-    const { services, servicesLoading, modelsByService } = get();
-    if (servicesLoading || services.length === 0) return "loading";
+    const { services, servicesLoading, bankModelsLoading, customModelsLoading, modelsByService } = get();
+    if (servicesLoading) return "loading";
     const connected = services.filter((s) => s.connected);
     if (connected.length === 0) return "no-models";
-    const anyLoading = connected.some((s) => modelsByService[s.service]?.loading);
-    if (anyLoading) return "loading";
-    const anyModels = connected.some((s) => (modelsByService[s.service]?.models.length ?? 0) > 0);
-    return anyModels ? "ready" : "no-models";
+    if (bankModelsLoading) return "loading";
+    if (connected.some((s) => (modelsByService[s.service]?.length ?? 0) > 0)) return "ready";
+
+    const hasConnectedBank = connected.some((s) => !s.service.startsWith("custom"));
+    const hasConnectedCustom = connected.some((s) => s.service.startsWith("custom"));
+    if (!hasConnectedBank && hasConnectedCustom && customModelsLoading) return "loading";
+    return "no-models";
   },
 
   getGroupedModels: () => {
     const { services, modelsByService } = get();
-    const groups: Array<{ service: string; label: string; models: ReadonlyArray<{ id: string; name?: string }> }> = [];
+    const groups: Array<{ service: string; label: string; models: ReadonlyArray<ModelInfo> }> = [];
     for (const svc of services.filter((s) => s.connected)) {
-      const entry = modelsByService[svc.service];
-      if (entry?.models.length) {
-        groups.push({ service: svc.service, label: svc.label, models: entry.models });
+      const models = modelsByService[svc.service] ?? [];
+      if (models.length > 0) {
+        groups.push({ service: svc.service, label: svc.label, models });
       }
     }
     return groups;

+ 25 - 37
packages/studio/src/store/service/types.ts

@@ -1,62 +1,50 @@
+export type EndpointGroup =
+  | "overseas"
+  | "china"
+  | "aggregator"
+  | "local"
+  | "codingPlan";
+
 export interface ServiceInfo {
   readonly service: string;
   readonly label: string;
+  readonly group?: EndpointGroup;
   readonly connected: boolean;
 }
 
 export interface ModelInfo {
   readonly id: string;
   readonly name?: string;
+  readonly maxOutput?: number;
+  readonly contextWindow?: number;
 }
 
-export interface ServiceModelsEntry {
+export type ModelPickerStatus = "loading" | "no-models" | "ready";
+
+export interface ModelGroup {
+  readonly service: string;
+  readonly label: string;
   readonly models: ReadonlyArray<ModelInfo>;
-  readonly loading: boolean;
-  readonly error: string | null;
 }
 
-// -- State --
-
-export interface ServiceState {
-  /** All known services with connection status */
+export interface ServiceStore {
   services: ReadonlyArray<ServiceInfo>;
   servicesLoading: boolean;
-  /** Models keyed by service id, fetched on demand */
-  modelsByService: Record<string, ServiceModelsEntry>;
-}
 
-// -- Actions --
+  modelsByService: Record<string, ReadonlyArray<ModelInfo>>;
+  bankModelsLoading: boolean;
+  customModelsLoading: boolean;
+  liveModelsLoading: Record<string, boolean>;
 
-export interface ServiceActions {
-  /** Fetch service list (fast — only reads secrets, no external API) */
   fetchServices: () => Promise<void>;
-  /** Fetch models for a specific service (calls external API) */
-  fetchModels: (service: string) => Promise<void>;
-  /** Write models directly (from test connection result) */
-  setModels: (service: string, models: ReadonlyArray<ModelInfo>) => void;
-  /** Clear models for a service (key removed) */
-  clearModels: (service: string) => void;
-  /** Invalidate and re-fetch services (after saving a key) */
   refreshServices: () => Promise<void>;
-}
-
-// -- Derived (selectors) --
+  fetchBankModels: () => Promise<void>;
+  fetchCustomModels: () => Promise<void>;
+  fetchLiveModels: (service: string) => Promise<void>;
 
-export interface ModelGroup {
-  readonly service: string;
-  readonly label: string;
-  readonly models: ReadonlyArray<ModelInfo>;
-}
-
-export type ModelPickerStatus = "loading" | "no-models" | "ready";
+  setLiveModels: (service: string, models: ReadonlyArray<ModelInfo>) => void;
+  clearModels: (service: string) => void;
 
-export interface ServiceSelectors {
-  /** Model picker status: loading → no-models → ready */
   getModelPickerStatus: () => ModelPickerStatus;
-  /** Grouped models for dropdown, derived from store state */
   getGroupedModels: () => ReadonlyArray<ModelGroup>;
 }
-
-// -- Composed --
-
-export type ServiceStore = ServiceState & ServiceActions & ServiceSelectors;