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

feat(studio): allow switching between env and Studio LLM config

Ma 6 месяцев назад
Родитель
Сommit
1a79f77116

+ 42 - 0
packages/core/src/__tests__/config-loader.test.ts

@@ -150,4 +150,46 @@ describe("loadProjectConfig local provider auth", () => {
     expect(config.llm.apiKey).toBe("sk-corp");
     expect(config.llm.temperature).toBe(0.9);
   });
+
+  it("keeps Studio config active when llm.configSource is studio", async () => {
+    root = await mkdtemp(join(tmpdir(), "inkos-config-loader-studio-source-"));
+    for (const key of ENV_KEYS) {
+      previousEnv.set(key, process.env[key]);
+      process.env[key] = "";
+    }
+
+    await writeFile(join(root, "inkos.json"), JSON.stringify({
+      name: "studio-source-project",
+      version: "0.1.0",
+      language: "zh",
+      llm: {
+        configSource: "studio",
+        services: [
+          { service: "custom", name: "内网GPT", baseUrl: "https://llm.internal.corp/v1", temperature: 0.9 },
+        ],
+        defaultModel: "corp-chat",
+      },
+      notify: [],
+    }, null, 2), "utf-8");
+    await writeFile(join(root, ".env"), [
+      "INKOS_LLM_PROVIDER=openai",
+      "INKOS_LLM_BASE_URL=https://api-vip.codex-for.me/v1",
+      "INKOS_LLM_MODEL=gpt-5.4",
+      "INKOS_LLM_API_KEY=sk-env",
+    ].join("\n"), "utf-8");
+    await mkdir(join(root, ".inkos"), { recursive: true });
+    await writeFile(
+      join(root, ".inkos", "secrets.json"),
+      JSON.stringify({ services: { "custom:内网GPT": { apiKey: "sk-corp" } } }, null, 2),
+      "utf-8",
+    );
+
+    const config = await loadProjectConfig(root);
+
+    expect(config.llm.configSource).toBe("studio");
+    expect(config.llm.provider).toBe("custom");
+    expect(config.llm.baseUrl).toBe("https://llm.internal.corp/v1");
+    expect(config.llm.model).toBe("corp-chat");
+    expect(config.llm.apiKey).toBe("sk-corp");
+  });
 });

+ 1 - 0
packages/core/src/__tests__/pipeline-runner.test.ts

@@ -248,6 +248,7 @@ describe("PipelineRunner", () => {
         defaultLLMConfig: {
           provider: "custom",
           service: "custom",
+          configSource: "env",
           baseUrl: "https://base.example/v1",
           apiKey: "base-key",
           model: "base-model",

+ 1 - 0
packages/core/src/models/project.ts

@@ -11,6 +11,7 @@ const LLMServiceEntrySchema = z.object({
 export const LLMConfigSchema = z.object({
   provider: z.enum(["anthropic", "openai", "custom"]),
   service: z.string().default("custom"),
+  configSource: z.enum(["env", "studio"]).default("env"),
   baseUrl: z.string().url(),
   apiKey: z.string().default(""),
   model: z.string().min(1),

+ 1 - 0
packages/core/src/pipeline/runner.ts

@@ -394,6 +394,7 @@ export class PipelineRunner {
       client = createLLMClient({
         provider,
         service: base?.service ?? "custom",
+        configSource: base?.configSource ?? "env",
         baseUrl: override.baseUrl,
         apiKey,
         model: override.model,

+ 34 - 20
packages/core/src/utils/config-loader.ts

@@ -16,6 +16,8 @@ interface ServiceConfigEntry {
   readonly maxTokens?: number;
 }
 
+type LLMConfigSource = "env" | "studio";
+
 export function isApiKeyOptionalForEndpoint(params: {
   readonly provider?: string | undefined;
   readonly baseUrl?: string | undefined;
@@ -77,9 +79,11 @@ export async function loadProjectConfig(
     throw new Error(`inkos.json in ${root} is not valid JSON. Check the file for syntax errors.`);
   }
 
-  // .env overrides inkos.json for LLM settings
+  // llm.configSource controls whether INKOS_LLM_* env vars override project config
   const env = process.env;
   const llm = (config.llm ?? {}) as Record<string, unknown>;
+  const configSource = resolveConfigSource(llm.configSource);
+  llm.configSource = configSource;
 
   const normalizedServices = normalizeServiceEntries(llm.services);
   if (normalizedServices.length > 0) {
@@ -111,7 +115,7 @@ export async function loadProjectConfig(
         llm.maxTokens = selectedEntry.maxTokens;
       }
 
-      if (selectedServiceId && !env.INKOS_LLM_API_KEY) {
+      if (selectedServiceId && (configSource !== "env" || !env.INKOS_LLM_API_KEY)) {
         const secretApiKey = await getServiceApiKey(root, selectedServiceId);
         if (secretApiKey) {
           llm.apiKey = secretApiKey;
@@ -120,38 +124,44 @@ export async function loadProjectConfig(
     }
   }
 
-  if (env.INKOS_LLM_PROVIDER) llm.provider = env.INKOS_LLM_PROVIDER;
-  if (env.INKOS_LLM_BASE_URL) llm.baseUrl = env.INKOS_LLM_BASE_URL;
-  if (env.INKOS_LLM_MODEL) llm.model = env.INKOS_LLM_MODEL;
-  if (env.INKOS_LLM_TEMPERATURE) llm.temperature = parseFloat(env.INKOS_LLM_TEMPERATURE);
-  if (env.INKOS_LLM_MAX_TOKENS) llm.maxTokens = parseInt(env.INKOS_LLM_MAX_TOKENS, 10);
-  if (env.INKOS_LLM_THINKING_BUDGET) llm.thinkingBudget = parseInt(env.INKOS_LLM_THINKING_BUDGET, 10);
+  if (configSource === "env") {
+    if (env.INKOS_LLM_PROVIDER) llm.provider = env.INKOS_LLM_PROVIDER;
+    if (env.INKOS_LLM_BASE_URL) llm.baseUrl = env.INKOS_LLM_BASE_URL;
+    if (env.INKOS_LLM_MODEL) llm.model = env.INKOS_LLM_MODEL;
+    if (env.INKOS_LLM_TEMPERATURE) llm.temperature = parseFloat(env.INKOS_LLM_TEMPERATURE);
+    if (env.INKOS_LLM_MAX_TOKENS) llm.maxTokens = parseInt(env.INKOS_LLM_MAX_TOKENS, 10);
+    if (env.INKOS_LLM_THINKING_BUDGET) llm.thinkingBudget = parseInt(env.INKOS_LLM_THINKING_BUDGET, 10);
+  }
   // Extra params from env: INKOS_LLM_EXTRA_<key>=<value>
   const extraFromEnv: Record<string, unknown> = {};
-  for (const [key, value] of Object.entries(env)) {
-    if (key.startsWith("INKOS_LLM_EXTRA_") && value) {
-      const paramName = key.slice("INKOS_LLM_EXTRA_".length);
-      // Auto-coerce: numbers, booleans, JSON objects
-      if (/^\d+(\.\d+)?$/.test(value)) extraFromEnv[paramName] = parseFloat(value);
-      else if (value === "true") extraFromEnv[paramName] = true;
-      else if (value === "false") extraFromEnv[paramName] = false;
-      else if (value.startsWith("{") || value.startsWith("[")) {
-        try { extraFromEnv[paramName] = JSON.parse(value); } catch { extraFromEnv[paramName] = value; }
+  if (configSource === "env") {
+    for (const [key, value] of Object.entries(env)) {
+      if (key.startsWith("INKOS_LLM_EXTRA_") && value) {
+        const paramName = key.slice("INKOS_LLM_EXTRA_".length);
+        // Auto-coerce: numbers, booleans, JSON objects
+        if (/^\d+(\.\d+)?$/.test(value)) extraFromEnv[paramName] = parseFloat(value);
+        else if (value === "true") extraFromEnv[paramName] = true;
+        else if (value === "false") extraFromEnv[paramName] = false;
+        else if (value.startsWith("{") || value.startsWith("[")) {
+          try { extraFromEnv[paramName] = JSON.parse(value); } catch { extraFromEnv[paramName] = value; }
+        }
+        else extraFromEnv[paramName] = value;
       }
-      else extraFromEnv[paramName] = value;
     }
   }
   if (Object.keys(extraFromEnv).length > 0) {
     llm.extra = { ...(llm.extra as Record<string, unknown> ?? {}), ...extraFromEnv };
   }
-  if (env.INKOS_LLM_API_FORMAT) llm.apiFormat = env.INKOS_LLM_API_FORMAT;
+  if (configSource === "env" && env.INKOS_LLM_API_FORMAT) llm.apiFormat = env.INKOS_LLM_API_FORMAT;
   config.llm = llm;
 
   // Global language override
   if (env.INKOS_DEFAULT_LANGUAGE) config.language = env.INKOS_DEFAULT_LANGUAGE;
 
   // API key ONLY from env — never stored in inkos.json
-  const apiKey = env.INKOS_LLM_API_KEY || (typeof llm.apiKey === "string" ? llm.apiKey : "");
+  const apiKey = configSource === "env"
+    ? env.INKOS_LLM_API_KEY || (typeof llm.apiKey === "string" ? llm.apiKey : "")
+    : (typeof llm.apiKey === "string" ? llm.apiKey : "");
   const provider = typeof llm.provider === "string" ? llm.provider : undefined;
   const baseUrl = typeof llm.baseUrl === "string" ? llm.baseUrl : undefined;
   const apiKeyOptional = isApiKeyOptionalForEndpoint({ provider, baseUrl });
@@ -177,6 +187,10 @@ export async function loadProjectConfig(
   return ProjectConfigSchema.parse(config);
 }
 
+function resolveConfigSource(value: unknown): LLMConfigSource {
+  return value === "studio" ? "studio" : "env";
+}
+
 function normalizeServiceEntries(raw: unknown): ServiceConfigEntry[] {
   if (Array.isArray(raw)) {
     return raw

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

@@ -314,6 +314,7 @@ describe("createStudioServer daemon lifecycle", () => {
 
   afterEach(async () => {
     await rm(root, { recursive: true, force: true });
+    await rm(join(tmpdir(), "inkos-global.env"), { force: true });
   });
 
   it("returns from /api/daemon/start before the first write cycle finishes", async () => {
@@ -551,6 +552,83 @@ describe("createStudioServer daemon lifecycle", () => {
     ]);
   });
 
+  it("reports config source and detected env overrides for Studio switching", async () => {
+    await writeFile(join(root, ".env"), [
+      "INKOS_LLM_PROVIDER=openai",
+      "INKOS_LLM_BASE_URL=https://project.example.com/v1",
+      "INKOS_LLM_MODEL=gpt-5.4",
+      "INKOS_LLM_API_KEY=sk-project",
+    ].join("\n"), "utf-8");
+    await writeFile(join(tmpdir(), "inkos-global.env"), [
+      "INKOS_LLM_PROVIDER=openai",
+      "INKOS_LLM_BASE_URL=https://global.example.com/v1",
+      "INKOS_LLM_MODEL=gpt-4o",
+      "INKOS_LLM_API_KEY=sk-global",
+    ].join("\n"), "utf-8");
+    await writeFile(join(root, "inkos.json"), JSON.stringify({
+      ...projectConfig,
+      llm: {
+        ...projectConfig.llm,
+        configSource: "env",
+      },
+    }, 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({
+      configSource: "env",
+      envConfig: {
+        effectiveSource: "project",
+        project: {
+          detected: true,
+          baseUrl: "https://project.example.com/v1",
+          model: "gpt-5.4",
+          hasApiKey: true,
+        },
+        global: {
+          detected: true,
+          baseUrl: "https://global.example.com/v1",
+          model: "gpt-4o",
+          hasApiKey: true,
+        },
+      },
+    });
+  });
+
+  it("allows switching config source without overwriting services", async () => {
+    await writeFile(join(root, "inkos.json"), JSON.stringify({
+      ...projectConfig,
+      llm: {
+        services: [
+          { service: "moonshot", temperature: 1, maxTokens: 4096 },
+        ],
+        defaultModel: "kimi-k2.5",
+        configSource: "env",
+      },
+    }, null, 2), "utf-8");
+
+    const { createStudioServer } = await import("./server.js");
+    const app = createStudioServer(cloneProjectConfig() as never, root);
+
+    const save = await app.request("http://localhost/api/v1/services/config", {
+      method: "PUT",
+      headers: { "Content-Type": "application/json" },
+      body: JSON.stringify({ configSource: "studio" }),
+    });
+
+    expect(save.status).toBe(200);
+
+    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 },
+    ]);
+    expect(raw.llm.defaultModel).toBe("kimi-k2.5");
+  });
+
   it("tests and lists models for custom services using baseUrl and stored config", async () => {
     await writeFile(join(root, "inkos.json"), JSON.stringify({
       ...projectConfig,

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

@@ -25,6 +25,7 @@ import {
   saveSecrets,
   getServiceApiKey,
   listModelsForService,
+  GLOBAL_ENV_PATH,
   type ResolvedModel,
   type PipelineConfig,
   type ProjectConfig,
@@ -121,6 +122,22 @@ interface ServiceConfigEntry {
   maxTokens?: number;
 }
 
+type LLMConfigSource = "env" | "studio";
+
+interface EnvConfigSummary {
+  detected: boolean;
+  provider: string | null;
+  baseUrl: string | null;
+  model: string | null;
+  hasApiKey: boolean;
+}
+
+interface EnvConfigStatus {
+  project: EnvConfigSummary;
+  global: EnvConfigSummary;
+  effectiveSource: "project" | "global" | null;
+}
+
 function broadcast(event: string, data: unknown): void {
   for (const handler of subscribers) {
     handler(event, data);
@@ -163,6 +180,10 @@ function normalizeServiceEntry(serviceId: string, value: Record<string, unknown>
   };
 }
 
+function normalizeConfigSource(value: unknown): LLMConfigSource {
+  return value === "studio" ? "studio" : "env";
+}
+
 function normalizeServiceConfig(raw: unknown): ServiceConfigEntry[] {
   if (Array.isArray(raw)) {
     return raw
@@ -203,6 +224,54 @@ async function saveRawConfig(root: string, config: Record<string, unknown>): Pro
   await writeFile(join(root, "inkos.json"), JSON.stringify(config, null, 2), "utf-8");
 }
 
+async function readEnvConfigSummary(path: string): Promise<EnvConfigSummary> {
+  try {
+    const raw = await readFile(path, "utf-8");
+    const values = new Map<string, string>();
+
+    for (const line of raw.split(/\r?\n/)) {
+      const trimmed = line.trim();
+      if (!trimmed || trimmed.startsWith("#")) continue;
+      const match = trimmed.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
+      if (!match) continue;
+      const [, key, value] = match;
+      values.set(key, value.trim());
+    }
+
+    const provider = values.get("INKOS_LLM_PROVIDER") ?? null;
+    const baseUrl = values.get("INKOS_LLM_BASE_URL") ?? null;
+    const model = values.get("INKOS_LLM_MODEL") ?? null;
+    const apiKey = values.get("INKOS_LLM_API_KEY") ?? "";
+    const detected = Boolean(provider || baseUrl || model || apiKey);
+
+    return {
+      detected,
+      provider,
+      baseUrl,
+      model,
+      hasApiKey: apiKey.length > 0,
+    };
+  } catch {
+    return {
+      detected: false,
+      provider: null,
+      baseUrl: null,
+      model: null,
+      hasApiKey: false,
+    };
+  }
+}
+
+async function readEnvConfigStatus(root: string): Promise<EnvConfigStatus> {
+  const project = await readEnvConfigSummary(join(root, ".env"));
+  const global = await readEnvConfigSummary(GLOBAL_ENV_PATH);
+  return {
+    project,
+    global,
+    effectiveSource: project.detected ? "project" : global.detected ? "global" : null,
+  };
+}
+
 async function resolveConfiguredServiceBaseUrl(root: string, serviceId: string, inlineBaseUrl?: string): Promise<string | undefined> {
   if (inlineBaseUrl?.trim()) return inlineBaseUrl.trim();
 
@@ -650,20 +719,31 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
     const config = await loadRawConfig(root);
     const llm = (config.llm as Record<string, unknown> | undefined) ?? {};
     const services = normalizeServiceConfig(llm.services);
-    return c.json({ services, defaultModel: llm.defaultModel ?? null });
+    const envConfig = await readEnvConfigStatus(root);
+    return c.json({
+      services,
+      defaultModel: llm.defaultModel ?? null,
+      configSource: normalizeConfigSource(llm.configSource),
+      envConfig,
+    });
   });
 
   app.put("/api/v1/services/config", async (c) => {
-    const body = await c.req.json<{ services: unknown; defaultModel?: string }>();
+    const body = await c.req.json<{ services?: unknown; defaultModel?: string; configSource?: LLMConfigSource }>();
     const config = await loadRawConfig(root);
     config.llm = config.llm ?? {};
     const llm = config.llm as Record<string, unknown>;
-    const existingServices = normalizeServiceConfig(llm.services);
-    const incomingServices = normalizeServiceConfig(body.services);
-    llm.services = mergeServiceConfig(existingServices, incomingServices);
+    if (body.services !== undefined) {
+      const existingServices = normalizeServiceConfig(llm.services);
+      const incomingServices = normalizeServiceConfig(body.services);
+      llm.services = mergeServiceConfig(existingServices, incomingServices);
+    }
     if (body.defaultModel !== undefined) {
       llm.defaultModel = body.defaultModel;
     }
+    if (body.configSource !== undefined) {
+      llm.configSource = normalizeConfigSource(body.configSource);
+    }
     await saveRawConfig(root, config);
     return c.json({ ok: true });
   });

+ 140 - 0
packages/studio/src/components/ServiceConfigSourceCard.tsx

@@ -0,0 +1,140 @@
+import { useEffect, useState } from "react";
+import { fetchJson } from "../hooks/use-api";
+
+type ConfigSource = "env" | "studio";
+type EnvScope = "project" | "global" | null;
+
+interface EnvConfigSummary {
+  detected: boolean;
+  provider: string | null;
+  baseUrl: string | null;
+  model: string | null;
+  hasApiKey: boolean;
+}
+
+interface ServiceConfigPayload {
+  services: Array<Record<string, unknown>>;
+  defaultModel: string | null;
+  configSource: ConfigSource;
+  envConfig: {
+    project: EnvConfigSummary;
+    global: EnvConfigSummary;
+    effectiveSource: EnvScope;
+  };
+}
+
+export function ServiceConfigSourceCard({ onChange }: { onChange?: () => void }) {
+  const [data, setData] = useState<ServiceConfigPayload | null>(null);
+  const [saving, setSaving] = useState<ConfigSource | null>(null);
+  const [error, setError] = useState<string | null>(null);
+
+  const load = async () => {
+    try {
+      const payload = await fetchJson<ServiceConfigPayload>("/services/config");
+      setData(payload);
+      setError(null);
+    } catch (e) {
+      setError(e instanceof Error ? e.message : "读取配置来源失败");
+    }
+  };
+
+  useEffect(() => {
+    void load();
+  }, []);
+
+  const switchSource = async (configSource: ConfigSource) => {
+    setSaving(configSource);
+    try {
+      await fetchJson("/services/config", {
+        method: "PUT",
+        headers: { "Content-Type": "application/json" },
+        body: JSON.stringify({ configSource }),
+      });
+      await load();
+      onChange?.();
+    } catch (e) {
+      setError(e instanceof Error ? e.message : "切换配置来源失败");
+    } finally {
+      setSaving(null);
+    }
+  };
+
+  if (!data && !error) {
+    return (
+      <div className="rounded-xl border border-border/40 bg-card/70 p-4 text-sm text-muted-foreground/70">
+        正在读取配置来源…
+      </div>
+    );
+  }
+
+  if (!data) {
+    return (
+      <div className="rounded-xl border border-amber-500/30 bg-amber-500/[0.04] p-4 text-sm text-amber-600">
+        {error ?? "读取配置来源失败"}
+      </div>
+    );
+  }
+
+  const { configSource, envConfig } = data;
+  const activeEnvSummary = envConfig.effectiveSource === "project" ? envConfig.project : envConfig.global;
+  const envLabel = envConfig.effectiveSource === "project" ? "项目 .env" : envConfig.effectiveSource === "global" ? "全局 ~/.inkos/.env" : null;
+  const envDetected = envConfig.project.detected || envConfig.global.detected;
+
+  return (
+    <div className="rounded-xl border border-border/40 bg-card/70 p-4 space-y-3">
+      <div className="flex items-center justify-between gap-3">
+        <div>
+          <div className="text-sm font-medium">LLM 配置来源</div>
+          <div className="text-xs text-muted-foreground/70 mt-1">
+            当前策略:
+            <span className="text-foreground"> {configSource === "env" ? "优先使用 .env" : "优先使用 Studio 配置"}</span>
+          </div>
+        </div>
+        <div className="flex items-center gap-2">
+          <button
+            type="button"
+            onClick={() => void switchSource("studio")}
+            disabled={saving !== null || configSource === "studio"}
+            className="rounded-lg border border-border/50 px-3 py-1.5 text-xs hover:bg-secondary/50 disabled:opacity-50"
+          >
+            {saving === "studio" ? "切换中…" : "使用 Studio 配置"}
+          </button>
+          <button
+            type="button"
+            onClick={() => void switchSource("env")}
+            disabled={saving !== null || configSource === "env"}
+            className="rounded-lg border border-border/50 px-3 py-1.5 text-xs hover:bg-secondary/50 disabled:opacity-50"
+          >
+            {saving === "env" ? "切换中…" : "使用 .env 优先"}
+          </button>
+        </div>
+      </div>
+
+      {envDetected ? (
+        <div className="rounded-lg border border-amber-500/25 bg-amber-500/[0.04] p-3 text-xs text-muted-foreground/80 space-y-1.5">
+          <div className="text-foreground">
+            检测到 LLM 环境变量覆盖:
+            <span className="font-medium"> {envLabel ?? "已检测到但未定位来源"}</span>
+          </div>
+          {activeEnvSummary.baseUrl ? <div>Base URL: <span className="font-mono text-foreground">{activeEnvSummary.baseUrl}</span></div> : null}
+          {activeEnvSummary.model ? <div>Model: <span className="font-mono text-foreground">{activeEnvSummary.model}</span></div> : null}
+          {activeEnvSummary.provider ? <div>Provider: <span className="font-mono text-foreground">{activeEnvSummary.provider}</span></div> : null}
+          <div>API Key: <span className="text-foreground">{activeEnvSummary.hasApiKey ? "已设置" : "未设置"}</span></div>
+          <div className="text-muted-foreground/70 pt-1">
+            {configSource === "env"
+              ? "当前请求会优先使用这套 .env 配置。切到 Studio 配置后,将改用服务页里的 service/baseUrl/model。"
+              : "当前虽然检测到 .env,但已切到 Studio 配置;Studio 和 Agent 请求会忽略这套 LLM 覆盖。"}
+          </div>
+        </div>
+      ) : (
+        <div className="rounded-lg border border-border/30 bg-secondary/20 p-3 text-xs text-muted-foreground/75">
+          未检测到目录或全局 `.env` 里的 LLM 覆盖变量。当前会直接使用项目配置和 Studio 服务配置。
+        </div>
+      )}
+
+      {error ? (
+        <div className="text-xs text-rose-500">{error}</div>
+      ) : null}
+    </div>
+  );
+}

+ 3 - 0
packages/studio/src/pages/ServiceDetailPage.tsx

@@ -2,6 +2,7 @@ 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";
 
 interface Nav {
   toServices: () => void;
@@ -220,6 +221,8 @@ export function ServiceDetailPage({ serviceId, nav }: { serviceId: string; nav:
         )}
       </div>
 
+      <ServiceConfigSourceCard onChange={() => { void refreshServices(); }} />
+
       <div className="space-y-5">
         {/* Custom fields */}
         {isCustom && (

+ 4 - 0
packages/studio/src/pages/ServiceListPage.tsx

@@ -1,6 +1,7 @@
 import { useEffect } from "react";
 import { useServiceStore } from "../store/service";
 import type { ServiceInfo } from "../store/service";
+import { ServiceConfigSourceCard } from "../components/ServiceConfigSourceCard";
 
 interface Nav {
   toDashboard: () => void;
@@ -23,6 +24,7 @@ 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]);
 
@@ -38,6 +40,8 @@ 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} />)