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

+ 18 - 1
packages/cli/src/__tests__/project-bootstrap.test.ts

@@ -42,7 +42,24 @@ describe("project bootstrap", () => {
     await ensureProjectDirectoryInitialized(tempDir, { language: "zh" });
 
     await expect(readFile(join(tempDir, ".env"), "utf-8")).resolves.toBe("EXISTING=1\n");
-    await expect(readFile(join(tempDir, ".gitignore"), "utf-8")).resolves.toBe("CUSTOM\n");
+    const gitignore = await readFile(join(tempDir, ".gitignore"), "utf-8");
+    expect(gitignore).toContain("CUSTOM\n");
+    expect(gitignore).toContain(".env\n");
+    expect(gitignore).toContain("node_modules/\n");
+    expect(gitignore).toContain(".DS_Store\n");
+  });
+
+  it("preserves an existing .gitignore when explicitly initializing", async () => {
+    await writeFile(join(tempDir, ".gitignore"), "dist/\n# keep me\n", "utf-8");
+
+    const { initializeProjectDirectory } = await import("../project-bootstrap.js");
+    await initializeProjectDirectory(tempDir, { language: "zh" });
+
+    const gitignore = await readFile(join(tempDir, ".gitignore"), "utf-8");
+    expect(gitignore).toContain("dist/\n# keep me\n");
+    expect(gitignore).toContain(".env\n");
+    expect(gitignore).toContain("node_modules/\n");
+    expect(gitignore).toContain(".DS_Store\n");
   });
 
   it("returns false when the directory is already an InkOS project", async () => {

+ 28 - 1
packages/cli/src/project-bootstrap.ts

@@ -32,6 +32,33 @@ async function writeMaybe(path: string, content: string, overwrite: boolean): Pr
   await writeFile(path, content, "utf-8");
 }
 
+const DEFAULT_GITIGNORE_ENTRIES = [".env", "node_modules/", ".DS_Store"] as const;
+
+export async function ensureProjectGitignore(projectDir: string): Promise<void> {
+  const path = join(projectDir, ".gitignore");
+  let existing = "";
+  if (await exists(path)) {
+    existing = await readFile(path, "utf-8");
+  }
+
+  const existingEntries = new Set(
+    existing
+      .split(/\r?\n/)
+      .map((line) => line.trim())
+      .filter((line) => line.length > 0 && !line.startsWith("#")),
+  );
+  const missing = DEFAULT_GITIGNORE_ENTRIES.filter((entry) => !existingEntries.has(entry));
+  if (missing.length === 0) return;
+
+  if (!existing) {
+    await writeFile(path, `${missing.join("\n")}\n`, "utf-8");
+    return;
+  }
+
+  const separator = existing.endsWith("\n") ? "" : "\n";
+  await writeFile(path, `${existing}${separator}${missing.join("\n")}\n`, "utf-8");
+}
+
 function buildProjectConfig(projectDir: string, language: "zh" | "en") {
   return {
     name: basename(projectDir),
@@ -119,7 +146,7 @@ export async function initializeProjectDirectory(
 
   await Promise.all([
     writeMaybe(join(projectDir, ".env"), buildProjectEnvTemplate(globalConfigured), overwriteSupportFiles),
-    writeMaybe(join(projectDir, ".gitignore"), [".env", "node_modules/", ".DS_Store"].join("\n"), overwriteSupportFiles),
+    ensureProjectGitignore(projectDir),
     writeMaybe(join(projectDir, ".nvmrc"), "22\n", overwriteSupportFiles),
     writeMaybe(join(projectDir, ".node-version"), "22\n", overwriteSupportFiles),
   ]);

+ 2 - 5
packages/cli/src/tui/setup.ts

@@ -10,6 +10,7 @@ import {
 } from "./ansi.js";
 import { resolveTuiLocale, type TuiLocale } from "./i18n.js";
 import { GLOBAL_ENV_PATH } from "../utils.js";
+import { ensureProjectGitignore } from "../project-bootstrap.js";
 
 const PROVIDERS = ["openai", "anthropic", "custom"] as const;
 
@@ -268,11 +269,7 @@ async function autoInit(cwd: string): Promise<void> {
     );
   }
 
-  await writeFile(
-    join(cwd, ".gitignore"),
-    [".env", "node_modules/", ".DS_Store"].join("\n"),
-    "utf-8",
-  );
+  await ensureProjectGitignore(cwd);
 
   console.log(`  ${c("✓", brightGreen, bold)} ${c(messages.initialized, dim)}`);
 }

+ 111 - 0
packages/core/src/__tests__/provider.test.ts

@@ -288,6 +288,117 @@ describe("chatCompletion via pi-ai", () => {
     vi.unstubAllGlobals();
   });
 
+  it("uses reasoning_content for custom openai-compatible non-stream responses that omit content", async () => {
+    const fetchMock = vi.fn().mockResolvedValue({
+      ok: true,
+      json: async () => ({
+        choices: [{ message: { reasoning_content: "推理通道文本" } }],
+        usage: { prompt_tokens: 3, completion_tokens: 2, total_tokens: 5 },
+      }),
+    });
+    vi.stubGlobal("fetch", fetchMock);
+
+    const client = makeClient(0.7, {
+      service: "custom",
+      stream: false,
+      _piModel: {
+        ...MOCK_PI_MODEL,
+        provider: "openai",
+        baseUrl: "https://gateway.example/v1",
+      },
+    });
+    const result = await chatCompletion(client, "glm-compat", [{ role: "user", content: "nihao" }]);
+
+    expect(result.content).toBe("推理通道文本");
+    expect(fetchMock).toHaveBeenCalledOnce();
+
+    vi.unstubAllGlobals();
+  });
+
+  it("uses reasoning_content for custom openai-compatible streams that omit content deltas", async () => {
+    const encoder = new TextEncoder();
+    const sse = [
+      "data: {\"choices\":[{\"delta\":{\"reasoning_content\":\"你\"}}]}\n\n",
+      "data: {\"choices\":[{\"delta\":{\"reasoning_content\":\"好\"}}]}\n\n",
+      "data: {\"usage\":{\"prompt_tokens\":3,\"completion_tokens\":2,\"total_tokens\":5}}\n\n",
+      "data: [DONE]\n\n",
+    ].join("");
+    const fetchMock = vi.fn().mockResolvedValue({
+      ok: true,
+      body: new ReadableStream({
+        start(controller) {
+          controller.enqueue(encoder.encode(sse));
+          controller.close();
+        },
+      }),
+    });
+    vi.stubGlobal("fetch", fetchMock);
+
+    const client = makeClient(0.7, {
+      service: "custom",
+      stream: true,
+      _piModel: {
+        ...MOCK_PI_MODEL,
+        provider: "openai",
+        baseUrl: "https://gateway.example/v1",
+      },
+    });
+    const result = await chatCompletion(client, "glm-compat", [{ role: "user", content: "nihao" }]);
+
+    expect(result.content).toBe("你好");
+    expect(result.usage.totalTokens).toBe(5);
+    expect(fetchMock).toHaveBeenCalledOnce();
+
+    vi.unstubAllGlobals();
+  });
+
+  it("retries custom openai-compatible chat by folding system messages into user when system role is unsupported", async () => {
+    const fetchMock = vi.fn()
+      .mockResolvedValueOnce({
+        ok: false,
+        status: 400,
+        statusText: "Bad Request",
+        text: async () => JSON.stringify({ error: { message: "role system is unsupported" } }),
+      })
+      .mockResolvedValueOnce({
+        ok: true,
+        json: async () => ({
+          choices: [{ message: { content: "ok" } }],
+          usage: { prompt_tokens: 9, completion_tokens: 1, total_tokens: 10 },
+        }),
+      });
+    vi.stubGlobal("fetch", fetchMock);
+
+    const client = makeClient(0.7, {
+      service: "custom",
+      stream: false,
+      _piModel: {
+        ...MOCK_PI_MODEL,
+        provider: "openai",
+        baseUrl: "https://gateway.example/v1",
+      },
+    });
+    const result = await chatCompletion(client, "wild-compatible", [
+      { role: "system", content: "只输出中文。" },
+      { role: "user", content: "ping" },
+    ]);
+
+    expect(result.content).toBe("ok");
+    expect(fetchMock).toHaveBeenCalledTimes(2);
+    const firstBody = JSON.parse(fetchMock.mock.calls[0]?.[1]?.body as string);
+    const secondBody = JSON.parse(fetchMock.mock.calls[1]?.[1]?.body as string);
+    expect(firstBody.messages).toEqual([
+      { role: "system", content: "只输出中文。" },
+      { role: "user", content: "ping" },
+    ]);
+    expect(secondBody.messages).toHaveLength(1);
+    expect(secondBody.messages[0]).toMatchObject({ role: "user" });
+    expect(secondBody.messages[0].content).toContain("只输出中文。");
+    expect(secondBody.messages[0].content).toContain("ping");
+
+    vi.unstubAllGlobals();
+  });
+
   it("keeps legacy env custom openai-compatible chat on pi-ai path", async () => {
     const fetchMock = vi.fn();
     vi.stubGlobal("fetch", fetchMock);

+ 75 - 10
packages/core/src/llm/provider.ts

@@ -444,6 +444,38 @@ function buildResponsesInput(messages: ReadonlyArray<LLMMessage>): Array<{ role:
     }));
 }
 
+function hasSystemMessages(messages: ReadonlyArray<LLMMessage>): boolean {
+  return messages.some((message) => message.role === "system" && message.content.trim().length > 0);
+}
+
+function foldSystemMessagesIntoFirstUser(messages: ReadonlyArray<LLMMessage>): LLMMessage[] {
+  const system = joinSystemPrompt(messages);
+  const nonSystemMessages = messages.filter((message) => message.role !== "system");
+  if (!system) return [...nonSystemMessages];
+
+  const firstUserIndex = nonSystemMessages.findIndex((message) => message.role === "user");
+  const prefix = `System instructions:\n${system}\n\nUser request:\n`;
+  if (firstUserIndex < 0) {
+    return [{ role: "user", content: `System instructions:\n${system}` }, ...nonSystemMessages];
+  }
+
+  return nonSystemMessages.map((message, index) => index === firstUserIndex
+    ? { ...message, content: `${prefix}${message.content}` }
+    : message);
+}
+
+function isSystemRoleUnsupportedErrorText(text: string): boolean {
+  const normalized = text.toLowerCase();
+  const mentionsSystemRole = normalized.includes("system") && normalized.includes("role");
+  if (!mentionsSystemRole) return false;
+  return normalized.includes("unsupported")
+    || normalized.includes("not support")
+    || normalized.includes("does not support")
+    || normalized.includes("invalid")
+    || normalized.includes("不支持")
+    || normalized.includes("不允许");
+}
+
 async function readErrorResponse(res: Response): Promise<string> {
   const text = await res.text().catch(() => "");
   try {
@@ -491,17 +523,29 @@ function parseSseEvents(buffer: string): { readonly events: ParsedSseEvent[]; re
   return { events, rest };
 }
 
-function extractChatContent(json: any): string {
-  const content = json?.choices?.[0]?.message?.content;
-  if (typeof content === "string") return content;
-  if (Array.isArray(content)) {
-    return content
+function extractOpenAITextPart(value: any): string {
+  if (typeof value === "string") return value;
+  if (Array.isArray(value)) {
+    return value
       .map((item) => typeof item?.text === "string" ? item.text : typeof item?.content === "string" ? item.content : "")
       .join("");
   }
   return "";
 }
 
+function extractChatContent(json: any): string {
+  const message = json?.choices?.[0]?.message;
+  return extractOpenAITextPart(message?.content) || extractOpenAITextPart(message?.reasoning_content);
+}
+
+function extractChatDeltaContent(json: any): string {
+  return extractOpenAITextPart(json?.choices?.[0]?.delta?.content);
+}
+
+function extractChatDeltaReasoningContent(json: any): string {
+  return extractOpenAITextPart(json?.choices?.[0]?.delta?.reasoning_content);
+}
+
 function extractResponsesContent(json: any): string {
   const output = Array.isArray(json?.output) ? json.output : [];
   return output
@@ -631,6 +675,7 @@ async function chatCompletionViaCustomOpenAICompatible(
   resolved: { readonly temperature: number; readonly maxTokens: number; readonly extra: Record<string, unknown> },
   onStreamProgress?: OnStreamProgress,
   onTextDelta?: (text: string) => void,
+  allowSystemRoleFallback = true,
 ): Promise<LLMResponse> {
   if (client.provider === "anthropic") {
     return chatCompletionViaCustomAnthropicCompatible(client, model, messages, resolved, onStreamProgress, onTextDelta);
@@ -746,7 +791,19 @@ async function chatCompletionViaCustomOpenAICompatible(
     body: JSON.stringify(payload),
   });
   if (!response.ok) {
-    throw wrapLLMError(new Error(await readErrorResponse(response)), errorCtx);
+    const detail = await readErrorResponse(response);
+    if (allowSystemRoleFallback && hasSystemMessages(messages) && isSystemRoleUnsupportedErrorText(detail)) {
+      return chatCompletionViaCustomOpenAICompatible(
+        client,
+        model,
+        foldSystemMessagesIntoFirstUser(messages),
+        resolved,
+        onStreamProgress,
+        onTextDelta,
+        false,
+      );
+    }
+    throw wrapLLMError(new Error(detail), errorCtx);
   }
 
   if (!client.stream) {
@@ -770,6 +827,7 @@ async function chatCompletionViaCustomOpenAICompatible(
   const decoder = new TextDecoder();
   let buffer = "";
   let content = "";
+  let reasoningContent = "";
   let usage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 };
 
   try {
@@ -782,11 +840,17 @@ async function chatCompletionViaCustomOpenAICompatible(
       for (const event of parsed.events) {
         if (!event.data || event.data === "[DONE]") continue;
         const json = JSON.parse(event.data);
-        const delta = json?.choices?.[0]?.delta?.content;
-        if (typeof delta === "string") {
+        const delta = extractChatDeltaContent(json);
+        if (delta) {
           content += delta;
           monitor.onChunk(delta);
           onTextDelta?.(delta);
+        } else {
+          const reasoningDelta = extractChatDeltaReasoningContent(json);
+          if (reasoningDelta) {
+            reasoningContent += reasoningDelta;
+            monitor.onChunk(reasoningDelta);
+          }
         }
         if (json?.usage) {
           usage = {
@@ -801,10 +865,11 @@ async function chatCompletionViaCustomOpenAICompatible(
     monitor.stop();
   }
 
-  if (!content) {
+  const finalContent = content || reasoningContent;
+  if (!finalContent) {
     throw wrapLLMError(new Error("LLM returned empty response from stream"), errorCtx);
   }
-  return { content, usage };
+  return { content: finalContent, usage };
 }
 
 // === Simple Chat (used by all agents via BaseAgent.chat()) ===

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

@@ -191,6 +191,7 @@ export function ServiceDetailPage({ serviceId, nav }: { serviceId: string; nav:
         setStatus(result.status);
       } else {
         setStatus(result.status);
+        if (result.status.state === "error") return;
       }
       await refreshServices();
       nav.toServices();

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

@@ -53,12 +53,20 @@ describe("matchServiceConfigEntryForDetail", () => {
 });
 
 describe("saveServiceConfig", () => {
-  it("persists secrets/config without probing the upstream service", async () => {
+  it("validates the upstream service before persisting secrets/config", async () => {
     const calls: string[] = [];
     const bodies: unknown[] = [];
     const fetchJsonImpl = vi.fn(async (path: string, init?: { body?: string }) => {
       calls.push(path);
       if (init?.body) bodies.push(JSON.parse(init.body));
+      if (path === "/services/openai/test") {
+        return {
+          ok: true,
+          models: [{ id: "gpt-5.5" }],
+          selectedModel: "gpt-5.5",
+          detected: { apiFormat: "chat", stream: true },
+        };
+      }
       if (path === "/services/openai/secret") return { ok: true };
       if (path === "/services/config") return { ok: true };
       throw new Error(`unexpected path: ${path}`);
@@ -79,31 +87,40 @@ describe("saveServiceConfig", () => {
     });
 
     expect(calls).toEqual([
+      "/services/openai/test",
       "/services/openai/secret",
       "/services/config",
     ]);
     expect(bodies).toEqual([
+      { apiKey: "sk-live", apiFormat: "chat", stream: true },
       { apiKey: "sk-live" },
       {
         service: "openai",
+        defaultModel: "gpt-5.5",
         services: [
           { service: "openai", temperature: 0.7, apiFormat: "chat", stream: true },
         ],
       },
     ]);
     expect(result).toEqual({
-      detectedModel: "",
-      detectedConfig: null,
-      status: { state: "saved" },
+      detectedModel: "gpt-5.5",
+      detectedConfig: { apiFormat: "chat", stream: true },
+      status: { state: "connected", models: [{ id: "gpt-5.5" }] },
     });
   });
 
-  it("still persists secrets/config when the key has not been manually tested", async () => {
+  it("does not persist secrets/config when validation fails", async () => {
     const calls: string[] = [];
-    const fetchJsonImpl = vi.fn(async (path: string) => {
+    const fetchJsonImpl = vi.fn(async (path: string, init?: { body?: string }) => {
       calls.push(path);
-      if (path === "/services/openai/secret") return { ok: true };
-      if (path === "/services/config") return { ok: true };
+      if (path === "/services/openai/test") {
+        expect(init?.body ? JSON.parse(init.body) : null).toEqual({
+          apiKey: "sk-bad",
+          apiFormat: "chat",
+          stream: true,
+        });
+        return { ok: false, error: "invalid key" };
+      }
       throw new Error(`unexpected path: ${path}`);
     });
 
@@ -119,11 +136,12 @@ describe("saveServiceConfig", () => {
       temperature: "0.7",
       detectedModel: "",
       fetchJsonImpl: fetchJsonImpl as never,
-    })).resolves.toMatchObject({ status: { state: "saved" } });
+    })).resolves.toEqual({
+      detectedModel: "",
+      detectedConfig: null,
+      status: { state: "error", message: "invalid key" },
+    });
 
-    expect(calls).toEqual([
-      "/services/openai/secret",
-      "/services/config",
-    ]);
+    expect(calls).toEqual(["/services/openai/test"]);
   });
 });

+ 52 - 7
packages/studio/src/pages/service-detail-state.ts

@@ -114,6 +114,51 @@ export async function saveServiceConfig(args: {
   const trimmedKey = args.apiKey.trim();
   const trimmedBaseUrl = args.baseUrl.trim();
 
+  if (!trimmedKey) {
+    return {
+      status: { state: "error", message: "请先输入 API Key" },
+      detectedModel: "",
+      detectedConfig: null,
+    };
+  }
+  if (args.isCustom && !trimmedBaseUrl) {
+    return {
+      status: { state: "error", message: "请先填写 Base URL" },
+      detectedModel: "",
+      detectedConfig: null,
+    };
+  }
+
+  let probe: ServiceProbeResponse;
+  try {
+    probe = await probeServiceForDetail(args.effectiveServiceId, {
+      apiKey: trimmedKey,
+      apiFormat: args.apiFormat,
+      stream: args.stream,
+      ...(args.isCustom ? { baseUrl: trimmedBaseUrl } : {}),
+    }, { fetchJsonImpl });
+  } catch (error) {
+    return {
+      status: { state: "error", message: error instanceof Error ? error.message : "连接失败" },
+      detectedModel: "",
+      detectedConfig: null,
+    };
+  }
+
+  if (!probe.ok) {
+    return {
+      status: { state: "error", message: probe.error ?? "连接失败" },
+      detectedModel: "",
+      detectedConfig: null,
+    };
+  }
+
+  const detectedModel = probe.selectedModel ?? args.detectedModel;
+  const detectedConfig = probe.detected ?? null;
+  const savedApiFormat = detectedConfig?.apiFormat ?? args.apiFormat;
+  const savedStream = typeof detectedConfig?.stream === "boolean" ? detectedConfig.stream : args.stream;
+  const savedBaseUrl = args.isCustom ? (detectedConfig?.baseUrl ?? trimmedBaseUrl) : undefined;
+
   await fetchJsonImpl(`/services/${encodeURIComponent(args.effectiveServiceId)}/secret`, {
     method: "PUT",
     headers: { "Content-Type": "application/json" },
@@ -125,16 +170,16 @@ export async function saveServiceConfig(args: {
     headers: { "Content-Type": "application/json" },
     body: JSON.stringify({
       service: args.effectiveServiceId,
-      ...(args.detectedModel ? { defaultModel: args.detectedModel } : {}),
+      ...(detectedModel ? { defaultModel: detectedModel } : {}),
       services: [
         {
           service: args.isCustom ? "custom" : args.serviceId,
           temperature: parseFloat(args.temperature),
-          apiFormat: args.apiFormat,
-          stream: args.stream,
+          apiFormat: savedApiFormat,
+          stream: savedStream,
           ...(args.isCustom ? {
             name: args.resolvedCustomName,
-            baseUrl: trimmedBaseUrl,
+            baseUrl: savedBaseUrl,
           } : {}),
         },
       ],
@@ -142,8 +187,8 @@ export async function saveServiceConfig(args: {
   });
 
   return {
-    status: { state: "saved" },
-    detectedModel: "",
-    detectedConfig: null,
+    status: { state: "connected", models: probe.models ?? [] },
+    detectedModel,
+    detectedConfig,
   };
 }