Ver código fonte

fix: honor configured chat service model

Ma 6 meses atrás
pai
commit
011b355137

+ 24 - 0
packages/studio/src/api/server.test.ts

@@ -954,6 +954,30 @@ describe("createStudioServer daemon lifecycle", () => {
     expect(raw.llm.defaultModel).toBe("kimi-k2.5");
   });
 
+  it("returns the saved default service and model for Studio chat selection", async () => {
+    await writeFile(join(root, "inkos.json"), JSON.stringify({
+      ...projectConfig,
+      llm: {
+        services: [
+          { service: "google", temperature: 1 },
+          { service: "moonshot", temperature: 0.7 },
+        ],
+        service: "moonshot",
+        defaultModel: "kimi-k2.5",
+      },
+    }, null, 2), "utf-8");
+
+    const { createStudioServer } = await import("./server.js");
+    const app = createStudioServer(cloneProjectConfig() as never, root);
+
+    const response = await app.request("http://localhost/api/v1/services/config");
+    expect(response.status).toBe(200);
+    await expect(response.json()).resolves.toMatchObject({
+      service: "moonshot",
+      defaultModel: "kimi-k2.5",
+    });
+  });
+
   it("rejects switching Studio runtime to env config source", async () => {
     const { createStudioServer } = await import("./server.js");
     const app = createStudioServer(cloneProjectConfig() as never, root);

+ 1 - 0
packages/studio/src/api/server.ts

@@ -1272,6 +1272,7 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
     const envConfig = await readEnvConfigStatus(root);
     return c.json({
       services,
+      service: typeof llm.service === "string" ? llm.service : null,
       defaultModel: llm.defaultModel ?? null,
       configSource: "studio" satisfies LLMConfigSource,
       storedConfigSource: normalizeConfigSource(llm.configSource),

+ 50 - 5
packages/studio/src/pages/ChatPage.tsx

@@ -2,6 +2,7 @@ import { useRef, useEffect, useMemo, useState } from "react";
 import type { Theme } from "../hooks/use-theme";
 import type { TFunction } from "../hooks/use-i18n";
 import type { SSEMessage } from "../hooks/use-sse";
+import { fetchJson } from "../hooks/use-api";
 import { chatSelectors, useChatStore } from "../store/chat";
 import { useServiceStore } from "../store/service";
 import {
@@ -31,6 +32,7 @@ import {
   MessageContent,
 } from "../components/ai-elements/message";
 import {
+  type ChatPageModelPreference,
   filterModelGroups,
   getBookCreateSessionId,
   pickModelSelection,
@@ -53,6 +55,11 @@ export interface ChatPageProps {
   readonly sse: { messages: ReadonlyArray<SSEMessage>; connected: boolean };
 }
 
+interface ServiceConfigPayload {
+  readonly service?: string | null;
+  readonly defaultModel?: string | null;
+}
+
 // -- Component --
 
 export function ChatPage({ activeBookId, nav, theme, t, sse: _sse }: ChatPageProps) {
@@ -96,12 +103,36 @@ export function ChatPage({ activeBookId, nav, theme, t, sse: _sse }: ChatPagePro
   const fetchServices = useServiceStore((s) => s.fetchServices);
   const fetchBankModels = useServiceStore((s) => s.fetchBankModels);
   const fetchCustomModels = useServiceStore((s) => s.fetchCustomModels);
+  const [configuredModelSelection, setConfiguredModelSelection] = useState<ChatPageModelPreference | null>(null);
+  const [serviceConfigLoaded, setServiceConfigLoaded] = useState(false);
 
   useEffect(() => { void fetchServices(); }, [fetchServices]);
   useEffect(() => {
     void fetchBankModels();
     void fetchCustomModels();
   }, [fetchBankModels, fetchCustomModels]);
+  useEffect(() => {
+    let cancelled = false;
+
+    void fetchJson<ServiceConfigPayload>("/services/config")
+      .then((payload) => {
+        if (cancelled) return;
+        setConfiguredModelSelection({
+          service: payload.service ?? null,
+          model: payload.defaultModel ?? null,
+        });
+      })
+      .catch(() => {
+        if (!cancelled) setConfiguredModelSelection(null);
+      })
+      .finally(() => {
+        if (!cancelled) setServiceConfigLoaded(true);
+      });
+
+    return () => {
+      cancelled = true;
+    };
+  }, []);
 
   const modelPickerStatus = useMemo(() => {
     if (servicesLoading || services.length === 0) return "loading" as const;
@@ -121,13 +152,27 @@ export function ChatPage({ activeBookId, nav, theme, t, sse: _sse }: ChatPagePro
       .map((s) => ({ service: s.service, label: s.label, models: modelsByService[s.service]! }));
   }, [services, modelsByService]);
 
-  // Auto-select first model when models load, and replace stale selections after lists refresh.
+  const selectedModelLabel = useMemo(() => {
+    if (!selectedModel) return "选择模型";
+    const group = groupedModels.find((item) => item.service === selectedService);
+    const model = group?.models.find((item) => item.id === selectedModel);
+    const modelLabel = model?.name ?? selectedModel;
+    return group ? `${group.label} · ${modelLabel}` : modelLabel;
+  }, [groupedModels, selectedModel, selectedService]);
+
+  // Auto-select from saved service config first, then fall back to the first available model.
   useEffect(() => {
-    const nextSelection = pickModelSelection(groupedModels, selectedModel, selectedService);
+    if (!serviceConfigLoaded) return;
+    const nextSelection = pickModelSelection(
+      groupedModels,
+      selectedModel,
+      selectedService,
+      configuredModelSelection,
+    );
     if (nextSelection) {
       setSelectedModel(nextSelection.model, nextSelection.service);
     }
-  }, [groupedModels, selectedModel, selectedService, setSelectedModel]);
+  }, [configuredModelSelection, groupedModels, selectedModel, selectedService, serviceConfigLoaded, setSelectedModel]);
 
   // Auto-resize textarea
   useEffect(() => {
@@ -356,8 +401,8 @@ export function ChatPage({ activeBookId, nav, theme, t, sse: _sse }: ChatPagePro
                 ) : modelPickerStatus === "ready" ? (
                   <DropdownMenu>
                     <DropdownMenuTrigger className="flex items-center gap-1.5 px-2 py-1 rounded-md hover:bg-muted text-sm transition-colors cursor-pointer">
-                      <span className="font-medium text-xs truncate max-w-[140px]">
-                        {selectedModel ?? "选择模型"}
+                      <span className="font-medium text-xs truncate max-w-[220px]">
+                        {selectedModelLabel}
                       </span>
                       <ChevronDown size={14} className="text-muted-foreground" />
                     </DropdownMenuTrigger>

+ 27 - 0
packages/studio/src/pages/chat-page-state.test.ts

@@ -142,6 +142,33 @@ describe("pickModelSelection", () => {
     });
   });
 
+  it("prefers the configured service and model when there is no current selection", () => {
+    expect(pickModelSelection(grouped, null, null, {
+      service: "moonshot",
+      model: "kimi-k2.5",
+    })).toEqual({
+      model: "kimi-k2.5",
+      service: "moonshot",
+    });
+  });
+
+  it("prefers the configured service even when its configured model is stale", () => {
+    expect(pickModelSelection(grouped, null, null, {
+      service: "moonshot",
+      model: "kimi-k3",
+    })).toEqual({
+      model: "kimi-k2.5",
+      service: "moonshot",
+    });
+  });
+
+  it("keeps a valid user selection over the configured default", () => {
+    expect(pickModelSelection(grouped, "gemini-2.5-flash", "google", {
+      service: "moonshot",
+      model: "kimi-k2.5",
+    })).toBeNull();
+  });
+
   it("returns null when no models are available", () => {
     expect(pickModelSelection([], "gemini-3.1-flash-image-preview", "google")).toBeNull();
   });

+ 29 - 0
packages/studio/src/pages/chat-page-state.ts

@@ -9,6 +9,11 @@ export interface ChatPageModelGroup {
   readonly models: ReadonlyArray<ChatPageModelInfo>;
 }
 
+export interface ChatPageModelPreference {
+  readonly model?: string | null;
+  readonly service?: string | null;
+}
+
 const BOOK_CREATE_SESSION_KEY = "inkos.book-create.session-id";
 
 export function getBookCreateSessionId(): string | null {
@@ -45,6 +50,7 @@ export function pickModelSelection(
   groupedModels: ReadonlyArray<ChatPageModelGroup>,
   selectedModel: string | null,
   selectedService: string | null,
+  preference?: ChatPageModelPreference | null,
 ): { model: string; service: string } | null {
   const selectedStillAvailable = selectedModel && selectedService
     ? groupedModels.some((group) =>
@@ -54,6 +60,29 @@ export function pickModelSelection(
     : false;
   if (selectedStillAvailable) return null;
 
+  const preferredService = preference?.service?.trim();
+  const preferredModel = preference?.model?.trim();
+  if (preferredService) {
+    const preferredGroup = groupedModels.find((group) => group.service === preferredService);
+    const exactModel = preferredModel
+      ? preferredGroup?.models.find((model) => model.id === preferredModel)
+      : undefined;
+    if (preferredGroup && exactModel) {
+      return { model: exactModel.id, service: preferredGroup.service };
+    }
+    const firstPreferredModel = preferredGroup?.models[0];
+    if (preferredGroup && firstPreferredModel) {
+      return { model: firstPreferredModel.id, service: preferredGroup.service };
+    }
+  }
+
+  if (preferredModel) {
+    for (const group of groupedModels) {
+      const exactModel = group.models.find((model) => model.id === preferredModel);
+      if (exactModel) return { model: exactModel.id, service: group.service };
+    }
+  }
+
   const firstGroup = groupedModels.find((group) => group.models.length > 0);
   const firstModel = firstGroup?.models[0];
   if (!firstGroup || !firstModel) return null;