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

fix(studio): 清理服务配置死代码并修复详情页行为

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

+ 4 - 7
packages/studio/src/api/server.test.ts

@@ -513,7 +513,6 @@ describe("createStudioServer daemon lifecycle", () => {
       body: JSON.stringify({
         language: "en",
         temperature: 0.2,
-        maxTokens: 2048,
         stream: true,
       }),
     });
@@ -524,7 +523,6 @@ describe("createStudioServer daemon lifecycle", () => {
     await expect(project.json()).resolves.toMatchObject({
       language: "en",
       temperature: 0.2,
-      maxTokens: 2048,
       stream: true,
     });
   });
@@ -776,7 +774,7 @@ describe("createStudioServer daemon lifecycle", () => {
       ...projectConfig,
       llm: {
         services: [
-          { service: "moonshot", temperature: 1, maxTokens: 4096, apiFormat: "chat", stream: true },
+          { service: "moonshot", temperature: 1, apiFormat: "chat", stream: true },
           { service: "custom", name: "内网GPT", baseUrl: "https://llm.internal.corp/v1", temperature: 0.9, apiFormat: "responses", stream: false },
         ],
         defaultModel: "kimi-k2.5",
@@ -793,7 +791,6 @@ describe("createStudioServer daemon lifecycle", () => {
         services: {
           moonshot: {
             temperature: 0.5,
-            maxTokens: 2048,
             apiFormat: "responses",
             stream: false,
           },
@@ -805,7 +802,7 @@ describe("createStudioServer daemon lifecycle", () => {
 
     const raw = JSON.parse(await readFile(join(root, "inkos.json"), "utf-8"));
     expect(raw.llm.services).toEqual([
-      { service: "moonshot", temperature: 0.5, maxTokens: 2048, apiFormat: "responses", stream: false },
+      { service: "moonshot", temperature: 0.5, apiFormat: "responses", stream: false },
       { service: "custom", name: "内网GPT", baseUrl: "https://llm.internal.corp/v1", temperature: 0.9, apiFormat: "responses", stream: false },
     ]);
   });
@@ -861,7 +858,7 @@ describe("createStudioServer daemon lifecycle", () => {
       ...projectConfig,
       llm: {
         services: [
-          { service: "moonshot", temperature: 1, maxTokens: 4096 },
+          { service: "moonshot", temperature: 1 },
         ],
         defaultModel: "kimi-k2.5",
         configSource: "env",
@@ -882,7 +879,7 @@ describe("createStudioServer daemon lifecycle", () => {
     const raw = JSON.parse(await readFile(join(root, "inkos.json"), "utf-8"));
     expect(raw.llm.configSource).toBe("studio");
     expect(raw.llm.services).toEqual([
-      { service: "moonshot", temperature: 1, maxTokens: 4096 },
+      { service: "moonshot", temperature: 1 },
     ]);
     expect(raw.llm.defaultModel).toBe("kimi-k2.5");
   });

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

@@ -132,7 +132,6 @@ interface ServiceConfigEntry {
   name?: string;
   baseUrl?: string;
   temperature?: number;
-  maxTokens?: number;
   apiFormat?: "chat" | "responses";
   stream?: boolean;
 }
@@ -185,7 +184,6 @@ function normalizeServiceEntry(serviceId: string, value: Record<string, unknown>
       name: decodeURIComponent(serviceId.slice("custom:".length)),
       ...(typeof value.baseUrl === "string" && value.baseUrl.length > 0 ? { baseUrl: value.baseUrl } : {}),
       ...(typeof value.temperature === "number" ? { temperature: value.temperature } : {}),
-      ...(typeof value.maxTokens === "number" ? { maxTokens: value.maxTokens } : {}),
       ...(value.apiFormat === "chat" || value.apiFormat === "responses" ? { apiFormat: value.apiFormat } : {}),
       ...(typeof value.stream === "boolean" ? { stream: value.stream } : {}),
     };
@@ -197,7 +195,6 @@ function normalizeServiceEntry(serviceId: string, value: Record<string, unknown>
       ...(typeof value.name === "string" && value.name.length > 0 ? { name: value.name } : {}),
       ...(typeof value.baseUrl === "string" && value.baseUrl.length > 0 ? { baseUrl: value.baseUrl } : {}),
       ...(typeof value.temperature === "number" ? { temperature: value.temperature } : {}),
-      ...(typeof value.maxTokens === "number" ? { maxTokens: value.maxTokens } : {}),
       ...(value.apiFormat === "chat" || value.apiFormat === "responses" ? { apiFormat: value.apiFormat } : {}),
       ...(typeof value.stream === "boolean" ? { stream: value.stream } : {}),
     };
@@ -206,7 +203,6 @@ function normalizeServiceEntry(serviceId: string, value: Record<string, unknown>
   return {
     service: serviceId,
     ...(typeof value.temperature === "number" ? { temperature: value.temperature } : {}),
-    ...(typeof value.maxTokens === "number" ? { maxTokens: value.maxTokens } : {}),
     ...(value.apiFormat === "chat" || value.apiFormat === "responses" ? { apiFormat: value.apiFormat } : {}),
     ...(typeof value.stream === "boolean" ? { stream: value.stream } : {}),
   };
@@ -225,7 +221,6 @@ function normalizeServiceConfig(raw: unknown): ServiceConfigEntry[] {
         ...(typeof entry.name === "string" && entry.name.length > 0 ? { name: entry.name } : {}),
         ...(typeof entry.baseUrl === "string" && entry.baseUrl.length > 0 ? { baseUrl: entry.baseUrl } : {}),
         ...(typeof entry.temperature === "number" ? { temperature: entry.temperature } : {}),
-        ...(typeof entry.maxTokens === "number" ? { maxTokens: entry.maxTokens } : {}),
         ...(entry.apiFormat === "chat" || entry.apiFormat === "responses" ? { apiFormat: entry.apiFormat } : {}),
         ...(typeof entry.stream === "boolean" ? { stream: entry.stream } : {}),
       }));
@@ -1182,9 +1177,6 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
       if (updates.temperature !== undefined) {
         existing.llm.temperature = updates.temperature;
       }
-      if (updates.maxTokens !== undefined) {
-        existing.llm.maxTokens = updates.maxTokens;
-      }
       if (updates.stream !== undefined) {
         existing.llm.stream = updates.stream;
       }

+ 17 - 33
packages/studio/src/pages/ServiceDetailPage.tsx

@@ -2,8 +2,8 @@ import { useState, useEffect } from "react";
 import { fetchJson } from "../hooks/use-api";
 import { useServiceStore } from "../store/service";
 import { Eye, EyeOff, Loader2, ArrowLeft } from "lucide-react";
-import { ServiceConfigSourceCard } from "../components/ServiceConfigSourceCard";
 import {
+  matchServiceConfigEntryForDetail,
   probeServiceForDetail,
   rehydrateServiceConnectionStatus,
   saveServiceConfigWithValidation,
@@ -48,7 +48,6 @@ export function ServiceDetailPage({ serviceId, nav }: { serviceId: string; nav:
   const [customName, setCustomName] = useState("");
   const [baseUrl, setBaseUrl] = useState("");
   const [temperature, setTemperature] = useState("0.7");
-  const [maxTokens, setMaxTokens] = useState("4096");
   const [apiFormat, setApiFormat] = useState<"chat" | "responses">("chat");
   const [stream, setStream] = useState(true);
   const [detectedModel, setDetectedModel] = useState<string>("");
@@ -62,20 +61,13 @@ export function ServiceDetailPage({ serviceId, nav }: { serviceId: string; nav:
     void fetchJson<{ services: Array<Record<string, unknown>> }>("/services/config")
       .then((data) => {
         if (cancelled) return;
-        const matched = (data.services ?? []).find((entry) => {
-          if (typeof entry.service !== "string") return false;
-          if (serviceId.startsWith("custom:")) {
-            return entry.service === "custom" && `custom:${String(entry.name ?? "")}` === serviceId;
-          }
-          return entry.service === serviceId;
-        });
+        const matched = matchServiceConfigEntryForDetail(data.services ?? [], serviceId);
         if (!matched) return;
         if (isCustom) {
           setCustomName(String(matched.name ?? persistedCustomName));
           setBaseUrl(String(matched.baseUrl ?? ""));
         }
         if (typeof matched.temperature === "number") setTemperature(String(matched.temperature));
-        if (typeof matched.maxTokens === "number") setMaxTokens(String(matched.maxTokens));
         if (matched.apiFormat === "chat" || matched.apiFormat === "responses") setApiFormat(matched.apiFormat);
         if (typeof matched.stream === "boolean") setStream(matched.stream);
       })
@@ -86,12 +78,10 @@ export function ServiceDetailPage({ serviceId, nav }: { serviceId: string; nav:
   const resolvedCustomName = persistedCustomName || customName.trim() || "Custom";
   const effectiveServiceId = isCustom ? `custom:${resolvedCustomName}` : serviceId;
   const label = isCustom ? (customName || persistedCustomName || "自定义服务") : (svc?.label ?? serviceId);
+  const storeModels = useServiceStore((s) => s.modelsByService[effectiveServiceId]);
 
   useEffect(() => {
     let cancelled = false;
-    if (svc?.connected) {
-      setStatus({ state: "testing" });
-    }
     void rehydrateServiceConnectionStatus({
       effectiveServiceId,
       shouldVerify: Boolean(svc?.connected),
@@ -108,8 +98,6 @@ export function ServiceDetailPage({ serviceId, nav }: { serviceId: string; nav:
         setStatus(result.status);
         if (result.status.state === "connected") {
           setStoreModels(effectiveServiceId, result.status.models);
-        } else {
-          clearStoreModels(effectiveServiceId);
         }
       })
       .catch(() => {
@@ -120,7 +108,6 @@ export function ServiceDetailPage({ serviceId, nav }: { serviceId: string; nav:
   }, [
     apiFormat,
     baseUrl,
-    clearStoreModels,
     effectiveServiceId,
     isCustom,
     setStoreModels,
@@ -131,8 +118,8 @@ export function ServiceDetailPage({ serviceId, nav }: { serviceId: string; nav:
   if (loading) return <DetailSkeleton />;
 
   // -- Derived state --
-  const isConnected = status.state === "connected";
-  const models = status.state === "connected" ? status.models : [];
+  const isConnected = Boolean(svc?.connected);
+  const models = status.state === "connected" ? status.models : (storeModels ?? []);
   const isBusy = status.state === "testing" || status.state === "saving";
 
   // -- Handlers --
@@ -192,7 +179,6 @@ export function ServiceDetailPage({ serviceId, nav }: { serviceId: string; nav:
         apiFormat,
         stream,
         temperature,
-        maxTokens,
         detectedModel,
       });
       if (result.status.state === "connected") {
@@ -237,8 +223,6 @@ export function ServiceDetailPage({ serviceId, nav }: { serviceId: string; nav:
         )}
       </div>
 
-      <ServiceConfigSourceCard onChange={() => { void refreshServices(); }} />
-
       <div className="space-y-5">
         {/* Custom fields */}
         {isCustom && (
@@ -321,18 +305,22 @@ export function ServiceDetailPage({ serviceId, nav }: { serviceId: string; nav:
         </div>
 
         {/* Models */}
-        {models.length > 0 && (
+        {isConnected && (
           <div className="space-y-2">
             <p className="text-xs text-muted-foreground/70 font-medium uppercase tracking-wider">
               可用模型({models.length})
             </p>
-            <div className="flex gap-1.5 flex-wrap">
-              {models.map((m) => (
-                <span key={m.id} className="text-[11px] px-2.5 py-1 rounded-md bg-emerald-500/[0.06] text-emerald-600 dark:text-emerald-400 border border-emerald-500/15">
-                  {m.name ?? m.id}
-                </span>
-              ))}
-            </div>
+            {models.length > 0 ? (
+              <div className="flex gap-1.5 flex-wrap">
+                {models.map((m) => (
+                  <span key={m.id} className="text-[11px] px-2.5 py-1 rounded-md bg-emerald-500/[0.06] text-emerald-600 dark:text-emerald-400 border border-emerald-500/15">
+                    {m.name ?? m.id}
+                  </span>
+                ))}
+              </div>
+            ) : (
+              <p className="text-xs text-muted-foreground/60">点击“测试连接”查看可用模型</p>
+            )}
           </div>
         )}
 
@@ -350,10 +338,6 @@ export function ServiceDetailPage({ serviceId, nav }: { serviceId: string; nav:
                   min="0" max="2" step="0.05" className="w-16 rounded-md border border-border/60 bg-background px-2 py-1 text-xs text-right font-mono" />
               </div>
             </Field>
-            <Field label="maxTokens">
-              <input type="number" value={maxTokens} onChange={(e) => setMaxTokens(e.target.value)}
-                min="256" max="200000" step="256" className="w-full rounded-md border border-border/60 bg-background px-3 py-1.5 text-xs font-mono" />
-            </Field>
           </div>
         </details>
       </div>

+ 24 - 24
packages/studio/src/pages/service-detail-state.test.ts

@@ -1,23 +1,16 @@
 import { describe, expect, it, vi } from "vitest";
 import {
+  matchServiceConfigEntryForDetail,
   rehydrateServiceConnectionStatus,
   saveServiceConfigWithValidation,
 } from "./service-detail-state";
 
 describe("rehydrateServiceConnectionStatus", () => {
-  it("verifies the saved key via /test on page load instead of /models", async () => {
+  it("loads saved key without probing models on page load", async () => {
     const fetchJsonImpl = vi.fn(async (path: string) => {
       if (path === "/services/openai/secret") {
         return { apiKey: "sk-live" };
       }
-      if (path === "/services/openai/test") {
-        return {
-          ok: true,
-          models: [{ id: "gpt-5.4", name: "gpt-5.4" }],
-          selectedModel: "gpt-5.4",
-          detected: { apiFormat: "responses", stream: false, modelsSource: "api" },
-        };
-      }
       throw new Error(`unexpected path: ${path}`);
     });
 
@@ -31,25 +24,34 @@ describe("rehydrateServiceConnectionStatus", () => {
       fetchJsonImpl: fetchJsonImpl as never,
     });
 
-    expect(fetchJsonImpl).toHaveBeenCalledTimes(2);
-    expect(fetchJsonImpl).toHaveBeenNthCalledWith(1, "/services/openai/secret");
-    expect(fetchJsonImpl).toHaveBeenNthCalledWith(
-      2,
-      "/services/openai/test",
-      expect.objectContaining({ method: "POST" }),
-    );
+    expect(fetchJsonImpl).toHaveBeenCalledTimes(1);
+    expect(fetchJsonImpl).toHaveBeenCalledWith("/services/openai/secret");
     expect(result).toMatchObject({
       apiKey: "sk-live",
-      detectedModel: "gpt-5.4",
-      detectedConfig: { apiFormat: "responses", stream: false, modelsSource: "api" },
-      status: {
-        state: "connected",
-        models: [{ id: "gpt-5.4", name: "gpt-5.4" }],
-      },
+      detectedModel: "",
+      detectedConfig: null,
+      status: { state: "idle" },
     });
   });
 });
 
+describe("matchServiceConfigEntryForDetail", () => {
+  const entries = [
+    { service: "moonshot", temperature: 0.5 },
+    { service: "custom", name: "内网GPT", baseUrl: "https://llm.internal.corp/v1" },
+    { service: "custom", name: "本地Ollama", baseUrl: "http://localhost:11434/v1" },
+  ];
+
+  it("matches concrete custom services without treating bare custom as an existing config", () => {
+    expect(matchServiceConfigEntryForDetail(entries, "custom")).toBeUndefined();
+    expect(matchServiceConfigEntryForDetail(entries, "custom:内网GPT")).toEqual(entries[1]);
+  });
+
+  it("matches non-custom services by service id", () => {
+    expect(matchServiceConfigEntryForDetail(entries, "moonshot")).toEqual(entries[0]);
+  });
+});
+
 describe("saveServiceConfigWithValidation", () => {
   it("validates the key before persisting secrets/config", async () => {
     const calls: string[] = [];
@@ -78,7 +80,6 @@ describe("saveServiceConfigWithValidation", () => {
       apiFormat: "chat",
       stream: true,
       temperature: "0.7",
-      maxTokens: "4096",
       detectedModel: "",
       fetchJsonImpl: fetchJsonImpl as never,
     });
@@ -118,7 +119,6 @@ describe("saveServiceConfigWithValidation", () => {
       apiFormat: "chat",
       stream: true,
       temperature: "0.7",
-      maxTokens: "4096",
       detectedModel: "",
       fetchJsonImpl: fetchJsonImpl as never,
     })).rejects.toThrow("401 Unauthorized");

+ 18 - 55
packages/studio/src/pages/service-detail-state.ts

@@ -30,10 +30,6 @@ interface ServiceProbeResponse {
   readonly error?: string;
 }
 
-function toErrorMessage(error: unknown, fallback: string): string {
-  return error instanceof Error ? error.message : fallback;
-}
-
 export async function probeServiceForDetail(
   serviceId: string,
   body: {
@@ -75,57 +71,26 @@ export async function rehydrateServiceConnectionStatus(args: {
   );
   const apiKey = String(secret.apiKey ?? "");
 
-  if (!args.shouldVerify || apiKey.trim().length === 0) {
-    return {
-      apiKey,
-      status: { state: "idle" },
-      detectedModel: "",
-      detectedConfig: null,
-    };
-  }
-
-  if (args.isCustom && args.baseUrl.trim().length === 0) {
-    return {
-      apiKey,
-      status: { state: "idle" },
-      detectedModel: "",
-      detectedConfig: null,
-    };
-  }
+  return {
+    apiKey,
+    status: { state: "idle" },
+    detectedModel: "",
+    detectedConfig: null,
+  };
+}
 
-  try {
-    const result = await probeServiceForDetail(
-      args.effectiveServiceId,
-      {
-        apiKey: apiKey.trim(),
-        apiFormat: args.apiFormat,
-        stream: args.stream,
-        ...(args.isCustom ? { baseUrl: args.baseUrl.trim() } : {}),
-      },
-      { fetchJsonImpl },
-    );
-    if (!result.ok) {
-      return {
-        apiKey,
-        status: { state: "error", message: result.error ?? "连接失败" },
-        detectedModel: "",
-        detectedConfig: null,
-      };
+export function matchServiceConfigEntryForDetail(
+  entries: ReadonlyArray<Record<string, unknown>>,
+  serviceId: string,
+): Record<string, unknown> | undefined {
+  return entries.find((entry) => {
+    if (typeof entry.service !== "string") return false;
+    if (serviceId.startsWith("custom:")) {
+      return entry.service === "custom" && `custom:${String(entry.name ?? "")}` === serviceId;
     }
-    return {
-      apiKey,
-      status: { state: "connected", models: result.models ?? [] },
-      detectedModel: result.selectedModel ?? "",
-      detectedConfig: result.detected ?? null,
-    };
-  } catch (error) {
-    return {
-      apiKey,
-      status: { state: "error", message: toErrorMessage(error, "连接失败") },
-      detectedModel: "",
-      detectedConfig: null,
-    };
-  }
+    if (serviceId === "custom") return false;
+    return entry.service === serviceId;
+  });
 }
 
 export async function saveServiceConfigWithValidation(args: {
@@ -138,7 +103,6 @@ export async function saveServiceConfigWithValidation(args: {
   readonly apiFormat: "chat" | "responses";
   readonly stream: boolean;
   readonly temperature: string;
-  readonly maxTokens: string;
   readonly detectedModel: string;
   readonly fetchJsonImpl?: JsonFetcher;
 }): Promise<{
@@ -180,7 +144,6 @@ export async function saveServiceConfigWithValidation(args: {
         {
           service: args.isCustom ? "custom" : args.serviceId,
           temperature: parseFloat(args.temperature),
-          maxTokens: parseInt(args.maxTokens, 10),
           apiFormat: probeResult?.detected?.apiFormat ?? args.apiFormat,
           stream: typeof probeResult?.detected?.stream === "boolean" ? probeResult.detected.stream : args.stream,
           ...(args.isCustom ? {