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

fix(studio): 过滤非文本聊天模型

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

+ 6 - 0
packages/core/src/__tests__/list-models.test.ts

@@ -24,6 +24,12 @@ describe("listModelsForService (B8)", () => {
     expect(sonnet?.contextWindow).toBe(1_000_000);
   });
 
+  it("google service 的文本模型列表不包含 image preview 模型", async () => {
+    const models = await listModelsForService("google");
+    expect(models.some((m) => m.id === "gemini-2.5-flash")).toBe(true);
+    expect(models.some((m) => m.id.includes("image"))).toBe(false);
+  });
+
   it("custom service 走 live probe + bank 补元数据", async () => {
     global.fetch = vi.fn().mockResolvedValue({
       ok: true,

+ 3 - 3
packages/core/src/llm/providers/endpoints/google.ts

@@ -23,14 +23,14 @@ export const GOOGLE: InkosEndpoint = {
     { id: "gemini-pro-latest", maxOutput: 65536, contextWindowTokens: 1114112 },
     { id: "gemini-flash-latest", maxOutput: 65536, contextWindowTokens: 1114112 },
     { id: "gemini-flash-lite-latest", maxOutput: 65536, contextWindowTokens: 1114112 },
-    { id: "gemini-3.1-flash-image-preview", maxOutput: 32768, contextWindowTokens: 163840, enabled: true, releasedAt: "2026-02-26" },
+    { id: "gemini-3.1-flash-image-preview", maxOutput: 32768, contextWindowTokens: 163840, enabled: false, releasedAt: "2026-02-26" },
     { id: "gemini-3.1-pro-preview", maxOutput: 65536, contextWindowTokens: 1114112, enabled: true, releasedAt: "2026-02-19" },
     { id: "gemini-3.1-flash-lite-preview", maxOutput: 65536, contextWindowTokens: 1114112, enabled: true, releasedAt: "2026-03-04" },
     { id: "gemini-3-flash-preview", maxOutput: 65536, contextWindowTokens: 1114112, enabled: true, releasedAt: "2025-12-17" },
-    { id: "gemini-3-pro-image-preview", maxOutput: 32768, contextWindowTokens: 163840, enabled: true, releasedAt: "2025-11-20" },
+    { id: "gemini-3-pro-image-preview", maxOutput: 32768, contextWindowTokens: 163840, enabled: false, releasedAt: "2025-11-20" },
     { id: "gemini-2.5-pro", maxOutput: 65536, contextWindowTokens: 1114112, releasedAt: "2025-06-17" },
     { id: "gemini-2.5-flash", maxOutput: 65536, contextWindowTokens: 1114112, releasedAt: "2025-06-17" },
-    { id: "gemini-2.5-flash-image", maxOutput: 32768, contextWindowTokens: 98304, releasedAt: "2025-08-26" },
+    { id: "gemini-2.5-flash-image", maxOutput: 32768, contextWindowTokens: 98304, enabled: false, releasedAt: "2025-08-26" },
     { id: "gemini-2.5-flash-lite", maxOutput: 65536, contextWindowTokens: 1114112, releasedAt: "2025-07-22" },
     { id: "gemini-2.5-flash-lite-preview-09-2025", maxOutput: 65536, contextWindowTokens: 1114112, releasedAt: "2025-09-25" },
     { id: "gemini-2.0-flash", maxOutput: 8192, contextWindowTokens: 1056768, releasedAt: "2025-02-05" },

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

@@ -736,6 +736,34 @@ describe("createStudioServer daemon lifecycle", () => {
     ]);
   });
 
+  it("filters non-text models out of connected bank model groups", async () => {
+    loadSecretsMock.mockResolvedValue({
+      services: {
+        google: { apiKey: "sk-google" },
+      },
+    });
+    getAllEndpointsMock.mockReturnValueOnce([
+      {
+        id: "google",
+        label: "Google Gemini",
+        group: "overseas",
+        models: [
+          { id: "gemini-2.5-flash", maxOutput: 65536, contextWindowTokens: 1114112, enabled: true },
+          { id: "gemini-3.1-flash-image-preview", maxOutput: 32768, contextWindowTokens: 163840, enabled: true },
+          { id: "text-embedding-004", maxOutput: 2048, contextWindowTokens: 2048, enabled: true },
+        ],
+      },
+    ] as never);
+
+    const { createStudioServer } = await import("./server.js");
+    const app = createStudioServer(cloneProjectConfig() as never, root);
+
+    const response = await app.request("http://localhost/api/v1/services/models");
+    expect(response.status).toBe(200);
+    const body = await response.json() as { groups: Array<{ service: string; models: Array<{ id: string }> }> };
+    expect(body.groups[0]?.models.map((m) => m.id)).toEqual(["gemini-2.5-flash"]);
+  });
+
   it("returns custom model groups through the slow probe path", async () => {
     await writeFile(join(root, "inkos.json"), JSON.stringify({
       ...projectConfig,
@@ -772,6 +800,26 @@ describe("createStudioServer daemon lifecycle", () => {
     );
   });
 
+  it("filters non-text models out of live service model lists", async () => {
+    getServiceApiKeyMock.mockResolvedValue("sk-google");
+    listModelsForServiceMock.mockResolvedValueOnce([
+      { id: "gemini-2.5-flash", name: "gemini-2.5-flash", reasoning: false, contextWindow: 1114112 },
+      { id: "gemini-3.1-flash-image-preview", name: "gemini-3.1-flash-image-preview", reasoning: false, contextWindow: 163840 },
+      { id: "text-embedding-004", name: "text-embedding-004", reasoning: false, contextWindow: 2048 },
+    ]);
+
+    const { createStudioServer } = await import("./server.js");
+    const app = createStudioServer(cloneProjectConfig() as never, root);
+
+    const response = await app.request("http://localhost/api/v1/services/google/models?refresh=1");
+    expect(response.status).toBe(200);
+    await expect(response.json()).resolves.toEqual({
+      models: [
+        { id: "gemini-2.5-flash", name: "gemini-2.5-flash", contextWindow: 1114112 },
+      ],
+    });
+  });
+
   it("merges service config patches instead of overwriting existing services", async () => {
     await writeFile(join(root, "inkos.json"), JSON.stringify({
       ...projectConfig,
@@ -1736,6 +1784,35 @@ describe("createStudioServer daemon lifecycle", () => {
     });
   });
 
+  it("rejects explicit non-text models before running the agent", async () => {
+    resolveServiceModelMock.mockResolvedValue({
+      model: { id: "gemini-3.1-flash-image-preview", provider: "google", api: "openai-completions" },
+      apiKey: "sk-google",
+    });
+
+    const { createStudioServer } = await import("./server.js");
+    const app = createStudioServer(cloneProjectConfig() as never, root);
+
+    const response = await app.request("http://localhost/api/v1/agent", {
+      method: "POST",
+      headers: { "Content-Type": "application/json" },
+      body: JSON.stringify({
+        instruction: "nihao",
+        service: "google",
+        model: "gemini-3.1-flash-image-preview",
+        sessionId: "agent-session-1",
+      }),
+    });
+
+    expect(response.status).toBe(400);
+    await expect(response.json()).resolves.toMatchObject({
+      error: expect.stringContaining("不适合文本聊天"),
+      response: expect.stringContaining("gemini-3.1-flash-image-preview"),
+    });
+    expect(resolveServiceModelMock).not.toHaveBeenCalled();
+    expect(runAgentSessionMock).not.toHaveBeenCalled();
+  });
+
   it("returns 500 with an error payload when the agent session fails", async () => {
     runAgentSessionMock.mockRejectedValueOnce(new Error("boom"));
 

+ 38 - 5
packages/studio/src/api/server.ts

@@ -91,6 +91,31 @@ function summarizeResult(result: unknown): string {
   return String(result).slice(0, 200);
 }
 
+const NON_TEXT_MODEL_ID_PARTS = [
+  "image",
+  "embedding",
+  "embed",
+  "rerank",
+  "tts",
+  "speech",
+  "audio",
+  "moderation",
+] as const;
+
+function isTextChatModelId(modelId: string): boolean {
+  const normalized = modelId.trim().toLowerCase();
+  if (!normalized) return false;
+  return !NON_TEXT_MODEL_ID_PARTS.some((part) => normalized.includes(part));
+}
+
+function filterTextChatModels<T extends { readonly id: string }>(models: ReadonlyArray<T>): T[] {
+  return models.filter((model) => isTextChatModelId(model.id));
+}
+
+function nonTextModelMessage(modelId: string): string {
+  return `模型 ${modelId} 不适合文本聊天/写作。请在模型选择器中改用文本模型,例如 gemini-2.5-flash、gemini-2.5-pro 或对应服务的 chat 模型。`;
+}
+
 function extractToolError(result: unknown): string {
   if (typeof result === "string") return result.slice(0, 500);
   if (result && typeof result === "object") {
@@ -1152,6 +1177,7 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
       label: ep.label,
       models: ep.models
         .filter((m) => m.enabled !== false)
+        .filter((m) => isTextChatModelId(m.id))
         .map((m) => ({
           id: m.id,
           name: m.id,
@@ -1184,7 +1210,9 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
     const groups = await Promise.all(customs.map(async (s) => ({
       service: s.id,
       label: s.label,
-      models: await probeModelsFromUpstream(s.baseUrl, secrets.services[s.id].apiKey, 10_000),
+      models: filterTextChatModels(
+        await probeModelsFromUpstream(s.baseUrl, secrets.services[s.id].apiKey, 10_000),
+      ),
     })));
 
     return c.json({ groups });
@@ -1215,7 +1243,7 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
       apiKey,
       isCustomServiceId(service) ? resolvedBaseUrl ?? undefined : undefined,
     );
-    const models = enriched.map((m) => ({
+    const models = filterTextChatModels(enriched).map((m) => ({
       id: m.id,
       name: m.name,
       ...(m.maxOutput !== undefined ? { maxOutput: m.maxOutput } : {}),
@@ -1440,6 +1468,10 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
     if (!sessionId?.trim()) {
       throw new ApiError(400, "SESSION_ID_REQUIRED", "sessionId is required");
     }
+    if (reqModel && !isTextChatModelId(reqModel)) {
+      const message = nonTextModelMessage(reqModel);
+      return c.json({ error: message, response: message }, 400);
+    }
 
     broadcast("agent:start", { instruction, activeBookId, sessionId });
 
@@ -1495,7 +1527,7 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
         const defaultModel = rawConfig.defaultModel as string | undefined;
         const servicesArr = normalizeServiceConfig(rawConfig.services);
         const firstService = servicesArr[0];
-        if (firstService?.service && defaultModel) {
+        if (firstService?.service && defaultModel && isTextChatModelId(defaultModel)) {
           try {
             const resolved = await resolveServiceModel(
               serviceConfigKey(firstService),
@@ -1517,11 +1549,12 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
           if (svcData?.apiKey) {
             try {
               const models = await listModelsForService(svcName, svcData.apiKey);
-              if (models.length > 0) {
+              const textModels = filterTextChatModels(models);
+              if (textModels.length > 0) {
                 const configuredEntry = await resolveConfiguredServiceEntry(root, svcName);
                 const resolved = await resolveServiceModel(
                   svcName,
-                  models[0].id,
+                  textModels[0].id,
                   root,
                   await resolveConfiguredServiceBaseUrl(root, svcName),
                   configuredEntry?.apiFormat,

+ 6 - 7
packages/studio/src/pages/ChatPage.tsx

@@ -33,6 +33,7 @@ import {
 import {
   filterModelGroups,
   getBookCreateSessionId,
+  pickModelSelection,
   setBookCreateSessionId,
 } from "./chat-page-state";
 
@@ -120,15 +121,13 @@ 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 none selected
+  // Auto-select first model when models load, and replace stale selections after lists refresh.
   useEffect(() => {
-    if (!selectedModel && groupedModels.length > 0) {
-      const first = groupedModels[0];
-      if (first.models.length > 0) {
-        setSelectedModel(first.models[0].id, first.service);
-      }
+    const nextSelection = pickModelSelection(groupedModels, selectedModel, selectedService);
+    if (nextSelection) {
+      setSelectedModel(nextSelection.model, nextSelection.service);
     }
-  }, [groupedModels, selectedModel, setSelectedModel]);
+  }, [groupedModels, selectedModel, selectedService, setSelectedModel]);
 
   // Auto-resize textarea
   useEffect(() => {

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

@@ -3,6 +3,7 @@ import {
   clearBookCreateSessionId,
   filterModelGroups,
   getBookCreateSessionId,
+  pickModelSelection,
   setBookCreateSessionId,
 } from "./chat-page-state";
 
@@ -104,3 +105,44 @@ describe("filterModelGroups", () => {
     ]);
   });
 });
+
+describe("pickModelSelection", () => {
+  const grouped = [
+    {
+      service: "google",
+      label: "Google Gemini",
+      models: [
+        { id: "gemini-2.5-flash", name: "gemini-2.5-flash" },
+      ],
+    },
+    {
+      service: "moonshot",
+      label: "Moonshot",
+      models: [
+        { id: "kimi-k2.5", name: "kimi-k2.5" },
+      ],
+    },
+  ] as const;
+
+  it("keeps the current selection when it is still available", () => {
+    expect(pickModelSelection(grouped, "kimi-k2.5", "moonshot")).toBeNull();
+  });
+
+  it("selects the first available model when current selection is missing", () => {
+    expect(pickModelSelection(grouped, "gemini-3.1-flash-image-preview", "google")).toEqual({
+      model: "gemini-2.5-flash",
+      service: "google",
+    });
+  });
+
+  it("selects the first available model when there is no current selection", () => {
+    expect(pickModelSelection(grouped, null, null)).toEqual({
+      model: "gemini-2.5-flash",
+      service: "google",
+    });
+  });
+
+  it("returns null when no models are available", () => {
+    expect(pickModelSelection([], "gemini-3.1-flash-image-preview", "google")).toBeNull();
+  });
+});

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

@@ -40,3 +40,22 @@ export function filterModelGroups(
     }))
     .filter((group) => group.models.length > 0);
 }
+
+export function pickModelSelection(
+  groupedModels: ReadonlyArray<ChatPageModelGroup>,
+  selectedModel: string | null,
+  selectedService: string | null,
+): { model: string; service: string } | null {
+  const selectedStillAvailable = selectedModel && selectedService
+    ? groupedModels.some((group) =>
+        group.service === selectedService
+        && group.models.some((model) => model.id === selectedModel),
+      )
+    : false;
+  if (selectedStillAvailable) return null;
+
+  const firstGroup = groupedModels.find((group) => group.models.length > 0);
+  const firstModel = firstGroup?.models[0];
+  if (!firstGroup || !firstModel) return null;
+  return { model: firstModel.id, service: firstGroup.service };
+}